diff --git a/.cfignore b/.cfignore index 92be10a235..f58e7a9d67 100644 --- a/.cfignore +++ b/.cfignore @@ -22,3 +22,4 @@ docs/ build/dev_config.json e2e-reports/ website/ +.helm-cache/ diff --git a/.github/workflows/documentation-versioning.yml b/.github/workflows/documentation-versioning.yml index f835ce78f3..73a411abc9 100644 --- a/.github/workflows/documentation-versioning.yml +++ b/.github/workflows/documentation-versioning.yml @@ -6,6 +6,7 @@ on: jobs: update-docs-internal-versions: + if: github.repository == 'cloudfoundry/stratos' runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index e99ddf7e80..114c896682 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -8,7 +8,7 @@ on: jobs: build-docs: - if: github.event_name != 'push' + if: github.event_name != 'push' && github.repository == 'cloudfoundry/stratos' runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 @@ -27,7 +27,7 @@ jobs: fi npm run build publish-docs: - if: github.event_name != 'pull_request' + if: github.event_name != 'pull_request' && github.repository == 'cloudfoundry/stratos' runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 @@ -63,4 +63,4 @@ jobs: npm i fi echo "Deploying web site... hang tight" - ./deploy.sh \ No newline at end of file + ./deploy.sh diff --git a/.gitignore b/.gitignore index 5bf171ab33..315651887b 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,9 @@ src/jetstream/jetstream src/jetstream/console-database.db src/jetstream/config.properties src/jetstream/db/dbconf.yml +src/jetstream/plugins/monocular/chart-repo/chartrepo +src/jetstream/plugins/analysis/container/analyzers +src/jetstream/.helm-cache # Automatically generated OpenAPI docs src/jetstream/docs/ @@ -135,4 +138,4 @@ website/versioned_sidebars website/versions.json website/versions-repo -/scan_tmp \ No newline at end of file +/scan_tmp diff --git a/angular.json b/angular.json index 6124c84bb9..90260ec293 100644 --- a/angular.json +++ b/angular.json @@ -28,7 +28,17 @@ "input": "custom-src/frontend/assets/custom", "output": "/core/assets/custom" }, - "src/frontend/packages/core/favicon.ico" + "src/frontend/packages/core/favicon.ico", + { + "glob": "**/*", + "input": "node_modules/ngx-monaco-editor/assets/monaco", + "output": "/core/assets/monaco" + }, + { + "glob": "**/*", + "input": "node_modules/@cfstratos/monaco-yaml/lib", + "output": "/core/assets/monaco/vs/language/yaml" + } ], "styles": [ "src/frontend/packages/core/src/styles.scss", @@ -338,7 +348,36 @@ } } } + }, + "kubernetes": { + "root": "src/frontend/packages/kubernetes", + "sourceRoot": "src/frontend/packages/kubernetes/src", + "projectType": "library", + "prefix": "lib", + "architect": { + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/frontend/packages/kubernetes/src/test.ts", + "tsConfig": "src/frontend/packages/kubernetes/tsconfig.spec.json", + "karmaConfig": "src/frontend/packages/kubernetes/karma.conf.js" + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": [ + "src/tsconfig.json" + ], + "tslintConfig": "src/frontend/packages/kubernetes/tslint.json", + "files": [ + "src/frontend/packages/kubernetes/src/**/*.ts" + ] + } + } + } } + }, "defaultProject": "stratos", "schematics": { diff --git a/build/tools/kube-terminal-dev.sh b/build/tools/kube-terminal-dev.sh new file mode 100755 index 0000000000..ce1ab1549e --- /dev/null +++ b/build/tools/kube-terminal-dev.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +# Colours +CYAN="\033[96m" +YELLOW="\033[93m" +RED="\033[91m" +RESET="\033[0m" +BOLD="\033[1m" + +# Program Paths: +PROG=$(basename ${BASH_SOURCE[0]}) +PROG_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +STRATOS_DIR="$( cd "${PROG_DIR}/../.." && pwd )" + +echo "Creating Service Account" +SRC="${STRATOS_DIR}/deploy/kubernetes/console/templates/service-account.yaml" + +TEMPFILE=$(mktemp) +cp $SRC $TEMPFILE +sed -i.bak '/\s*helm/d' $TEMPFILE +sed -i.bak '/\s*app\.kubernetes\.io\/version/d' $TEMPFILE +sed -i.bak '/\s*app\.kubernetes\.io\/instance/d' $TEMPFILE +sed -i.bak '/\s*{{-/d' $TEMPFILE + +# Create a namespace +NS="stratos-dev" +kubectl get ns $NS > /dev/null 2>&1 +if [ $? -ne 0 ]; then + kubectl create ns $NS +fi + +kubectl apply -n $NS -f $TEMPFILE +USER=stratos-dev-admin-user +USER=stratos + +# Service account should be created - now need to get token +SECRET=$(kubectl get -n $NS sa $USER -o json | jq -r '.secrets[0].name') +TOKEN=$(kubectl get -n $NS secret $SECRET -o json | jq -r '.data.token') +echo "Token secret: $SECRET" +TOKEN=$(echo $TOKEN | base64 -d -) +echo "Token $TOKEN" + +rm -f $TEMPFILE +rm -f $TEMPFILE.bak + +CFG=${STRATOS_DIR}/src/jetstream/config.properties +touch $CFG + +echo -e "\n# Kubernetes Terminal Config for dev" >> $CFG +echo "STRATOS_KUBERNETES_NAMESPACE=stratos-dev" >> $CFG +echo "STRATOS_KUBERNETES_TERMINAL_IMAGE=splatform/stratos-kube-terminal:dev" >> $CFG +echo "KUBE_TERMINAL_SERVICE_ACCOUNT_TOKEN=$TOKEN" >> $CFG + +MKUBE=$(minikube ip) +if [ $? -eq 0 ]; then + echo "KUBERNETES_SERVICE_HOST=$MKUBE" >> $CFG + echo "KUBERNETES_SERVICE_PORT=8443" >> $CFG +else + echo "KUBERNETES_SERVICE_HOST=" >> $CFG + echo "KUBERNETES_SERVICE_PORT=8443" >> $CFG +fi diff --git a/build/tools/mysqldb-dev.sh b/build/tools/mysqldb-dev.sh index 94aefd952f..157410950b 100755 --- a/build/tools/mysqldb-dev.sh +++ b/build/tools/mysqldb-dev.sh @@ -8,39 +8,9 @@ echo $STRATOS_PATH docker stop stratos-db docker rm stratos-db -ID=$(docker run --name stratos-db -d -e MYSQL_ROOT_PASSWORD=dbroot -p 3306:3306 splatform/stratos-mariadb) -echo $ID +IMAGE=mariadb:10.2.33 -rm -f dbsetup.sql init.sh -cat < dbsetup.sql -CREATE DATABASE stratosdb; -CREATE USER stratos IDENTIFIED BY 'strat0s'; -GRANT ALL PRIVILEGES ON stratosdb.* to 'stratos'@'%'; -EOF - -cat < init.sh -#!/usr/bin/env bash -mysql -uroot -pdbroot < /dbsetup.sql -EOF - -chmod +x init.sh -docker cp ./dbsetup.sql ${ID}:/dbsetup.sql -docker cp ./init.sh ${ID}:/init.sh -rm dbsetup.sql init.sh - -#Fetch dockerize tool -wget https://github.com/jwilder/dockerize/releases/download/v0.6.1/dockerize-linux-amd64-v0.6.1.tar.gz -tar -xzvf dockerize-linux-amd64-v0.6.1.tar.gz -rm dockerize-linux-amd64-v0.6.1.tar.gz - -chmod +x ./dockerize -docker cp ./dockerize ${ID}:/dockerize -rm dockerize - -#We us wait for the internal socket to come up before running init script -echo "Just waiting a few seconds for the DB to come online ..." -docker exec -t ${ID} /dockerize -wait file:///var/run/mysql/mysql.sock -timeout 1m - -docker exec -t ${ID} /init.sh - -echo "Database ready" +# The container can set up users and a new database via env vars +ID=$(docker run --name stratos-db -d -e MYSQL_DATABASE=stratosdb -e MYSQL_ROOT_PASSWORD=dbroot -e MYSQL_PASSWORD=strat0s -p 3306:3306 ${IMAGE}) +echo "Launched container: $ID" +echo "Database started ... it may take a few seconds to complete initialization ..." diff --git a/deploy/all-in-one/build.sh b/deploy/all-in-one/build.sh index 54304cf546..87611e610a 100755 --- a/deploy/all-in-one/build.sh +++ b/deploy/all-in-one/build.sh @@ -181,4 +181,4 @@ echo " Registry : ${DOCKER_REGISTRY}" echo " Org : ${DOCKER_ORG}" echo " Tag : ${TAG}" printf "${RESET}" -echo "" \ No newline at end of file +echo "" diff --git a/deploy/all-in-one/config.all-in-one.properties b/deploy/all-in-one/config.all-in-one.properties index 5f3dd13de8..9a94940533 100644 --- a/deploy/all-in-one/config.all-in-one.properties +++ b/deploy/all-in-one/config.all-in-one.properties @@ -13,4 +13,5 @@ ENCRYPTION_KEY=B374A26A71490437AA024E4FADD5B497FDFF1A8EA6FF12F6FB65AF2720B59CCF STRATOS_DEPLOYMENT_DOCKER_AIO=true SKIP_SSL_VALIDATION=true SQLITE_KEEP_DB=true -TEMPLATE_DIR=./templates \ No newline at end of file +TEMPLATE_DIR=./templates +HELM_CACHE_FOLDER=./helm-cache \ No newline at end of file diff --git a/deploy/ci/console-dev-releases.yml b/deploy/ci/console-dev-releases.yml index e52789e24f..e4af7f2fff 100644 --- a/deploy/ci/console-dev-releases.yml +++ b/deploy/ci/console-dev-releases.yml @@ -1,3 +1,4 @@ +# This pipeline creates an Alpha, Beta or RC release --- resource_types: - name: docker-image @@ -46,7 +47,19 @@ resources: username: ((docker-username)) password: ((docker-password)) repository: ((docker-repository))/stratos-console - +- name: kube-terminal-image + type: docker-image + source: + username: ((docker-username)) + password: ((docker-password)) + repository: ((docker-repository))/stratos-kube-terminal +- name: analyzers-image + type: docker-image + source: + username: ((docker-username)) + password: ((docker-password)) + repository: ((docker-repository))/stratos-analyzers + # Artifacts - name: image-tag type: s3 @@ -101,6 +114,7 @@ jobs: GIT_USER: ((concourse-user)) GIT_EMAIL: ((concourse-email)) GIT_PRIVATE_KEY: ((github-private-key)) + TAG_SUFFIX: ((tag-suffix)) - name: build-images plan: - get: stratos @@ -128,7 +142,16 @@ jobs: tag: image-tag/v2-alpha-tag patch_base_reg: ((patch-base-reg)) patch_base_tag: ((patch-base-tag)) - - do: + - do: + - put: ui-image + params: + dockerfile: stratos/deploy/Dockerfile.ui + build: stratos/ + target_name: prod-build + tag: image-tag/v2-alpha-tag + build_args_file: image-tag/ui-build-args + patch_base_reg: ((patch-base-reg)) + patch_base_tag: ((patch-base-tag)) - put: config-init-image params: dockerfile: stratos/deploy/Dockerfile.init @@ -136,15 +159,22 @@ jobs: tag: image-tag/v2-alpha-tag patch_base_reg: ((patch-base-reg)) patch_base_tag: ((patch-base-tag)) - - put: ui-image + - do: + - put: analyzers-image params: - dockerfile: stratos/deploy/Dockerfile.ui - build: stratos/ - target_name: prod-build + dockerfile: stratos/src/jetstream/plugins/analysis/container/Dockerfile + build: stratos/src/jetstream/plugins/analysis/container/ tag: image-tag/v2-alpha-tag - build_args_file: image-tag/ui-build-args patch_base_reg: ((patch-base-reg)) patch_base_tag: ((patch-base-tag)) + - put: kube-terminal-image + params: + dockerfile: stratos/deploy/containers/kube-terminal/Dockerfile.kubeterminal + build: stratos/deploy/containers/kube-terminal + tag: image-tag/v2-alpha-tag + patch_base_reg: ((patch-base-reg)) + patch_base_tag: ((patch-base-tag)) + - name: create-chart plan: - get: stratos @@ -170,6 +200,7 @@ jobs: DOCKER_REGISTRY: ((docker-registry)) HELM_REPO_PATH: ((helm-repo-path)) HELM_REPO_BRANCH: ((helm-repo-branch)) + TAG_SUFFIX: ((tag-suffix)) - put: helm-chart-tarball params: file: helm-chart/*.tgz @@ -196,4 +227,5 @@ jobs: GITHUB_REPO: ((helm-repo-github-repository)) GIT_USER: ((concourse-user)) GIT_EMAIL: ((concourse-email)) - GIT_PRIVATE_KEY: ((github-private-key)) \ No newline at end of file + GIT_PRIVATE_KEY: ((github-private-key)) + TAG_SUFFIX: ((tag-suffix)) diff --git a/deploy/ci/console-helm-chart-pr.yml b/deploy/ci/console-helm-chart-pr.yml index bee5e837d4..a476d10cf2 100644 --- a/deploy/ci/console-helm-chart-pr.yml +++ b/deploy/ci/console-helm-chart-pr.yml @@ -40,7 +40,6 @@ jobs: GITHUB_REPO: ((helm-repo-github-repository)) GITHUB_TOKEN: ((github-access-token)) GIT_PRIVATE_KEY: ((github-private-key)) - GITHUB_TOKEN: ((github-access-token)) DOCKER_ORG: ((docker-organization)) DOCKER_REGISTRY: ((docker-registry)) HELM_REPO_PATH: ((helm-repo-path)) diff --git a/deploy/ci/console-make-release.yml b/deploy/ci/console-make-release.yml index d803858ce9..1a5856a2ed 100644 --- a/deploy/ci/console-make-release.yml +++ b/deploy/ci/console-make-release.yml @@ -80,3 +80,4 @@ jobs: HELM_RELEASE_REPO_FOLDER: ((release-helm-stable-folder)) HELM_RELEASE_REGISTRY_HOST: ((release-repository)) HELM_RELEASE_REGISTRY_ORG: ((release-repository-organization)) + TAG_SUFFIX: ((tag-suffix)) diff --git a/deploy/cloud-foundry/config.properties b/deploy/cloud-foundry/config.properties index a5cb9bd20a..648d77d54a 100644 --- a/deploy/cloud-foundry/config.properties +++ b/deploy/cloud-foundry/config.properties @@ -19,4 +19,6 @@ ENCRYPTION_KEY=B374A26A71490437AA024E4FADD5B497FDFF1A8EA6FF12F6FB65AF2720B59CCF #VCAP_APPLICATION={"cf_api": "https://api.10.4.21.240.nip.io:8443"} # User invite templates -TEMPLATE_DIR=./templates \ No newline at end of file +TEMPLATE_DIR=./templates + +HELM_CACHE_FOLDER=./helm-cache \ No newline at end of file diff --git a/deploy/containers/kube-terminal/Dockerfile.kubeterminal b/deploy/containers/kube-terminal/Dockerfile.kubeterminal new file mode 100644 index 0000000000..1872e5abb0 --- /dev/null +++ b/deploy/containers/kube-terminal/Dockerfile.kubeterminal @@ -0,0 +1,58 @@ +FROM splatform/stratos-bk-build-base:leap15_1 as terminal-builder +USER root +WORKDIR /root + +# Kubectl versions +RUN curl -L -o kubectl_1.18 https://storage.googleapis.com/kubernetes-release/release/v1.18.2/bin/linux/amd64/kubectl +RUN curl -L -o kubectl_1.17 https://storage.googleapis.com/kubernetes-release/release/v1.17.5/bin/linux/amd64/kubectl +RUN curl -L -o kubectl_1.16 https://storage.googleapis.com/kubernetes-release/release/v1.16.9/bin/linux/amd64/kubectl +RUN curl -L -o kubectl_1.15 https://storage.googleapis.com/kubernetes-release/release/v1.15.11/bin/linux/amd64/kubectl +RUN curl -L -o kubectl_1.14 https://storage.googleapis.com/kubernetes-release/release/v1.14.10/bin/linux/amd64/kubectl + +# Tar each one up, to save space in the image +RUN gzip kubectl_1.18 +RUN gzip kubectl_1.17 +RUN gzip kubectl_1.16 +RUN gzip kubectl_1.15 +RUN gzip kubectl_1.14 + +# Fetch Helm 3 package +RUN curl -L -o helm.tar.gz https://get.helm.sh/helm-v3.1.2-linux-amd64.tar.gz && \ + tar -xvf helm.tar.gz --strip-components=1 && \ + gzip helm + +RUN ls -al + +# Use small base image with very little in it +FROM splatform/stratos-base:leap15_1 + +# Use gzip from the builder image +COPY --from=terminal-builder /usr/bin/gunzip /usr/bin/ +COPY --from=terminal-builder /usr/bin/gzip /usr/bin/ + +RUN mkdir /stratos + +# Copy the various kubectl versions + +COPY --from=terminal-builder /root/helm.gz /stratos/helm.gz +COPY --from=terminal-builder /root/kubectl* /stratos/ + +# Run as user 'stratos' +RUN useradd -ms /bin/bash stratos -K MAIL_DIR=/dev/null + +RUN chown -R stratos /stratos && \ + chgrp -R users /stratos + +# Remove a few packages +RUN zypper rm -y diffutils shadow fillup openssl + +# Remove zypper +RUN zypper rm -y dirmngr && \ + rm -rf /usr/bin/rpm* + +USER stratos +WORKDIR /home/stratos + +ADD ./kubeconsole.bashrc /home/stratos/.bashrc + +CMD exec /bin/bash -c "trap : TERM INT; sleep infinity & wait" diff --git a/deploy/containers/kube-terminal/kubeconsole.bashrc b/deploy/containers/kube-terminal/kubeconsole.bashrc new file mode 100644 index 0000000000..c07ef129f5 --- /dev/null +++ b/deploy/containers/kube-terminal/kubeconsole.bashrc @@ -0,0 +1,77 @@ + +CYAN="\033[96m" +YELLOW="\033[93m" +GREEN="\033[92m" +RESET="\033[0m" +BOLD="\033[1m" +DIM="\033[2m" + +echo -e "${CYAN}Stratos Kubernetes Terminal${RESET}" +echo "" + +# Only do these on first run +if [ ! -f "/stratos/.firstrun" ]; then + # Unpack helm comand + gunzip /stratos/helm.gz + + # Need to choose appropriate kubectl version + pushd /stratos > /dev/null + # Default to the newwest version that we have + USE=$(ls kubectl_* | sort -r | head -n1) + popd > /dev/null + + # If env var K8S_VERSION is set, then use it (major.minor only) + if [ -n "${K8S_VERSION}" ]; then + VERSION="kubectl_${K8S_VERSION}.gz" + if [ -f "/stratos/${VERSION}" ]; then + USE=${VERSION} + fi + fi + + gunzip /stratos/${USE} + VER=${USE::-3} + mv /stratos/${VER} /stratos/kubectl + chmod +x /stratos/kubectl +fi + +export PATH=/stratos:$PATH + +export KUBECONFIG=${HOME}/.stratos/kubeconfig +export PS1="\033[92mstratos>\033[0m" +alias k=kubectl + +# Helm shell completion +source <(helm completion bash) + +#helm repo remove stable > /dev/null + +if [ ! -f "/stratos/.firstrun" ]; then + if [ -f "${HOME}/.stratos/helm-setup" ]; then + echo "Setting up Helm repositories ..." + source "${HOME}/.stratos/helm-setup" > /dev/null + + # helm repo update can take a while, so get the user to do that if they want to use helm + echo -e "You may need to run ${CYAN}helm repo update${RESET} to update your repositories before using Helm" + echo "" + fi + + if [ -f "${HOME}/.stratos/history" ]; then + cat ${HOME}/.stratos/history > ${HOME}/.bash_history + fi +fi + +# Make Bash append rather than overwrite the history on disk: +shopt -s histappend +# A new shell gets the history lines from all previous shells +PROMPT_COMMAND='history -a' +# Don't put duplicate lines in the history. +export HISTCONTROL=ignoredups + +touch "/stratos/.firstrun" + +# Remove any env vars matching KUBERNETES +unset `compgen -A variable | grep KUBERNETES` + +echo +echo -e "${CYAN}kubectl${RESET} and ${CYAN}helm${RESET} commands are available" +echo "" diff --git a/deploy/kubernetes/build.sh b/deploy/kubernetes/build.sh index b2d5fde029..04d4a083e0 100755 --- a/deploy/kubernetes/build.sh +++ b/deploy/kubernetes/build.sh @@ -214,6 +214,14 @@ if [ "${CHART_ONLY}" == "false" ]; then log "-- Building/publishing the runtime container image for the Console web server (frontend)" patchAndPushImage stratos-console deploy/Dockerfile.ui "${STRATOS_PATH}" prod-build + # Build and push an image for the Kubernetes Terminal + log "-- Building/publishing Kubernetes Terminal" + patchAndPushImage stratos-kube-terminal Dockerfile.kubeterminal "${STRATOS_PATH}/deploy/containers/kube-terminal" + + # Analzyers container + log "-- Building/publishing Stratos Analyzers" + patchAndPushImage stratos-analyzers Dockerfile "${STRATOS_PATH}/src/jetstream/plugins/analysis/container" + # Build any custom images added by a fork if [ "${HAS_CUSTOM_BUILD}" == "true" ]; then custom_image_build diff --git a/deploy/kubernetes/console/README.md b/deploy/kubernetes/console/README.md index b584df5a41..cadd5c7d49 100644 --- a/deploy/kubernetes/console/README.md +++ b/deploy/kubernetes/console/README.md @@ -313,7 +313,7 @@ helm install my-console stratos/console --namespace=console --set console.localA In some scenarios it is useful to be able to add custom annotations and/or labels to the Kubernetes resources that the Stratos Helm chart creates. -The Stratos Helm chart exposes a number of Helm chart values that cabe specified in order to do this - they are: +The Stratos Helm chart exposes a number of Helm chart values that can be specified in order to do this - they are: |Parameter|Description|Default| |----|---|---| diff --git a/deploy/kubernetes/console/templates/analyzers.yaml b/deploy/kubernetes/console/templates/analyzers.yaml new file mode 100644 index 0000000000..226d984564 --- /dev/null +++ b/deploy/kubernetes/console/templates/analyzers.yaml @@ -0,0 +1,109 @@ +{{- if .Values.console.techPreview }} +--- +{{- if semverCompare ">=1.16" (printf "%s.%s" .Capabilities.KubeVersion.Major (trimSuffix "+" .Capabilities.KubeVersion.Minor) )}} +apiVersion: apps/v1 +{{- else }} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Deployment +metadata: + name: stratos-analyzers + labels: + app.kubernetes.io/name: "stratos" + app.kubernetes.io/instance: "{{ .Release.Name }}" + app.kubernetes.io/version: "{{ .Chart.AppVersion }}" + app.kubernetes.io/component: "stratos-analyzers" + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" +spec: + selector: + matchLabels: + app.kubernetes.io/name: "stratos" + app.kubernetes.io/component: "stratos-analyzers" + template: + metadata: +{{- if .Values.console.podAnnotations }} + annotations: +{{ toYaml .Values.console.podAnnotations | indent 8 }} +{{- end }} + labels: + app.kubernetes.io/name: "stratos" + app.kubernetes.io/instance: "{{ .Release.Name }}" + app.kubernetes.io/version: "{{ .Chart.AppVersion }}" + app.kubernetes.io/component: "stratos-analyzers" + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" + app: "{{ .Release.Name }}" + {{- if .Values.console.podExtraLabels}} + {{ toYaml .Values.console.podExtraLabels | nindent 8 }} + {{- end}} + spec: + containers: + - name: analyzers + image: {{.Values.kube.registry.hostname}}/{{.Values.kube.organization}}/stratos-analyzers:{{.Values.consoleVersion}} + imagePullPolicy: {{.Values.imagePullPolicy}} + ports: + - name: api + containerPort: 8090 + env: + - name: STRATOS_IMAGE_REF + value: "{{.Values.consoleVersion}}:{{ .Release.Revision }}" + - name: ANALYSIS_SCRIPTS_DIR + value: "/scripts" + - name: ANALYSIS_REPORTS_DIR + value: "/reports" + volumeMounts: + - name: data + mountPath: /reports + {{- if and .Values.kube.registry.username .Values.kube.registry.password }} + imagePullSecrets: + - name: {{.Values.dockerRegistrySecret}} + {{- end }} + {{- if not .Values.console.reportsVolumeDisabled }} + volumes: + - name: data + persistentVolumeClaim: + claimName: "{{ .Release.Name }}-reports" + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: "{{ .Release.Name }}-analyzers" + labels: + app.kubernetes.io/name: "stratos" + app.kubernetes.io/instance: "{{ .Release.Name }}" + app.kubernetes.io/version: "{{ .Chart.AppVersion }}" + app.kubernetes.io/component: "stratos-analyzers-service" + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" +spec: + type: ClusterIP + ports: + - name: analyzers + port: 8090 + targetPort: 8090 + selector: + app: "{{ .Release.Name }}" + app.kubernetes.io/component: "stratos-analyzers" +--- +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: "{{ .Release.Name }}-reports" + labels: + app.kubernetes.io/name: "stratos" + app.kubernetes.io/instance: "{{ .Release.Name }}" + app.kubernetes.io/version: "{{ .Chart.AppVersion }}" + app.kubernetes.io/component: "stratos-reports-volume" + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" + annotations: + {{- if .Values.storageClass }} + volume.beta.kubernetes.io/storage-class: {{ .Values.storageClass | quote }} + {{- else }} + volume.alpha.kubernetes.io/storage-class: default + {{- end }} +spec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: {{ default "1Gi" .Values.console.reportsVolumeSize | quote }} +{{- end }} diff --git a/deploy/kubernetes/console/templates/deployment.yaml b/deploy/kubernetes/console/templates/deployment.yaml index ca5030f156..f5c61e853f 100644 --- a/deploy/kubernetes/console/templates/deployment.yaml +++ b/deploy/kubernetes/console/templates/deployment.yaml @@ -29,7 +29,7 @@ spec: type: RollingUpdate rollingUpdate: maxSurge: 0 - maxUnavailable: 1 + maxUnavailable: 1 selector: matchLabels: app.kubernetes.io/name: "stratos" @@ -284,6 +284,8 @@ spec: value: {{ default "false" .Values.console.techPreview | quote }} - name: API_KEYS_ENABLED value: {{ default "admin_only" .Values.console.apiKeysEnabled | quote }} + - name: HELM_CACHE_FOLDER + value: /helm-cache {{- if .Values.console.ui }} {{- if .Values.console.ui.listMaxSize }} - name: UI_LIST_MAX_SIZE @@ -292,6 +294,14 @@ spec: - name: UI_LIST_ALLOW_LOAD_MAXED value: {{ default "false" .Values.console.ui.listAllowLoadMaxed | quote }} {{- end }} + - name: ANALYSIS_SERVICES_API + value: "http://{{ .Release.Name }}-analyzers:8090" + - name: STRATOS_KUBERNETES_NAMESPACE + value: "{{ .Release.Namespace }}" + - name: STRATOS_KUBERNETES_TERMINAL_IMAGE + value: "{{.Values.kube.registry.hostname}}/{{.Values.kube.organization}}/stratos-kube-terminal:{{.Values.consoleVersion}}" + - name: STRATOS_KUBERNETES_DASHBOARD_IMAGE + value: "{{.Values.console.kubeDashboardImage}}" {{- include "stratosJetstreamEnv" . | indent 8 }} readinessProbe: httpGet: @@ -317,10 +327,15 @@ spec: name: "{{ .Release.Name }}-templates" readOnly: true {{- end }} + - mountPath: /helm-cache + name: helm-cache-volume {{- if and .Values.kube.registry.username .Values.kube.registry.password }} imagePullSecrets: - name: {{.Values.dockerRegistrySecret}} {{- end }} + {{- if and (eq (printf "%s" .Values.kube.auth) "rbac") (.Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1") }} + serviceAccountName: "stratos" + {{- end }} volumes: - name: "{{ .Release.Name }}-encryption-key-volume" secret: @@ -343,3 +358,5 @@ spec: configMap: name: {{ .Values.console.templatesConfigMapName }} {{- end }} + - name: helm-cache-volume + emptyDir: {} diff --git a/deploy/kubernetes/console/templates/service-account.yaml b/deploy/kubernetes/console/templates/service-account.yaml new file mode 100644 index 0000000000..d8ef0e07d7 --- /dev/null +++ b/deploy/kubernetes/console/templates/service-account.yaml @@ -0,0 +1,61 @@ +{{- if and (eq (printf "%s" .Values.kube.auth) "rbac") (.Capabilities.APIVersions.Has "rbac.authorization.k8s.io/v1") }} +--- +# Service account main Stratos Deployment +# Allows it to create some resources in its namespace +apiVersion: "v1" +kind: "ServiceAccount" +metadata: + name: "stratos" + labels: + app.kubernetes.io/component: "stratos" + app.kubernetes.io/instance: "{{ .Release.Name }}" + app.kubernetes.io/name: "stratos" + app.kubernetes.io/version: "{{ .Chart.AppVersion }}" + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" +--- +# Role "stratos-role" only used by account "stratos" +apiVersion: "rbac.authorization.k8s.io/v1" +kind: "Role" +metadata: + name: "stratos-role" + labels: + app.kubernetes.io/component: "stratos-role" + app.kubernetes.io/instance: "{{ .Release.Name }}" + app.kubernetes.io/name: "stratos" + app.kubernetes.io/version: "{{ .Chart.AppVersion }}" + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" +rules: +- apiGroups: + - "" + resources: + - "secrets" + - "pods" + verbs: + - "create" + - "update" + - "get" + - "list" + - "delete" +- apiGroups: [""] + resources: ["pods/exec"] + verbs: ["create", "get"] +--- +# Role binding for service account "stratos" and role "stratos-role" +apiVersion: "rbac.authorization.k8s.io/v1" +kind: "RoleBinding" +metadata: + name: "stratos-role-binding" + labels: + app.kubernetes.io/component: "stratos-role-binding" + app.kubernetes.io/instance: "{{ .Release.Name }}" + app.kubernetes.io/name: "stratos" + app.kubernetes.io/version: "{{ .Chart.AppVersion }}" + helm.sh/chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" +subjects: +- kind: "ServiceAccount" + name: "stratos" +roleRef: + apiGroup: "rbac.authorization.k8s.io" + kind: "Role" + name: "stratos-role" +{{- end }} diff --git a/deploy/kubernetes/console/values.schema.json b/deploy/kubernetes/console/values.schema.json index 34651e77ae..7915c6e023 100644 --- a/deploy/kubernetes/console/values.schema.json +++ b/deploy/kubernetes/console/values.schema.json @@ -1,373 +1,367 @@ { - "$schema": "http://json-schema.org/schema#", - "type": "object", - "properties": { - "autoCleanup": { - "type": "boolean" + "$schema": "http://json-schema.org/schema#", + "type": "object", + "properties": { + "console": { + "type": "object", + "properties": { + "techPreview": { + "type": "boolean", + "description": "Enable/disable technology preview features" }, - "configInit": { - "type": "object", - "properties": { - "nodeSelector": { - "type": "object" - } - } + "apiKeysEnabled": { + "type": "string", + "enum": ["disabled", "admin_only", "all_users"], + "description": "Enable API keys for admins, all users or nobody" }, - "console": { - "type": "object", - "properties": { - "apiKeysEnabled": { - "type": "string", - "enum": ["disabled", "admin_only", "all_users"] - }, - "autoRegisterCF": { - "type": ["string", "null"] - }, - "backendLogLevel": { - "type": "string" - }, - "cookieDomain": { - "type": ["string", "null"] - }, - "deploymentAnnotations": { - "type": "object" - }, - "deploymentExtraLabels": { - "type": "object" - }, - "jobAnnotations": { - "type": "object" - }, - "jobExtraLabels": { - "type": "object" - }, - "localAdminPassword": { - "type": ["string", "null"] - }, - "nodeSelector": { - "type": "object" - }, - "podAnnotations": { - "type": "object" - }, - "podExtraLabels": { - "type": "object" - }, - "service": { - "type": "object", - "properties": { - "annotations": { - "type": "object" - }, - "externalIPs": { - "type": "array" - }, - "externalName": { - "type": ["string", "null"] - }, - "extraLabels": { - "type": "object" - }, - "http": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "servicePort": { - "type": "integer" - } - } - }, - "ingress": { - "type": "object", - "properties": { - "annotations": { - "type": "object" - }, - "enabled": { - "type": "boolean" - }, - "extraLabels": { - "type": "object" - }, - "host": { - "type": ["string", "null"] - }, - "secretName": { - "type": ["string", "null"] - }, - "tls": { - "type": "object", - "properties": { - "crt": { - "type": ["string", "null"] - }, - "key": { - "type": ["string", "null"] - } - } - } - } - }, - "loadBalancerIP": { - "type": ["string", "null"] - }, - "loadBalancerSourceRanges": { - "type": "array" - }, - "servicePort": { - "type": "integer" - }, - "type": { - "type": "string" - } - } - }, - "sessionStoreSecret": { - "type": ["string", "null"] - }, - "sslCiphers": { - "type": ["string", "null"] - }, - "sslProtocols": { - "type": ["string", "null"] - }, - "ssoLogin": { - "type": "boolean" - }, - "ssoOptions": { - "type": ["string", "null"] - }, - "statefulSetAnnotations": { - "type": "object" + "autoRegisterCF": { + "type": ["string", "null"] + }, + "backendLogLevel": { + "type": "string", + "enum": ["debug", "info", "warn", "error"] + }, + "cookieDomain": { + "type": ["string", "null"] + }, + "deploymentAnnotations": { + "type": "object" + }, + "deploymentExtraLabels": { + "type": "object" + }, + "jobAnnotations": { + "type": "object" + }, + "jobExtraLabels": { + "type": "object" + }, + "localAdminPassword": { + "type": ["string", "null"] + }, + "nodeSelector": { + "type": "object" + }, + "podAnnotations": { + "type": "object" + }, + "podExtraLabels": { + "type": "object" + }, + "service": { + "type": "object", + "description": "Configure the properties of the external service", + "properties": { + "type": { + "type": "string", + "description": "Service type", + "enum": ["ClusterIP", "NodePort", "LoadBalancer", "ExternalName"] + }, + "annotations": { + "type": "object" + }, + "externalIPs": { + "type": "array" + }, + "externalName": { + "type": ["string", "null"] + }, + "extraLabels": { + "type": "object" + }, + "http": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "servicePort": { + "type": "integer" + } + } + }, + "ingress": { + "type": "object", + "properties": { + "annotations": { + "type": "object" }, - "statefulSetExtraLabels": { - "type": "object" + "enabled": { + "type": "boolean" }, - "techPreview": { - "type": "boolean" + "extraLabels": { + "type": "object" }, - "templatesConfigMapName": { - "type": ["string", "null"] + "host": { + "type": ["string", "null"] }, - "tlsSecretName": { - "type": ["string", "null"] + "secretName": { + "type": ["string", "null"] }, - "ui": { - "type": "object", - "properties": { - "listAllowLoadMaxed": { - "type": "boolean" - }, - "listMaxSize": { - "type": ["integer", "null"] - } + "tls": { + "type": "object", + "properties": { + "crt": { + "type": ["string", "null"] + }, + "key": { + "type": ["string", "null"] } - }, - "userInviteSubject": { - "type": ["string", "null"] + } } + } + }, + "loadBalancerIP": { + "type": ["string", "null"] + }, + "loadBalancerSourceRanges": { + "type": "array" + }, + "servicePort": { + "type": "integer" } + } }, - "consoleVersion": { - "type": "string" + "sessionStoreSecret": { + "type": ["string", "null"] }, - "dockerRegistrySecret": { - "type": "string" + "sslCiphers": { + "type": ["string", "null"] }, - "env": { - "type": "object", - "properties": { - "DOMAIN": { - "type": ["string", "null"] - }, - "SMTP_AUTH": { - "type": "string" - }, - "SMTP_FROM_ADDRESS": { - "type": ["string", "null"] - }, - "SMTP_HOST": { - "type": ["string", "null"] - }, - "SMTP_PASSWORD": { - "type": ["string", "null"] - }, - "SMTP_PORT": { - "type": "string" - }, - "SMTP_USER": { - "type": ["string", "null"] - }, - "UAA_HOST": { - "type": ["string", "null"] - }, - "UAA_PORT": { - "type": "integer" - }, - "UAA_ZONE": { - "type": "string" - } - } + "sslProtocols": { + "type": ["string", "null"] }, - "imagePullPolicy": { - "type": "string" + "ssoLogin": { + "type": "boolean" }, - "images": { - "type": "object", - "properties": { - "configInit": { - "type": "string" - }, - "console": { - "type": "string" - }, - "mariadb": { - "type": "string" - }, - "proxy": { - "type": "string" - } - } + "ssoOptions": { + "type": ["string", "null"] }, - "kube": { - "type": "object", - "properties": { - "auth": { - "type": "string" - }, - "external_console_https_port": { - "type": "integer" - }, - "organization": { - "type": "string" - }, - "registry": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "hostname": { - "type": ["string", "null"] - }, - "password": { - "type": ["string", "null"] - }, - "username": { - "type": ["string", "null"] - } - } - }, - "storage_class": { - "type": "object", - "properties": { - "persistent": { - "type": ["string", "null"] - } - } - } + "statefulSetAnnotations": { + "type": "object" + }, + "statefulSetExtraLabels": { + "type": "object" + }, + "templatesConfigMapName": { + "type": ["string", "null"] + }, + "tlsSecretName": { + "type": ["string", "null"] + }, + "ui": { + "type": "object", + "properties": { + "listAllowLoadMaxed": { + "type": "boolean" + }, + "listMaxSize": { + "type": ["integer", "null"] } + } + }, + "userInviteSubject": { + "type": ["string", "null"] + } + } + }, + "imagePullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"], + "description": "Sets the Image Pull Policy" + }, + "dockerRegistrySecret": { + "type": "string" + }, + "env": { + "type": "object", + "properties": { + "DOMAIN": { + "type": ["string", "null"] + }, + "SMTP_AUTH": { + "type": "string" + }, + "SMTP_FROM_ADDRESS": { + "type": ["string", "null"] + }, + "SMTP_HOST": { + "type": ["string", "null"] + }, + "SMTP_PASSWORD": { + "type": ["string", "null"] + }, + "SMTP_PORT": { + "type": "string" + }, + "SMTP_USER": { + "type": ["string", "null"] + }, + "UAA_HOST": { + "type": ["string", "null"] + }, + "UAA_PORT": { + "type": "integer" + }, + "UAA_ZONE": { + "type": "string" + } + } + }, + "images": { + "type": "object", + "properties": { + "configInit": { + "type": "string" + }, + "console": { + "type": "string" }, "mariadb": { - "type": "object", - "properties": { - "database": { - "type": "string" - }, - "external": { - "type": "boolean" - }, - "host": { - "type": ["string", "null"] - }, - "nodeSelector": { - "type": "object" - }, - "persistence": { - "type": "object", - "properties": { - "accessMode": { - "type": "string" - }, - "enabled": { - "type": "boolean" - }, - "size": { - "type": "string" - }, - "storageClass": { - "type": ["string", "null"] - } - } - }, - "port": { - "type": "null" - }, - "resources": { - "type": "object", - "properties": { - "requests": { - "type": "object", - "properties": { - "cpu": { - "type": "string" - }, - "memory": { - "type": "string" - } - } - } - } - }, - "rootPassword": { - "type": ["string", "null"] - }, - "tls": { - "type": ["string", "null"] - }, - "type": { - "type": ["string", "null"] - }, - "user": { - "type": "string" - }, - "userPassword": { - "type": ["string", "null"] - } + "type": "string" + }, + "proxy": { + "type": "string" + } + } + }, + "kube": { + "type": "object", + "properties": { + "auth": { + "type": "string" + }, + "external_console_https_port": { + "type": "integer" + }, + "organization": { + "type": "string" + }, + "registry": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "hostname": { + "type": ["string", "null"] + }, + "password": { + "type": ["string", "null"] + }, + "username": { + "type": ["string", "null"] } + } }, - "services": { - "type": "object", - "properties": { - "loadbalanced": { - "type": "boolean" - } + "storage_class": { + "type": "object", + "properties": { + "persistent": { + "type": ["string", "null"] } + } + } + } + }, + "mariadb": { + "type": "object", + "properties": { + "database": { + "type": "string" }, - "uaa": { - "type": "object", - "properties": { - "consoleAdminIdentifier": { - "type": ["string", "null"] - }, - "consoleClient": { - "type": ["string", "null"] - }, - "consoleClientSecret": { - "type": ["string", "null"] - }, - "endpoint": { - "type": ["string", "null"] - }, - "skipSSLValidation": { - "type": "boolean" + "external": { + "type": "boolean" + }, + "host": { + "type": ["string", "null"] + }, + "nodeSelector": { + "type": "object" + }, + "persistence": { + "type": "object", + "properties": { + "accessMode": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "size": { + "type": "string" + }, + "storageClass": { + "type": ["string", "null"] + } + } + }, + "port": { + "type": "null" + }, + "resources": { + "type": "object", + "properties": { + "requests": { + "type": "object", + "properties": { + "cpu": { + "type": "string" + }, + "memory": { + "type": "string" } + } } + } + }, + "rootPassword": { + "type": ["string", "null"] + }, + "tls": { + "type": ["string", "null"] + }, + "type": { + "type": ["string", "null"] }, - "useLb": { - "type": "boolean" + "user": { + "type": "string" + }, + "userPassword": { + "type": ["string", "null"] + } + } + }, + "uaa": { + "type": "object", + "properties": { + "consoleAdminIdentifier": { + "type": ["string", "null"] + }, + "consoleClient": { + "type": ["string", "null"] + }, + "consoleClientSecret": { + "type": ["string", "null"] + }, + "endpoint": { + "type": ["string", "null"] + }, + "skipSSLValidation": { + "type": "boolean" + } + } + }, + "autoCleanup": { + "type": "boolean" + }, + "configInit": { + "type": "object", + "properties": { + "nodeSelector": { + "type": "object" } + } } + } } diff --git a/deploy/kubernetes/console/values.yaml b/deploy/kubernetes/console/values.yaml index 2b14718a99..e9999e9d35 100644 --- a/deploy/kubernetes/console/values.yaml +++ b/deploy/kubernetes/console/values.yaml @@ -57,6 +57,7 @@ console: servicePort: 80 # nodePort: 30001 + # Name of config map that provides the template files for user invitation emails templatesConfigMapName: @@ -112,6 +113,15 @@ console: # Node Selector for console Pod nodeSelector: {} + # Download link when installing the Kubernetes Dashboard in a targetted Kube Endpoint + kubeDashboardImage: + + # Size for analysis reports volume + reportsVolumeSize: 1Gi + + # Do not use a persistent volume for analysis reports + reportsVolumeDisabled: false + # ssl protocols and ciphers overrides - leave empty for defaults sslProtocols: sslCiphers: @@ -121,6 +131,7 @@ images: proxy: stratos-jetstream mariadb: stratos-mariadb configInit: stratos-config-init + analyzers: stratos-analyzers # Specify which storage class should be used for PVCs #storageClass: default diff --git a/deploy/kubernetes/custom/imagelist.txt b/deploy/kubernetes/custom/imagelist.txt new file mode 100644 index 0000000000..e628bfa984 --- /dev/null +++ b/deploy/kubernetes/custom/imagelist.txt @@ -0,0 +1 @@ +stratos-kube-terminal:_VERSION_ \ No newline at end of file diff --git a/docs/connecting-k8s.md b/docs/connecting-k8s.md new file mode 100644 index 0000000000..b43d225c60 --- /dev/null +++ b/docs/connecting-k8s.md @@ -0,0 +1,79 @@ +# Connecting Different Kinds of Kubernetes Clusters + +Currently the Stratos Kubernetes plugin supports the following four types of clusters: + +1. CAASP (OIDC) +2. AWS EKS (AWS IAM auth) +2. Azure AKS +4. Certificate based Kubernetes authentication + +The following details, how to find the endpoint URL required to register the cluster in Stratos and what credentials are required to connect. + +## CAASP (OIDC) +To connect a CAASP cluster to Stratos, download a `kubeconfig` from Velum. + +1. To find the endpoint URL, inspect the file. The `server` property details the endpoint URL + +``` +apiVersion: v1 +kind: Config +clusters: +- name: caasp + cluster: + server: https://kube-api-x1.devenv.caasp.suse.net:6443 <---Endpoint URL + certificate-authority-data: 1c1MFpYSnVZV3dnUTBFd0hoY05NVGd4TURBMU1USXhNalU1V2hjTk1qZ3hNREF5TVRJeE1qVTVXakNCb1RFTApNQWtHQTFVRUJoTUNSRVV4RURBT0JnTlZCQWdNQjBKaGRtRnlhV0V4RWpBUUJnTlZCQWNNQ1U1MWNtVnRZbVZ5Clp6RWJNQmtHQTFVRUNnd1NVMVZUUlNCQmRYUnZaMl... +``` +2. Specify the Endpoint URL when adding the endpoint to Stratos. +3. To connect to Kubernetes, select the `CAASP (OIDC)` option, and upload the `kubeconfig` file downloaded from Velum. + +## Amazon EKS +The following details are required to connect to an EKS system: +- EKS Cluster endpoint URL. (To register the endpoint). + + This can be located in the generated configuration. See the following example. +To Connect the following details are required: +- Cluster Name (See the following example) +- AWS Access Key +- AWS Secret Key + +### EKS Endpoint URL And Cluster Name +You can locate the EKS cluster endpoint URL and the cluster name, by inspecting the generated cluster configuration in your local `kubeconfig`. + +``` +10:20 $ cat ~/.kube/config +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN5RENDQWJDZ0F3SUJBZ0lCQURBTkJna3Foa...QXR2N3dOQkt3eFhsYgpxZm5HRUs0WHRmSWNIcjJHSjhZOXdIa0lPRm0rR3Nvak1PaG1pK05wbER2YjVJc3BmMmxxbXdLd3RmRT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + server: https://40BCD34B7E297903DA2EAF19B6164521.sk1.us-east-1.eks.amazonaws.com + name: arn:aws:eks:us-east-1:138384977974:cluster/BRSSCF + +``` +The endpoint URL is specified in the `server` property (i.e. `https://40BCD34B7E297903DA2EAF19B6164521.sk1.us-east-1.eks.amazonaws.com`), while the cluster name is the last part of the `name` property (i.e `BRSSCF`). + +## Azure AKS +To connect an AKS kubernetes instance, the following is required: +1. AKS Endpoint URL, which can be found from the AKS console or the generated kubernetes configuration. +2. To connect to the cluster, provide the `kubeconfig` file. + +## Certificate based authentication (Minikube) + +Minikube by default uses TLS certificates for authentication. To find the Minikube endpoint URL, locate the `minikube` entry in your local `kubeconfig` file. In the following example, the `minikube` endpoint URL is `https://192.168.99.100:8443`. + +``` +- cluster: + certificate-authority: /home/user/.minikube/ca.crt + server: https://192.168.99.100:8443 + name: minikube +``` + +To connect to the cluster, locate the relevant entry in the `users` section in your kubernetes config file. + +``` +users: +- name: minikube + user: + client-certificate: /home/user/.minikube/client.crt + client-key: /home/user/.minikube/client.key + +``` +The two files specified under `client-certificate` and `client-key` are required to connect to the cluster. +Select the `Kubernetes Cert Auth` option in the connect dialog and select the two files to connect. \ No newline at end of file diff --git a/docs/eksdeployment.md b/docs/eksdeployment.md new file mode 100644 index 0000000000..74399f12d9 --- /dev/null +++ b/docs/eksdeployment.md @@ -0,0 +1,83 @@ +# Stratos on EKS + +## EKS Setup + +Follow the instructions outlined in https://docs.aws.amazon.com/eks/latest/userguide/getting-started.html to deploy an EKS cluster. + +If you plan to deploy SCF in the cluster, make the following modifications when creating the worker nodes. +- Set `NodeInstanceType` to `t2.large` +- Set `NodeInstanceVolume` to `75` + +## Helm Setup + +Download the latest Helm release (atleast 2.9 is required for RBAC support) from https://github.com/helm/helm/releases + +Save the following to a file caleld `helm-rbac.yaml` +``` +apiVersion: v1 +kind: ServiceAccount +metadata: + name: tiller + namespace: kube-system +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: tiller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: ServiceAccount + name: tiller + namespace: kube-system +``` + +Apply the configuration to the EKS cluster +``` +$ kubectl apply -f helm-rbac.yaml +``` + +Install Helm +``` +$ helm init --service-account tiller +``` + +## Storage Classes + +Stratos requires a storage class that is scoped to a single `AZ`. + +To setup the scoped storage class, save the following to `scoped_storage_class.yaml`. +``` +kind: StorageClass +apiVersion: storage.k8s.io/v1 +metadata: + name: gp2scoped +provisioner: kubernetes.io/aws-ebs +parameters: + type: gp2 + zone: "us-east-1a" +reclaimPolicy: Retain +mountOptions: + - debug +``` +To create the storage class, execute the following: +``` +$ kubectl apply -f scoped_storage_class.yaml +``` + +In this guide, the scoped storage class will be referred to as `gp2scoped`. + +## Stratos Deployment + +To deploy Stratos on EKS, the following configuration overrides are required, save the following to a file named `stratos-values.yaml`. +``` +storageClass: gp2scoped +useLb: true +``` + +Install `stratos` with Helm +``` +$ helm install stratos/console -f stratos-values.yaml --namespace stratos --name stratos +``` diff --git a/package-lock.json b/package-lock.json index a1ca760edb..5568c2be10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,9 +5,9 @@ "requires": true, "dependencies": { "@angular-builders/custom-webpack": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-9.1.0.tgz", - "integrity": "sha512-Dek6KxNUFBELKqNRO4Im5JIP0/rZF4HmvgA8X+RyqOd9cyDxk16A441WlqTqy3UKX8lcbf6C9RcR5D2dI1ZATQ==", + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@angular-builders/custom-webpack/-/custom-webpack-9.2.0.tgz", + "integrity": "sha512-0ivkjENONFm0oNy6hdCod4YaT4dUk80KuP9+eDliWuZIA70yKQgIYMLul0bz6/i+Cm24PaZ2tq4w7kW7AuSMoA==", "dev": true, "requires": { "@angular-devkit/architect": ">=0.900.0 < 0.1000.0", @@ -28,19 +28,6 @@ "rxjs": "6.5.4" }, "dependencies": { - "@angular-devkit/core": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-9.1.7.tgz", - "integrity": "sha512-guvolu9Cl+qYMTtedLZD9wCqustJjdqzJ2psD2C1Sr1LrX9T0mprmDldR/YnhsitThveJEb6sM/0EvqWxoSvKw==", - "dev": true, - "requires": { - "ajv": "6.12.0", - "fast-json-stable-stringify": "2.1.0", - "magic-string": "0.25.7", - "rxjs": "6.5.4", - "source-map": "0.7.3" - } - }, "rxjs": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", @@ -49,12 +36,6 @@ "requires": { "tslib": "^1.9.0" } - }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "dev": true } } }, @@ -375,16 +356,6 @@ "rxjs": "6.5.4" }, "dependencies": { - "@angular-devkit/architect": { - "version": "0.901.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.901.7.tgz", - "integrity": "sha512-yW/PUEqle55QihOFbmeNXaVTodhfeXkteoFDUpz+YpX3xiQDXDtNbIJSzKOQTojtBKdSMKMvZkQLr+RAa7/1EA==", - "dev": true, - "requires": { - "@angular-devkit/core": "9.1.7", - "rxjs": "6.5.4" - } - }, "rxjs": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", @@ -527,19 +498,6 @@ "rxjs": "6.5.4" }, "dependencies": { - "@angular-devkit/core": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-9.1.7.tgz", - "integrity": "sha512-guvolu9Cl+qYMTtedLZD9wCqustJjdqzJ2psD2C1Sr1LrX9T0mprmDldR/YnhsitThveJEb6sM/0EvqWxoSvKw==", - "dev": true, - "requires": { - "ajv": "6.12.0", - "fast-json-stable-stringify": "2.1.0", - "magic-string": "0.25.7", - "rxjs": "6.5.4", - "source-map": "0.7.3" - } - }, "rxjs": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", @@ -548,12 +506,6 @@ "requires": { "tslib": "^1.9.0" } - }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "dev": true } } }, @@ -598,39 +550,6 @@ "uuid": "7.0.2" }, "dependencies": { - "@angular-devkit/architect": { - "version": "0.901.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.901.7.tgz", - "integrity": "sha512-yW/PUEqle55QihOFbmeNXaVTodhfeXkteoFDUpz+YpX3xiQDXDtNbIJSzKOQTojtBKdSMKMvZkQLr+RAa7/1EA==", - "dev": true, - "requires": { - "@angular-devkit/core": "9.1.7", - "rxjs": "6.5.4" - } - }, - "@angular-devkit/core": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-9.1.7.tgz", - "integrity": "sha512-guvolu9Cl+qYMTtedLZD9wCqustJjdqzJ2psD2C1Sr1LrX9T0mprmDldR/YnhsitThveJEb6sM/0EvqWxoSvKw==", - "dev": true, - "requires": { - "ajv": "6.12.0", - "fast-json-stable-stringify": "2.1.0", - "magic-string": "0.25.7", - "rxjs": "6.5.4", - "source-map": "0.7.3" - } - }, - "@schematics/angular": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-9.1.7.tgz", - "integrity": "sha512-ld3WcoMWvup04V3OWioQ+AFGQBzz7IDM4Fxc5+Qc3wILWkDJnNkrc4EmJAow96Ab4/T1+Wl1vof3tV4At0BTzA==", - "dev": true, - "requires": { - "@angular-devkit/core": "9.1.7", - "@angular-devkit/schematics": "9.1.7" - } - }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -661,27 +580,12 @@ "glob": "^7.1.3" } }, - "rxjs": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", - "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - }, "semver": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.1.3.tgz", "integrity": "sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA==", "dev": true }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "dev": true - }, "uuid": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.2.tgz", @@ -964,12 +868,12 @@ "integrity": "sha512-4u+CWMPB4hCkAsFCEzC94YEWT0wVozqGkc/Dortt2hFaqvZpIegg6iJVZlDxuyDjzFYBPnnbTDdgiTTA8ckfuA==" }, "@apidevtools/json-schema-ref-parser": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.1.tgz", - "integrity": "sha512-Qsdz0W0dyK84BuBh5KZATWXOtVDXIF2EeNRzpyWblPUeAmnIokwWcwrpAm5pTPMjuWoIQt9C67X3Af1OlL6oSw==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz", + "integrity": "sha512-M3YgsLjI0lZxvrpeGVk9Ap032W6TPQkH6pRAZz81Ac3WUNF79VQooAFnp8umjvVzUmD93NkogxEwbSce7qMsUg==", "dev": true, "requires": { - "@jsdevtools/ono": "^7.1.2", + "@jsdevtools/ono": "^7.1.3", "call-me-maybe": "^1.0.1", "js-yaml": "^3.13.1" } @@ -1809,9 +1713,9 @@ } }, "@babel/helper-validator-identifier": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.0.tgz", - "integrity": "sha512-6G8bQKjOh+of4PV/ThDm/rRqlU7+IGoJuofpagU5GlEl29Vv0RGqqt86ZGRV8ZuSOY3o+8yXl5y782SMcG7SHw==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", "dev": true }, "@babel/helper-wrap-function": { @@ -1955,124 +1859,124 @@ } }, "@babel/helpers": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.1.tgz", - "integrity": "sha512-muQNHF+IdU6wGgkaJyhhEmI54MOZBKsFfsXFhboz1ybwJ1Kl7IHlbm2a++4jwrmY5UYsgitt5lfqo1wMFcHmyw==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.10.4.tgz", + "integrity": "sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA==", "dev": true, "requires": { - "@babel/template": "^7.10.1", - "@babel/traverse": "^7.10.1", - "@babel/types": "^7.10.1" + "@babel/template": "^7.10.4", + "@babel/traverse": "^7.10.4", + "@babel/types": "^7.10.4" }, "dependencies": { "@babel/code-frame": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.1.tgz", - "integrity": "sha512-IGhtTmpjGbYzcEDOw7DcQtbQSXcG9ftmAXtWTu9V936vDye4xjjekktFAtgZsWpzTj/X01jocB46mTywm/4SZw==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", + "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", "dev": true, "requires": { - "@babel/highlight": "^7.10.1" + "@babel/highlight": "^7.10.4" } }, "@babel/generator": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.2.tgz", - "integrity": "sha512-AxfBNHNu99DTMvlUPlt1h2+Hn7knPpH5ayJ8OqDWSeLld+Fi2AYBTC/IejWDM9Edcii4UzZRCsbUt0WlSDsDsA==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.10.4.tgz", + "integrity": "sha512-toLIHUIAgcQygFZRAQcsLQV3CBuX6yOIru1kJk/qqqvcRmZrYe6WavZTSG+bB8MxhnL9YPf+pKQfuiP161q7ng==", "dev": true, "requires": { - "@babel/types": "^7.10.2", + "@babel/types": "^7.10.4", "jsesc": "^2.5.1", "lodash": "^4.17.13", "source-map": "^0.5.0" } }, "@babel/helper-function-name": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.1.tgz", - "integrity": "sha512-fcpumwhs3YyZ/ttd5Rz0xn0TpIwVkN7X0V38B9TWNfVF42KEkhkAAuPCQ3oXmtTRtiPJrmZ0TrfS0GKF0eMaRQ==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz", + "integrity": "sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==", "dev": true, "requires": { - "@babel/helper-get-function-arity": "^7.10.1", - "@babel/template": "^7.10.1", - "@babel/types": "^7.10.1" + "@babel/helper-get-function-arity": "^7.10.4", + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" } }, "@babel/helper-get-function-arity": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.1.tgz", - "integrity": "sha512-F5qdXkYGOQUb0hpRaPoetF9AnsXknKjWMZ+wmsIRsp5ge5sFh4c3h1eH2pRTTuy9KKAA2+TTYomGXAtEL2fQEw==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz", + "integrity": "sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==", "dev": true, "requires": { - "@babel/types": "^7.10.1" + "@babel/types": "^7.10.4" } }, "@babel/helper-split-export-declaration": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.1.tgz", - "integrity": "sha512-UQ1LVBPrYdbchNhLwj6fetj46BcFwfS4NllJo/1aJsT+1dLTEnXJL0qHqtY7gPzF8S2fXBJamf1biAXV3X077g==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.10.4.tgz", + "integrity": "sha512-pySBTeoUff56fL5CBU2hWm9TesA4r/rOkI9DyJLvvgz09MB9YtfIYe3iBriVaYNaPe+Alua0vBIOVOLs2buWhg==", "dev": true, "requires": { - "@babel/types": "^7.10.1" + "@babel/types": "^7.10.4" } }, "@babel/helper-validator-identifier": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.1.tgz", - "integrity": "sha512-5vW/JXLALhczRCWP0PnFDMCJAchlBvM7f4uk/jXritBnIa6E1KmqmtrS3yn1LAnxFBypQ3eneLuXjsnfQsgILw==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", "dev": true }, "@babel/highlight": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.1.tgz", - "integrity": "sha512-8rMof+gVP8mxYZApLF/JgNDAkdKa+aJt3ZYxF8z6+j/hpeXL7iMsKCPHa2jNMHu/qqBwzQF4OHNoYi8dMA/rYg==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", + "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.10.1", + "@babel/helper-validator-identifier": "^7.10.4", "chalk": "^2.0.0", "js-tokens": "^4.0.0" } }, "@babel/parser": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.2.tgz", - "integrity": "sha512-PApSXlNMJyB4JiGVhCOlzKIif+TKFTvu0aQAhnTvfP/z3vVSN6ZypH5bfUNwFXXjRQtUEBNFd2PtmCmG2Py3qQ==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.10.4.tgz", + "integrity": "sha512-8jHII4hf+YVDsskTF6WuMB3X4Eh+PsUkC2ljq22so5rHvH+T8BzyL94VOdyFLNR8tBSVXOTbNHOKpR4TfRxVtA==", "dev": true }, "@babel/template": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.1.tgz", - "integrity": "sha512-OQDg6SqvFSsc9A0ej6SKINWrpJiNonRIniYondK2ViKhB06i3c0s+76XUft71iqBEe9S1OKsHwPAjfHnuvnCig==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.10.4.tgz", + "integrity": "sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==", "dev": true, "requires": { - "@babel/code-frame": "^7.10.1", - "@babel/parser": "^7.10.1", - "@babel/types": "^7.10.1" + "@babel/code-frame": "^7.10.4", + "@babel/parser": "^7.10.4", + "@babel/types": "^7.10.4" } }, "@babel/traverse": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.1.tgz", - "integrity": "sha512-C/cTuXeKt85K+p08jN6vMDz8vSV0vZcI0wmQ36o6mjbuo++kPMdpOYw23W2XH04dbRt9/nMEfA4W3eR21CD+TQ==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.10.4.tgz", + "integrity": "sha512-aSy7p5THgSYm4YyxNGz6jZpXf+Ok40QF3aA2LyIONkDHpAcJzDUqlCKXv6peqYUs2gmic849C/t2HKw2a2K20Q==", "dev": true, "requires": { - "@babel/code-frame": "^7.10.1", - "@babel/generator": "^7.10.1", - "@babel/helper-function-name": "^7.10.1", - "@babel/helper-split-export-declaration": "^7.10.1", - "@babel/parser": "^7.10.1", - "@babel/types": "^7.10.1", + "@babel/code-frame": "^7.10.4", + "@babel/generator": "^7.10.4", + "@babel/helper-function-name": "^7.10.4", + "@babel/helper-split-export-declaration": "^7.10.4", + "@babel/parser": "^7.10.4", + "@babel/types": "^7.10.4", "debug": "^4.1.0", "globals": "^11.1.0", "lodash": "^4.17.13" } }, "@babel/types": { - "version": "7.10.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.2.tgz", - "integrity": "sha512-AD3AwWBSz0AWF0AkCN9VPiWrvldXq+/e3cHa4J89vo4ymjz1XwrBFFVZmkJTsQIPNk+ZVomPSXUJqq8yyjZsng==", + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.10.4.tgz", + "integrity": "sha512-UTCFOxC3FsFHb7lkRMVvgLzaRVamXuAs2Tz4wajva4WxtVY82eZeaUBtC2Zt95FU9TiznuC0Zk35tsim8jeVpg==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.10.1", + "@babel/helper-validator-identifier": "^7.10.4", "lodash": "^4.17.13", "to-fast-properties": "^2.0.0" } @@ -3427,12 +3331,6 @@ "@babel/types": "^7.11.0" } }, - "@babel/helper-validator-identifier": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", - "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", - "dev": true - }, "@babel/highlight": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", @@ -3613,12 +3511,6 @@ "@babel/types": "^7.11.0" } }, - "@babel/helper-validator-identifier": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", - "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", - "dev": true - }, "@babel/highlight": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", @@ -3711,12 +3603,6 @@ "@babel/types": "^7.10.4" } }, - "@babel/helper-validator-identifier": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", - "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", - "dev": true - }, "@babel/types": { "version": "7.11.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.11.5.tgz", @@ -4007,6 +3893,14 @@ "@babel/helper-validator-identifier": "^7.9.0", "lodash": "^4.17.13", "to-fast-properties": "^2.0.0" + }, + "dependencies": { + "@babel/helper-validator-identifier": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz", + "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==", + "dev": true + } } }, "@cfstratos/ajsf-core": { @@ -4027,6 +3921,26 @@ "lodash-es": "^4.17.15" } }, + "@cfstratos/monaco-yaml": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cfstratos/monaco-yaml/-/monaco-yaml-2.5.0.tgz", + "integrity": "sha512-4ojHL0lnXZHHW8k2J8ipaXsl+aQj3bOXVdJEIwyhLtsJcYpq3npEiQkQs/U7ySi4Ehm/3qTdccXlilAyuWLxyw==", + "requires": { + "js-yaml": "^3.12.0", + "prettier": "^1.19.1", + "vscode-json-languageservice": "^3.8.3", + "vscode-languageserver": "^6.1.1", + "vscode-uri": "^2.1.2" + }, + "dependencies": { + "prettier": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz", + "integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==", + "optional": true + } + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -4139,9 +4053,9 @@ } }, "@jsdevtools/ono": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.2.tgz", - "integrity": "sha512-qS/a24RA5FEoiJS9wiv6Pwg2c/kiUo3IVUQcfeM9JvsR6pM8Yx+yl/6xWYLckZCT5jpLNhslgjiA8p/XcGyMRQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true }, "@ngrx/effects": { @@ -4277,12 +4191,12 @@ } }, "@rollup/plugin-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.0.2.tgz", - "integrity": "sha512-t4zJMc98BdH42mBuzjhQA7dKh0t4vMJlUka6Fz0c+iO5IVnWaEMiYBy1uBj9ruHZzXBW23IPDGL9oCzBkQ9Udg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz", + "integrity": "sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==", "dev": true, "requires": { - "@rollup/pluginutils": "^3.0.4" + "@rollup/pluginutils": "^3.0.8" } }, "@rollup/plugin-node-resolve": { @@ -4524,28 +4438,6 @@ "requires": { "@angular-devkit/core": "9.1.7", "@angular-devkit/schematics": "9.1.7" - }, - "dependencies": { - "@angular-devkit/schematics": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-9.1.7.tgz", - "integrity": "sha512-oeHPJePBcPp/bd94jHQeFUnft93PGF5iJiKV9szxqS8WWC5OMZ5eK7icRY0PwvLyfenspAZxdZcNaqJqPMul5A==", - "dev": true, - "requires": { - "@angular-devkit/core": "9.1.7", - "ora": "4.0.3", - "rxjs": "6.5.4" - } - }, - "rxjs": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", - "integrity": "sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==", - "dev": true, - "requires": { - "tslib": "^1.9.0" - } - } } }, "@schematics/update": { @@ -4565,19 +4457,6 @@ "semver-intersect": "1.4.0" }, "dependencies": { - "@angular-devkit/core": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-9.1.7.tgz", - "integrity": "sha512-guvolu9Cl+qYMTtedLZD9wCqustJjdqzJ2psD2C1Sr1LrX9T0mprmDldR/YnhsitThveJEb6sM/0EvqWxoSvKw==", - "dev": true, - "requires": { - "ajv": "6.12.0", - "fast-json-stable-stringify": "2.1.0", - "magic-string": "0.25.7", - "rxjs": "6.5.4", - "source-map": "0.7.3" - } - }, "rxjs": { "version": "6.5.4", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz", @@ -4592,12 +4471,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.1.3.tgz", "integrity": "sha512-ekM0zfiA9SCBlsKa2X1hxyxiI4L3B6EbVJkkdgQXnSEEaHlGdvyodMruTiulSRWMMB4NeIuYNMC9rTKTz97GxA==", "dev": true - }, - "source-map": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz", - "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==", - "dev": true } } }, @@ -4625,6 +4498,24 @@ "d3-transition": "^1.3.2" } }, + "@swimlane/ngx-graph": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@swimlane/ngx-graph/-/ngx-graph-7.0.1.tgz", + "integrity": "sha512-V85EuEJr61AM3J24slsiUkg6eak6J8IBy5zeD55fywLl3QbndRELRp3l/T2wu/HNpzyHzrC0/qpkEauqcHtRsA==", + "requires": { + "@swimlane/ngx-charts": "^13.0.1", + "d3-dispatch": "^1.0.3", + "d3-ease": "^1.0.5", + "d3-force": "^1.1.0", + "d3-selection": "^1.2.0", + "d3-shape": "^1.2.0", + "d3-timer": "^1.0.7", + "d3-transition": "^1.1.1", + "dagre": "^0.8.4", + "transformation-matrix": "^1.15.3", + "webcola": "^3.3.8" + } + }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", @@ -5285,7 +5176,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "requires": { "sprintf-js": "~1.0.2" } @@ -6269,16 +6159,6 @@ "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==", "dev": true - }, - "pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "requires": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } } } }, @@ -7537,6 +7417,11 @@ "d3-transition": "1" } }, + "d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" + }, "d3-color": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", @@ -7561,6 +7446,17 @@ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.6.tgz", "integrity": "sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ==" }, + "d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "requires": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, "d3-format": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.4.tgz", @@ -7584,6 +7480,11 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" }, + "d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" + }, "d3-scale": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.2.1.tgz", @@ -7640,6 +7541,15 @@ "d3-timer": "1" } }, + "dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "requires": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "damerau-levenshtein": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz", @@ -8086,7 +7996,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.2.0.tgz", "integrity": "sha512-uEUyaDKoSQ1M4Oq8l45hSE26SnTxL6snNnqvK/VWx5wJhmff5z0FUVJDKDanor/6w3kzE3i7XZOk+7wC0EXr1A==", - "dev": true, "requires": { "is-obj": "^2.0.0" }, @@ -8094,8 +8003,7 @@ "is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", - "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", - "dev": true + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==" } } }, @@ -8747,8 +8655,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esrecurse": { "version": "4.2.1", @@ -9835,6 +9742,14 @@ "integrity": "sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==", "dev": true }, + "graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "requires": { + "lodash": "^4.17.15" + } + }, "handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -10498,8 +10413,7 @@ "indexes-of": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", - "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=", - "dev": true + "integrity": "sha1-8w9xbI4r00bHtn0985FVZqfAVgc=" }, "indexof": { "version": "0.0.1", @@ -11604,7 +11518,6 @@ "version": "3.13.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", - "dev": true, "requires": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -11641,18 +11554,18 @@ "dev": true }, "json-schema-ref-parser": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.1.tgz", - "integrity": "sha512-KLrCjRjW5hMXxsX4osVBWpwixXL9NtICfpyNNS0eHguN5mP/I4UatI7i7PFS8jU94b1NHF4EbirACdCn0RFPBA==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/json-schema-ref-parser/-/json-schema-ref-parser-9.0.6.tgz", + "integrity": "sha512-z0JGv7rRD3CnJbZY/qCpscyArdtLJhr/wRBmFUdoZ8xMjsFyNdILSprG2degqRLjBjyhZHAEBpGOxniO9rKTxA==", "dev": true, "requires": { - "@apidevtools/json-schema-ref-parser": "9.0.1" + "@apidevtools/json-schema-ref-parser": "9.0.6" } }, "json-schema-to-typescript": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-9.1.0.tgz", - "integrity": "sha512-9/yDXQQyqtRDxohQGRCKht4Wjfg73TALi1yzy651EOo71a6aKFtIm2WUbDWSf8OitFGukUn00dx4t1kg0W6O4Q==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-9.1.1.tgz", + "integrity": "sha512-VrdxmwQROjPBRlHxXwGUa2xzhOMPiNZIVsxZrZjMYtbI7suRFMiEktqaD/gqhfSya7Djy+x8dnJT+H0/0sZO0Q==", "dev": true, "requires": { "@types/json-schema": "^7.0.4", @@ -11669,20 +11582,6 @@ "stdin": "0.0.1" }, "dependencies": { - "glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", - "dev": true, - "requires": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, "mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -11726,6 +11625,11 @@ "minimist": "^1.2.5" } }, + "jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==" + }, "jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", @@ -11905,13 +11809,6 @@ "path-exists": "^4.0.0" } }, - "fsevents": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz", - "integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==", - "dev": true, - "optional": true - }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -12465,8 +12362,7 @@ "lodash": { "version": "4.17.19", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", - "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", - "dev": true + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==" }, "lodash-es": { "version": "4.17.15", @@ -13574,6 +13470,11 @@ "tslib": "^1.9.0" } }, + "ngx-monaco-editor": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/ngx-monaco-editor/-/ngx-monaco-editor-9.0.0.tgz", + "integrity": "sha512-fPXT3M8W920Vs0KPMX6iA7GJYjBRnl9naug9A7D2inPPzxQGtgPZrSEatIPf37a6ir9Ts+8fwt1bTkFzfTgIpQ==" + }, "nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -15770,6 +15671,16 @@ "postcss-value-parser": "^3.0.0" }, "dependencies": { + "postcss-selector-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-3.1.2.tgz", + "integrity": "sha512-h7fJ/5uWuRVyOtkO45pnt1Ih40CEleeyCHzipqAZO2e5H20g25Y48uYnFUiShvY4rZWNJ/Bib/KVPmanaCtOhA==", + "requires": { + "dot-prop": "^5.2.0", + "indexes-of": "^1.0.1", + "uniq": "^1.0.1" + } + }, "postcss-value-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", @@ -15790,11 +15701,28 @@ "postcss-value-parser": "^3.0.0" }, "dependencies": { + "postcss": { + "version": "7.0.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.32.tgz", + "integrity": "sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw==", + "dev": true, + "requires": { + "chalk": "^2.4.2", + "source-map": "^0.6.1", + "supports-color": "^6.1.0" + } + }, "postcss-value-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true } } }, @@ -18358,8 +18286,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", - "dev": true + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, "sshpk": { "version": "1.16.1", @@ -19462,9 +19389,9 @@ "dev": true }, "thenify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.0.tgz", - "integrity": "sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", "dev": true, "requires": { "any-promise": "^1.0.0" @@ -19616,6 +19543,11 @@ "punycode": "^2.1.1" } }, + "transformation-matrix": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/transformation-matrix/-/transformation-matrix-1.15.3.tgz", + "integrity": "sha512-ThJH58GNFKhCw3gIoOtwf3tNwuYjbyEeiGdeq4mNMYWdJctnI896KUqn6PVt7jmNVepqa1bcKQtnMB1HtjsDMA==" + }, "tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -19845,8 +19777,7 @@ "uniq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", - "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=", - "dev": true + "integrity": "sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=" }, "uniqs": { "version": "2.0.0", @@ -20295,6 +20226,67 @@ "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=", "dev": true }, + "vscode-json-languageservice": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-3.8.4.tgz", + "integrity": "sha512-njDG0+YJvYNKXH+6plQGZMxgbifATFrRpC6Qnm/SAn4IW8bMHxsYunsxrjtpqK42CVSz6Lr7bpbTEZbVuOmFLw==", + "requires": { + "jsonc-parser": "^2.3.1", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "3.16.0-next.2", + "vscode-nls": "^5.0.0", + "vscode-uri": "^2.1.2" + } + }, + "vscode-jsonrpc": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-5.0.1.tgz", + "integrity": "sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==" + }, + "vscode-languageserver": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-6.1.1.tgz", + "integrity": "sha512-DueEpkUAkD5XTR4MLYNr6bQIp/UFR0/IPApgXU3YfCBCB08u2sm9hRCs6DxYZELkk++STPjpcjksR2H8qI3cDQ==", + "requires": { + "vscode-languageserver-protocol": "^3.15.3" + } + }, + "vscode-languageserver-protocol": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.15.3.tgz", + "integrity": "sha512-zrMuwHOAQRhjDSnflWdJG+O2ztMWss8GqUUB8dXLR/FPenwkiBNkMIJJYfSN6sgskvsF0rHAoBowNQfbyZnnvw==", + "requires": { + "vscode-jsonrpc": "^5.0.1", + "vscode-languageserver-types": "3.15.1" + }, + "dependencies": { + "vscode-languageserver-types": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.15.1.tgz", + "integrity": "sha512-+a9MPUQrNGRrGU630OGbYVQ+11iOIovjCkqxajPa9w57Sd5ruK8WQNsslzpa0x/QJqC8kRc2DUxWjIFwoNm4ZQ==" + } + } + }, + "vscode-languageserver-textdocument": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz", + "integrity": "sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA==" + }, + "vscode-languageserver-types": { + "version": "3.16.0-next.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0-next.2.tgz", + "integrity": "sha512-QjXB7CKIfFzKbiCJC4OWC8xUncLsxo19FzGVp/ADFvvi87PlmBSCAtZI5xwGjF5qE0xkLf0jjKUn3DzmpDP52Q==" + }, + "vscode-nls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.0.0.tgz", + "integrity": "sha512-u0Lw+IYlgbEJFF6/qAqG2d1jQmJl0eyAGJHoAJqr2HT4M2BNuQYSEiSE75f52pXHSJm8AlTjnLLbBFPrdz2hpA==" + }, + "vscode-uri": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", + "integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==" + }, "watchpack": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.4.tgz", @@ -20555,6 +20547,17 @@ "resolved": "https://registry.npmjs.org/web-animations-js/-/web-animations-js-2.3.2.tgz", "integrity": "sha512-TOMFWtQdxzjWp8qx4DAraTWTsdhxVSiWa6NkPFSaPtZ1diKUxTn4yTix73A1euG1WbSOMMPcY51cnjTIHrGtDA==" }, + "webcola": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webcola/-/webcola-3.4.0.tgz", + "integrity": "sha512-4BiLXjXw3SJHo3Xd+rF+7fyClT6n7I+AR6TkBqyQ4kTsePSAMDLRCXY1f3B/kXJeP9tYn4G1TblxTO+jAt0gaw==", + "requires": { + "d3-dispatch": "^1.0.3", + "d3-drag": "^1.0.4", + "d3-shape": "^1.3.5", + "d3-timer": "^1.0.5" + } + }, "webdriver-js-extender": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/webdriver-js-extender/-/webdriver-js-extender-2.1.0.tgz", diff --git a/package.json b/package.json index 250a84ddd3..a4bf816bb3 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Stratos Console", "main": "index.js", "scripts": { - "build-backend": "npm run prepare-backend && ./build/bk-build.sh", + "build-backend": "./build/bk-build.sh", "full-build-dev": "npm run build-dev; npm run build-backend", "fetch-backend-deps": "./build/bk-fetch-deps.sh", "test-backend": "./build/bk-build.sh test", @@ -27,6 +27,7 @@ "test-frontend:store": "NG_TEST_SUITE=store ng test store --code-coverage --watch=false", "test-frontend:cloud-foundry": "NG_TEST_SUITE=cloud-foundry ng test cloud-foundry --code-coverage --watch=false", "test-frontend:cf-autoscaler": "NG_TEST_SUITE=autoscaler ng test cf-autoscaler --code-coverage --watch=false", + "test-frontend:kubernetes": "NG_TEST_SUITE=kubernetes ng test kubernetes --code-coverage --watch=false", "posttest": "nyc report --reporter=html --reporter=lcovonly --reporter=json --tempDir=coverage/nyc", "codecov": "codecov -f coverage/coverage-final.json", "lint": "ng lint --format stylish", @@ -42,7 +43,7 @@ "build-devkit": "cd src/frontend/packages/devkit && npm run build", "clean-symlinks": "node build/clean-symlinks.js", "dev-setup": "node build/dev-setup.js", - "prepare-backend":"node dist-devkit/backend.js" + "prepare-backend": "node dist-devkit/backend.js" }, "author": "", "license": "Apache-2.0", @@ -60,30 +61,33 @@ "@angular/platform-browser-dynamic": "^9.1.6", "@angular/platform-server": "^9.1.6", "@angular/router": "^9.1.6", + "@cfstratos/ajsf-material": "^0.1.6", + "@cfstratos/monaco-yaml": "^2.5.0", "@ngrx/effects": "^9.1.2", "@ngrx/router-store": "^9.1.2", "@ngrx/store": "^9.1.2", "@ngrx/store-devtools": "^9.1.2", "@swimlane/ngx-charts": "^13.0.3", - "@types/moment-timezone": "^0.5.13", + "@swimlane/ngx-graph": "^7.0.1", "@types/marked": "^0.7.4", + "@types/moment-timezone": "^0.5.13", "angular2-virtual-scroll": "^0.4.16", "core-js": "^3.6.5", "immer": "^6.0.3", + "intersect": "^1.0.1", "lodash-es": "^4.17.14", "mappy-breakpoints": "^0.2.3", "marked": "^0.8.2", - "intersect": "^1.0.1", "moment": "^2.24.0", "moment-timezone": "^0.5.28", "ngrx-store-localstorage": "9.0.0", "ngx-moment": "^3.5.0", + "ngx-monaco-editor": "^9.0.0", "normalizr": "^3.6.0", "reselect": "^4.0.0", "rxjs": "^6.5.5", "rxjs-spy": "^7.0.2", "rxjs-websockets": "~8.0.1", - "@cfstratos/ajsf-material": "^0.1.6", "ts-md5": "^1.2.7", "tslib": "^1.10.0", "web-animations-js": "^2.3.2", diff --git a/protractor.conf.js b/protractor.conf.js index 42fe5d0015..38d078a47f 100644 --- a/protractor.conf.js +++ b/protractor.conf.js @@ -12,7 +12,9 @@ const globby = require('globby'); const timeReporterPlugin = require('./src/test-e2e/time-reporter-plugin.js'); const browserReporterPlugin = require('./src/test-e2e/browser-reporter-plugin.js'); const https = require('https'); -const { ProtractorBrowserLogReporter } = require('jasmine-protractor-browser-log-reporter'); +const { + ProtractorBrowserLogReporter +} = require('jasmine-protractor-browser-log-reporter'); // Test report folder name var timestamp = moment().format('YYYYDDMM-hh.mm.ss'); diff --git a/src/frontend/packages/core/src/features/login/login-page/login-page.component.theme.scss b/src/frontend/packages/core/src/features/login/login-page/login-page.component.theme.scss index 9a682c63c1..2b41be411b 100644 --- a/src/frontend/packages/core/src/features/login/login-page/login-page.component.theme.scss +++ b/src/frontend/packages/core/src/features/login/login-page/login-page.component.theme.scss @@ -1,5 +1,6 @@ @mixin login-page-theme($theme, $app-theme) { $primary: map-get($theme, primary); + .login { background-color: map-get($app-theme, app-background-color); &__title { @@ -9,4 +10,5 @@ color: mat-contrast($primary, 500); } } + } diff --git a/src/frontend/packages/core/src/features/login/logout-page/logout-page.component.spec.ts b/src/frontend/packages/core/src/features/login/logout-page/logout-page.component.spec.ts index fff7bac83e..5029385a87 100644 --- a/src/frontend/packages/core/src/features/login/logout-page/logout-page.component.spec.ts +++ b/src/frontend/packages/core/src/features/login/logout-page/logout-page.component.spec.ts @@ -1,11 +1,11 @@ import { CommonModule } from '@angular/common'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { CoreModule } from '@angular/flex-layout'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; import { StoreModule } from '@ngrx/store'; import { appReducers } from '../../../../../store/src/reducers.module'; +import { CoreModule } from '../../../core/core.module'; import { SharedModule } from '../../../public-api'; import { LogoutPageComponent } from './logout-page.component'; @@ -15,7 +15,7 @@ describe('LogoutPageComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ LogoutPageComponent ], + declarations: [LogoutPageComponent], imports: [ CommonModule, CoreModule, @@ -27,7 +27,7 @@ describe('LogoutPageComponent', () => { ) ] }) - .compileComponents(); + .compileComponents(); })); beforeEach(() => { diff --git a/src/frontend/packages/kubernetes/assets/custom/aks.svg b/src/frontend/packages/kubernetes/assets/custom/aks.svg new file mode 100644 index 0000000000..b54c47bfe8 --- /dev/null +++ b/src/frontend/packages/kubernetes/assets/custom/aks.svg @@ -0,0 +1,28 @@ + + + + + background + + + + Layer 1 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/assets/custom/app_placeholder.svg b/src/frontend/packages/kubernetes/assets/custom/app_placeholder.svg new file mode 100644 index 0000000000..98cd9de0d5 --- /dev/null +++ b/src/frontend/packages/kubernetes/assets/custom/app_placeholder.svg @@ -0,0 +1,9 @@ + + app_placeholder + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/assets/custom/caasp.png b/src/frontend/packages/kubernetes/assets/custom/caasp.png new file mode 100644 index 0000000000..5a57afdc7d Binary files /dev/null and b/src/frontend/packages/kubernetes/assets/custom/caasp.png differ diff --git a/src/frontend/packages/kubernetes/assets/custom/eks.svg b/src/frontend/packages/kubernetes/assets/custom/eks.svg new file mode 100644 index 0000000000..4ef2b4478c --- /dev/null +++ b/src/frontend/packages/kubernetes/assets/custom/eks.svg @@ -0,0 +1,18 @@ + + + + providers-resize + + background + + + + Layer 1 + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/assets/custom/gke.svg b/src/frontend/packages/kubernetes/assets/custom/gke.svg new file mode 100644 index 0000000000..9750cceb23 --- /dev/null +++ b/src/frontend/packages/kubernetes/assets/custom/gke.svg @@ -0,0 +1,18 @@ + + + + + background + + + + Layer 1 + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/assets/custom/helm.svg b/src/frontend/packages/kubernetes/assets/custom/helm.svg new file mode 100644 index 0000000000..45f2c2f862 --- /dev/null +++ b/src/frontend/packages/kubernetes/assets/custom/helm.svg @@ -0,0 +1,43 @@ + + + + +logo +Created with Sketch. + + + + + + diff --git a/src/frontend/packages/kubernetes/assets/custom/help/en/connecting_gke.md b/src/frontend/packages/kubernetes/assets/custom/help/en/connecting_gke.md new file mode 100644 index 0000000000..6940477366 --- /dev/null +++ b/src/frontend/packages/kubernetes/assets/custom/help/en/connecting_gke.md @@ -0,0 +1,21 @@ +# Connecting to Google Kubernetes Engine + +You can connect a Google Kubernetes Engine (GKE Cluster) using Application Default Credentials. + +To obtain a credentials file to upload, you should: + +1. Install the **gcloud** Command Line Interface +1. Run the command: `gcloud auth application-default login` +1. Authenticate with Google in the opened web browser to obtain credentials + +A credentials file will be written to: + +``` +~/.config/gcloud/application_default_credentials.json +``` + +This is the file that you should use when connecting in the endpoint connection dialog. + +> Note: You may need to copy this file to a non-hidden folder in order to be able to browse to it in the UI (or enable hidden files in your OS's file browser) + +> Note: For more information on obtaining Application Default Credentials, refer to https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/assets/custom/k3s.svg b/src/frontend/packages/kubernetes/assets/custom/k3s.svg new file mode 100755 index 0000000000..1f4cd9d50b --- /dev/null +++ b/src/frontend/packages/kubernetes/assets/custom/k3s.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/assets/custom/kube_import.png b/src/frontend/packages/kubernetes/assets/custom/kube_import.png new file mode 100644 index 0000000000..62d9f0bdd2 Binary files /dev/null and b/src/frontend/packages/kubernetes/assets/custom/kube_import.png differ diff --git a/src/frontend/packages/kubernetes/assets/custom/kubernetes.svg b/src/frontend/packages/kubernetes/assets/custom/kubernetes.svg new file mode 100644 index 0000000000..bd6b1464fe --- /dev/null +++ b/src/frontend/packages/kubernetes/assets/custom/kubernetes.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/assets/custom/placeholder.png b/src/frontend/packages/kubernetes/assets/custom/placeholder.png new file mode 100644 index 0000000000..6ef0a4f5f2 Binary files /dev/null and b/src/frontend/packages/kubernetes/assets/custom/placeholder.png differ diff --git a/src/frontend/packages/kubernetes/karma.conf.js b/src/frontend/packages/kubernetes/karma.conf.js new file mode 100644 index 0000000000..c23e417786 --- /dev/null +++ b/src/frontend/packages/kubernetes/karma.conf.js @@ -0,0 +1,8 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + ...require('../../../../build/karma.conf.creator.js')('extensions')(config) + }) +} diff --git a/src/frontend/packages/kubernetes/package.json b/src/frontend/packages/kubernetes/package.json new file mode 100644 index 0000000000..6019510ad0 --- /dev/null +++ b/src/frontend/packages/kubernetes/package.json @@ -0,0 +1,17 @@ +{ + "name": "@stratosui/kubernetes", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^6.0.0-rc.0 || ^6.0.0", + "@angular/core": "^6.0.0-rc.0 || ^6.0.0" + }, + "stratos": { + "module": "KubePackageModule", + "routingModule": "KubePackageModuleRoutingModule", + "theming": "sass/_all-theme#apply-theme-kubernetes", + "assets": { + "assets": "core/assets" + }, + "backend": ["analysis", "kubernetes", "monocular"] + } +} diff --git a/src/frontend/packages/kubernetes/sass/_all-theme.scss b/src/frontend/packages/kubernetes/sass/_all-theme.scss new file mode 100644 index 0000000000..8a917544fa --- /dev/null +++ b/src/frontend/packages/kubernetes/sass/_all-theme.scss @@ -0,0 +1,24 @@ +// Theming for the copmponents in the Kubernetes package + +@import '../src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.theme'; +@import '../src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.theme'; +@import '../src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.theme'; +@import '../src/helm/list-types/monocular-chart-card/monocular-chart-card.component.theme'; +@import '../src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.theme'; +@import '../src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.theme'; +@import '../src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme'; + +@mixin apply-theme-kubernetes($stratos-theme) { + + $theme: map-get($stratos-theme, theme); + $app-theme: map-get($stratos-theme, app-theme); + + @include kube-summary-theme($theme, $app-theme); + @include kube-analysis-report-theme($theme, $app-theme); + @include kube-analysis-card-theme($theme, $app-theme); + @include monocular-chart-card($theme, $app-theme); + @include helm-release-summary-tab-theme($theme, $app-theme); + @include kube-node-link-theme($theme, $app-theme); + @include app-chart-values-editor-theme($theme, $app-theme); + +} diff --git a/src/frontend/packages/kubernetes/src/helm/chart-view/monocular.component.html b/src/frontend/packages/kubernetes/src/helm/chart-view/monocular.component.html new file mode 100644 index 0000000000..cafd403267 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/chart-view/monocular.component.html @@ -0,0 +1,5 @@ + +

{{ title }}

+
+ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/chart-view/monocular.component.scss b/src/frontend/packages/kubernetes/src/helm/chart-view/monocular.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/helm/chart-view/monocular.component.ts b/src/frontend/packages/kubernetes/src/helm/chart-view/monocular.component.ts new file mode 100644 index 0000000000..fd788562d4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/chart-view/monocular.component.ts @@ -0,0 +1,48 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { ChartsService } from '../monocular/shared/services/charts.service'; +import { createMonocularProviders } from '../monocular/stratos-monocular-providers.helpers'; + + +@Component({ + selector: 'app-monocular', + templateUrl: './monocular.component.html', + styleUrls: ['./monocular.component.scss'], + providers: [ + ...createMonocularProviders() + ] +}) +export class MonocularChartViewComponent implements OnInit { + + public breadcrumbs = []; + + public title = ''; + + constructor( + private route: ActivatedRoute, + private chartService: ChartsService + ) { } + + public ngOnInit() { + + // Set breadcrumbs + const breadcrumbs = [ + { value: 'Helm' }, + { value: 'Charts', routerLink: '/monocular/charts' }]; + + // Deconstruct the URL + const parts = this.route.snapshot.params; + this.title = parts.chartName; + + if (!!parts.version) { + breadcrumbs.push( + { value: this.title, routerLink: this.chartService.getChartSummaryRoute(parts.repo, parts.chartName, null, this.route) } + ); + this.title = parts.version; + } + + this.breadcrumbs = [{ breadcrumbs }]; + } + +} diff --git a/src/frontend/packages/kubernetes/src/helm/helm-entity-catalog.ts b/src/frontend/packages/kubernetes/src/helm/helm-entity-catalog.ts new file mode 100644 index 0000000000..ec62f85798 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm-entity-catalog.ts @@ -0,0 +1,28 @@ +import { + StratosCatalogEndpointEntity, + StratosCatalogEntity, +} from '../../../store/src/entity-catalog/entity-catalog-entity/entity-catalog-entity'; +import { IFavoriteMetadata } from '../../../store/src/types/user-favorites.types'; +import { + HelmChartActionBuilders, + HelmChartVersionsActionBuilders, + HelmVersionActionBuilders, +} from './store/helm.action-builders'; +import { HelmVersion, MonocularChart, MonocularVersion } from './store/helm.types'; + +/** + * A strongly typed collection of Helm Catalog Entities. + * This can be used to access functionality exposed by each specific type, such as get, update, delete, etc + */ +export class HelmEntityCatalog { + endpoint: StratosCatalogEndpointEntity; + chart: StratosCatalogEntity; + version: StratosCatalogEntity; + chartVersions: StratosCatalogEntity; +} + +/** + * A strongly typed collection of Helm Catalog Entities. + * This can be used to access functionality exposed by each specific type, such as get, update, delete, etc + */ +export const helmEntityCatalog: HelmEntityCatalog = new HelmEntityCatalog(); diff --git a/src/frontend/packages/kubernetes/src/helm/helm-entity-factory.ts b/src/frontend/packages/kubernetes/src/helm/helm-entity-factory.ts new file mode 100644 index 0000000000..3746c87fef --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm-entity-factory.ts @@ -0,0 +1,66 @@ +import { Schema, schema } from 'normalizr'; + +import { EntitySchema } from '../../../store/src/helpers/entity-schema'; +import { stratosMonocularEndpointGuid } from './monocular/stratos-monocular.helper'; +import { HelmVersion, MonocularChart } from './store/helm.types'; + +export const helmVersionsEntityType = 'helmVersions'; +export const monocularChartsEntityType = 'monocularCharts'; +export const monocularChartVersionsEntityType = 'monocularChartVersions'; + +export const HELM_ENDPOINT_TYPE = 'helm'; +export const HELM_REPO_ENDPOINT_TYPE = 'repo'; +export const HELM_HUB_ENDPOINT_TYPE = 'hub'; + +const entityCache: { + [key: string]: EntitySchema, +} = {}; + +export class HelmEntitySchema extends EntitySchema { + /** + * @param entityKey As per schema.Entity ctor + * @param [definition] As per schema.Entity ctor + * @param [options] As per schema.Entity ctor + * @param [relationKey] Allows multiple children of the same type within a single parent entity. For instance user with developer + * spaces, manager spaces, auditor space, etc + */ + constructor( + entityKey: string, + definition?: Schema, + options?: schema.EntityOptions, + relationKey?: string + ) { + super(entityKey, HELM_ENDPOINT_TYPE, definition, options, relationKey); + } +} + +entityCache[monocularChartsEntityType] = new HelmEntitySchema( + monocularChartsEntityType, + {}, + { + idAttribute: (entity: MonocularChart) => { + const monocularPrefix = entity.monocularEndpointId || stratosMonocularEndpointGuid; + return monocularPrefix + '/' + entity.id; + } + } +); + +entityCache[helmVersionsEntityType] = new HelmEntitySchema( + helmVersionsEntityType, + {}, + { idAttribute: (entity: HelmVersion) => entity.endpointId } +); + +entityCache[monocularChartVersionsEntityType] = new HelmEntitySchema( + monocularChartVersionsEntityType, + {}, + { idAttribute: (entity: MonocularChart) => entity.id } +); + +export function helmEntityFactory(key: string): EntitySchema { + const entity = entityCache[key]; + if (!entity) { + throw new Error(`Unknown entity schema type: ${key}`); + } + return entity; +} diff --git a/src/frontend/packages/kubernetes/src/helm/helm-entity-generator.ts b/src/frontend/packages/kubernetes/src/helm/helm-entity-generator.ts new file mode 100644 index 0000000000..7e14101f7a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm-entity-generator.ts @@ -0,0 +1,157 @@ +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; +import { catchError, first, map } from 'rxjs/operators'; + +import { IListAction } from '../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../store/src/app-state'; +import { + StratosBaseCatalogEntity, + StratosCatalogEndpointEntity, + StratosCatalogEntity, +} from '../../../store/src/entity-catalog/entity-catalog-entity/entity-catalog-entity'; +import { StratosEndpointExtensionDefinition } from '../../../store/src/entity-catalog/entity-catalog.types'; +import { EndpointModel } from '../../../store/src/public-api'; +import { stratosEntityCatalog } from '../../../store/src/stratos-entity-catalog'; +import { IFavoriteMetadata } from '../../../store/src/types/user-favorites.types'; +import { helmEntityCatalog } from './helm-entity-catalog'; +import { + HELM_ENDPOINT_TYPE, + HELM_HUB_ENDPOINT_TYPE, + HELM_REPO_ENDPOINT_TYPE, + helmEntityFactory, + helmVersionsEntityType, + monocularChartsEntityType, + monocularChartVersionsEntityType, +} from './helm-entity-factory'; +import { HelmHubRegistrationComponent } from './helm-hub-registration/helm-hub-registration.component'; +import { + HelmChartActionBuilders, + helmChartActionBuilders, + HelmChartVersionsActionBuilders, + helmChartVersionsActionBuilders, + HelmVersionActionBuilders, + helmVersionActionBuilders, +} from './store/helm.action-builders'; +import { HelmVersion, MonocularChart, MonocularVersion } from './store/helm.types'; + + +export function generateHelmEntities(): StratosBaseCatalogEntity[] { + const helmRepoRenderPriority = 10; + const endpointDefinition: StratosEndpointExtensionDefinition = { + type: HELM_ENDPOINT_TYPE, + logoUrl: '/core/assets/custom/helm.svg', + authTypes: [], + registeredLimit: 0, + icon: 'helm', + iconFont: 'stratos-icons', + label: 'Helm', + labelPlural: 'Helms', + subTypes: [ + { + type: HELM_REPO_ENDPOINT_TYPE, + label: 'Helm Repository', + labelPlural: 'Helm Repositories', + logoUrl: '/core/assets/custom/helm.svg', + urlValidation: undefined, + unConnectable: true, + techPreview: false, + authTypes: [], + endpointListActions: (store: Store): IListAction[] => { + return [{ + action: (item: EndpointModel) => { + helmEntityCatalog.chart.api.synchronise(item).pipe( + catchError(() => null), // Be super safe to ensure we pass the first filter + first() + ).subscribe(res => { + if (res != null) { + stratosEntityCatalog.endpoint.api.getAll(); + } + }); + }, + label: 'Synchronize', + description: '', + createVisible: row => row.pipe( + map(item => item.cnsi_type === HELM_ENDPOINT_TYPE && item.sub_type === HELM_REPO_ENDPOINT_TYPE) + ), + createEnabled: () => of(true) + }]; + }, + renderPriority: helmRepoRenderPriority, + registeredLimit: null, + }, + { + type: HELM_HUB_ENDPOINT_TYPE, + label: 'Helm Hub', + labelPlural: 'Helm Hubs', + authTypes: [], + unConnectable: true, + logoUrl: '/core/assets/custom/helm.svg', + renderPriority: helmRepoRenderPriority + 1, + registrationComponent: HelmHubRegistrationComponent, + registeredLimit: 1, + }, + ], + }; + + return [ + generateEndpointEntity(endpointDefinition), + generateChartEntity(endpointDefinition), + generateVersionEntity(endpointDefinition), + generateChartVersionsEntity(endpointDefinition), + ]; +} + +function generateEndpointEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + helmEntityCatalog.endpoint = new StratosCatalogEndpointEntity( + endpointDefinition, + metadata => `/monocular/charts`, + ); + return helmEntityCatalog.endpoint; +} + +function generateChartEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition = { + type: monocularChartsEntityType, + schema: helmEntityFactory(monocularChartsEntityType), + endpoint: endpointDefinition + }; + helmEntityCatalog.chart = new StratosCatalogEntity( + definition, + { + actionBuilders: helmChartActionBuilders + } + ); + return helmEntityCatalog.chart; +} + +function generateVersionEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition = { + type: helmVersionsEntityType, + schema: helmEntityFactory(helmVersionsEntityType), + endpoint: endpointDefinition + }; + helmEntityCatalog.version = new StratosCatalogEntity( + definition, + { + actionBuilders: helmVersionActionBuilders + } + ); + return helmEntityCatalog.version; +} + +function generateChartVersionsEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition = { + type: monocularChartVersionsEntityType, + schema: helmEntityFactory(monocularChartVersionsEntityType), + endpoint: endpointDefinition + }; + helmEntityCatalog.chartVersions = new StratosCatalogEntity( + definition, + { + actionBuilders: helmChartVersionsActionBuilders + } + ); + return helmEntityCatalog.chartVersions; +} + + diff --git a/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.html b/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.html new file mode 100644 index 0000000000..fe749d24f3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.html @@ -0,0 +1,10 @@ + + +
+

Helm Hub is a public repository for Helm Charts.

+

To browse and install these charts you need to register Helm Hub as an endpoint by clicking `Register` below. +

+

To disable Helm Hub simply unregister the Helm Hub endpoint

+
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.scss b/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.spec.ts new file mode 100644 index 0000000000..69893fc648 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EndpointsService } from '../../../../core/src/core/endpoints.service'; +import { UserService } from '../../../../core/src/core/user.service'; +import { BaseTestModules } from '../../../../core/test-framework/core-test.helper'; +import { HelmHubRegistrationComponent } from './helm-hub-registration.component'; + +describe('HelmHubRegistrationComponent', () => { + let component: HelmHubRegistrationComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [...BaseTestModules], + declarations: [HelmHubRegistrationComponent], + providers: [ + EndpointsService, + UserService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmHubRegistrationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.ts b/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.ts new file mode 100644 index 0000000000..45adcc2cdc --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm-hub-registration/helm-hub-registration.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { filter, map, pairwise } from 'rxjs/operators'; + +import { StepOnNextFunction } from '../../../../core/src/shared/components/stepper/step/step.component'; +import { ActionState } from '../../../../store/src/reducers/api-request-reducer/types'; +import { stratosEntityCatalog } from '../../../../store/src/stratos-entity-catalog'; +import { HELM_ENDPOINT_TYPE, HELM_HUB_ENDPOINT_TYPE } from '../helm-entity-factory'; + +@Component({ + selector: 'app-helm-hub-registration', + templateUrl: './helm-hub-registration.component.html', + styleUrls: ['./helm-hub-registration.component.scss'] +}) +export class HelmHubRegistrationComponent { + + onNext: StepOnNextFunction = () => { + return stratosEntityCatalog.endpoint.api.register( + HELM_ENDPOINT_TYPE, + HELM_HUB_ENDPOINT_TYPE, + 'Helm Hub', + 'https://hub.helm.sh/api', + false + ).pipe( + pairwise(), + filter(([oldV, newV]) => oldV.busy && !newV.busy), + map(([, newV]) => newV), + map(state => ({ + success: !state.error, + message: state.message, + redirect: !state.error + })) + ); + }; + +} diff --git a/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts b/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts new file mode 100644 index 0000000000..db5308232e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm-testing.module.ts @@ -0,0 +1,75 @@ +import { HttpClient, HttpClientModule, HttpHandler } from '@angular/common/http'; +import { NgModule } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { CoreModule, SharedModule } from '@stratosui/core'; + +import { AppTestModule } from '../../../core/test-framework/core-test.helper'; +import { + CATALOGUE_ENTITIES, + entityCatalog, + EntityCatalogFeatureModule, + TestEntityCatalog, +} from '../../../store/src/public-api'; +import { generateStratosEntities } from '../../../store/src/stratos-entity-generator'; +import { createBasicStoreModule } from '../../../store/testing/public-api'; +import { HelmReleaseGuid } from '../kubernetes/workloads/workload.types'; +import { generateHelmEntities } from './helm-entity-generator'; + + +@NgModule({ + imports: [{ + ngModule: EntityCatalogFeatureModule, + providers: [ + { + provide: CATALOGUE_ENTITIES, useFactory: () => { + const testEntityCatalog = entityCatalog as TestEntityCatalog; + testEntityCatalog.clear(); + return [ + ...generateStratosEntities(), + ...generateHelmEntities(), + ]; + } + } + ] + }] +}) +export class HelmTestingModule { } + + +export const HelmReleaseActivatedRouteMock = { + provide: ActivatedRoute, + useValue: { + snapshot: { + queryParams: {}, + params: { + guid: '123:4' + } + } + } +}; + +export const HelmReleaseGuidMock = { + provide: HelmReleaseGuid, + useValue: { + guid: '123:4' + } +}; + +export const HelmBaseTestModules = [ + AppTestModule, + HelmTestingModule, + RouterTestingModule, + CoreModule, + createBasicStoreModule(), + NoopAnimationsModule, + HttpClientModule, + SharedModule +]; + +export const HelmBaseTestProviders = [ + HttpClient, + HttpHandler +]; + diff --git a/src/frontend/packages/kubernetes/src/helm/helm.module.ts b/src/frontend/packages/kubernetes/src/helm/helm.module.ts new file mode 100644 index 0000000000..e2229215bf --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm.module.ts @@ -0,0 +1,34 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { CoreModule } from '../../../core/src/core/core.module'; +import { SharedModule } from '../../../core/src/shared/shared.module'; +import { MonocularChartViewComponent } from './chart-view/monocular.component'; +import { HelmRoutingModule } from './helm.routing'; +import { MonocularChartCardComponent } from './list-types/monocular-chart-card/monocular-chart-card.component'; +import { MonocularTabBaseComponent } from './monocular-tab-base/monocular-tab-base.component'; +import { MonocularModule } from './monocular/monocular.module'; +import { CatalogTabComponent } from './tabs/catalog-tab/catalog-tab.component'; + +@NgModule({ + imports: [ + CoreModule, + CommonModule, + SharedModule, + HelmRoutingModule, + MonocularModule + ], + declarations: [ + MonocularTabBaseComponent, + CatalogTabComponent, + MonocularChartCardComponent, + MonocularChartViewComponent, + ], + providers: [ + ], + entryComponents: [ + MonocularChartCardComponent, + ] +}) +export class HelmModule { } + diff --git a/src/frontend/packages/kubernetes/src/helm/helm.routing.ts b/src/frontend/packages/kubernetes/src/helm/helm.routing.ts new file mode 100644 index 0000000000..b523106013 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm.routing.ts @@ -0,0 +1,25 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { MonocularChartViewComponent } from './chart-view/monocular.component'; +import { MonocularTabBaseComponent } from './monocular-tab-base/monocular-tab-base.component'; +import { CatalogTabComponent } from './tabs/catalog-tab/catalog-tab.component'; + +const monocular: Routes = [ + { + path: '', + component: MonocularTabBaseComponent, + children: [ + { path: '', redirectTo: 'charts', pathMatch: 'full' }, + { path: 'charts', component: CatalogTabComponent }, + { path: 'charts/:repo', component: CatalogTabComponent }, + ] + }, + { pathMatch: 'full', path: 'charts/:endpoint/:repo/:chartName/:version', component: MonocularChartViewComponent }, + { path: 'charts/:endpoint/:repo/:chartName', component: MonocularChartViewComponent }, +]; + +@NgModule({ + imports: [RouterModule.forChild(monocular)] +}) +export class HelmRoutingModule { } diff --git a/src/frontend/packages/kubernetes/src/helm/helm.setup.module.ts b/src/frontend/packages/kubernetes/src/helm/helm.setup.module.ts new file mode 100644 index 0000000000..29ca39d039 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm.setup.module.ts @@ -0,0 +1,48 @@ +import { CommonModule } from '@angular/common'; +import { NgModule, Optional, SkipSelf } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { EndpointsService } from '../../../core/src/core/endpoints.service'; +import { CoreModule } from '../../../core/src/public-api'; +import { SharedModule } from '../../../core/src/shared/shared.module'; +import { GetSystemInfo } from '../../../store/src/actions/system.actions'; +import { EntityCatalogModule } from '../../../store/src/entity-catalog.module'; +import { EndpointHealthCheck } from '../../../store/src/entity-catalog/entity-catalog.types'; +import { AppState } from '../../../store/src/public-api'; +import { HELM_ENDPOINT_TYPE } from './helm-entity-factory'; +import { generateHelmEntities } from './helm-entity-generator'; +import { HelmHubRegistrationComponent } from './helm-hub-registration/helm-hub-registration.component'; +import { HelmStoreModule } from './helm.store.module'; + +@NgModule({ + imports: [ + EntityCatalogModule.forFeature(generateHelmEntities), + CoreModule, + CommonModule, + SharedModule, + HelmStoreModule, + ], + declarations: [ + HelmHubRegistrationComponent + ] +}) +export class HelmSetupModule { + constructor( + endpointService: EndpointsService, + store: Store, + @Optional() @SkipSelf() parentModule: HelmSetupModule + ) { + if (parentModule) { + // Module has already been imported + } else { + endpointService.registerHealthCheck( + new EndpointHealthCheck(HELM_ENDPOINT_TYPE, (endpoint) => { + if (endpoint.endpoint_metadata && endpoint.endpoint_metadata.status === 'Synchronizing') { + store.dispatch(new GetSystemInfo()); + } + }) + ); + } + + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/helm.store.module.ts b/src/frontend/packages/kubernetes/src/helm/helm.store.module.ts new file mode 100644 index 0000000000..481eaae349 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/helm.store.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; + +import { HelmEffects } from './store/helm.effects'; + +@NgModule({ + imports: [ + EffectsModule.forFeature([ + HelmEffects + ]), + ] +}) +export class HelmStoreModule { } diff --git a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.html b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.html new file mode 100644 index 0000000000..b676946fa2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.scss b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.spec.ts new file mode 100644 index 0000000000..7075b41929 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.spec.ts @@ -0,0 +1,57 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseTestModulesNoShared } from '../../../../../core/test-framework/core-test.helper'; +import { ChartItemComponent } from '../../monocular/chart-item/chart-item.component'; +import { ListItemComponent } from '../../monocular/list-item/list-item.component'; +import { ChartsService } from '../../monocular/shared/services/charts.service'; +import { ConfigService } from '../../monocular/shared/services/config.service'; +import { MonocularChart } from '../../store/helm.types'; +import { MonocularChartCardComponent } from './monocular-chart-card.component'; + +describe('MonocularChartCardComponent', () => { + let component: MonocularChartCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + MonocularChartCardComponent, + ChartItemComponent, + ListItemComponent + ], + imports: [ + ...BaseTestModulesNoShared, + ], + providers: [ + ChartsService, + ConfigService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MonocularChartCardComponent); + component = fixture.componentInstance; + component.row = { + attributes: { + repo: { + + + }, + }, + relationships: { + latestChartVersion: { + data: { + + } + } + }, + } as MonocularChart; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.theme.scss b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.theme.scss new file mode 100644 index 0000000000..45f5918d5c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.theme.scss @@ -0,0 +1,40 @@ + +@mixin monocular-chart-card($theme, $app-theme) { + $link-color: map-get($app-theme, link-color); + $link-active-color: map-get($app-theme, link-active-color); + + $side-nav: map-get($app-theme, side-nav); + $side-nav-bg: map-get($side-nav, background); + + $is-dark: map-get($theme, is-dark); + @if $is-dark == true { + $border-color: $black; + $background-color-top: lighten($side-nav-bg, 10); + $background-color-bottom: $side-nav-bg; + $color: $link-color; + .list-item { + background-color: $background-color-bottom; + border-color: $border-color; + .list-item-logo { + background-color: $background-color-top; + } + .list-item-info { + a { + color: $link-color; + &:hover { + color: $link-active-color; + } + } + border-color: $border-color; + .chart-item-info { + &__version { + color: $link-color; + } + &__repo:hover { + background-color: $background-color-bottom; + } + } + } + } + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.ts b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.ts new file mode 100644 index 0000000000..fe540b5c52 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-chart-card/monocular-chart-card.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; + +import { CardCell } from '../../../../../core/src/shared/components/list/list.types'; +import { MonocularChart } from '../../store/helm.types'; + +@Component({ + selector: 'app-monocular-chart-card', + templateUrl: './monocular-chart-card.component.html', + styleUrls: ['./monocular-chart-card.component.scss'] +}) +export class MonocularChartCardComponent extends CardCell { + + @Input() row: MonocularChart; + + constructor() { + super(); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-data-source.ts b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-data-source.ts new file mode 100644 index 0000000000..e71de82d77 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-data-source.ts @@ -0,0 +1,42 @@ +import { Store } from '@ngrx/store'; + +import { ListDataSource } from '../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../core/src/shared/components/list/list.component.types'; +import { IRequestEntityTypeState } from '../../../../store/src/app-state'; +import { AppState, EndpointModel } from '../../../../store/src/public-api'; +import { PaginationEntityState } from '../../../../store/src/types/pagination.types'; +import { helmEntityCatalog } from '../helm-entity-catalog'; +import { MonocularChart } from '../store/helm.types'; + +export class MonocularChartsDataSource extends ListDataSource { + + constructor( + store: Store, + listConfig: IListConfig, + endpoints: IRequestEntityTypeState + ) { + const action = helmEntityCatalog.chart.actions.getMultiple(); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (row) => action.entity[0].getId(row), + paginationKey: action.paginationKey, + isLocal: true, + listConfig, + transformEntities: [ + { type: 'filter', field: 'name' }, + (entities: MonocularChart[], paginationState: PaginationEntityState) => { + const repository = paginationState.clientPagination.filter.items.repository; + if (!repository) { + return entities; + } + return entities.filter(e => e.monocularEndpointId ? + repository === endpoints[e.monocularEndpointId].name : + repository === e.attributes.repo.name + ); + } + ] + }); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-list-config.service.ts b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-list-config.service.ts new file mode 100644 index 0000000000..5cf3124c39 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/list-types/monocular-charts-list-config.service.ts @@ -0,0 +1,136 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +import { EndpointsService } from '../../../../core/src/core/endpoints.service'; +import { ITableColumn } from '../../../../core/src/shared/components/list/list-table/table.types'; +import { + IListConfig, + IListMultiFilterConfig, + ListViewTypes, +} from '../../../../core/src/shared/components/list/list.component.types'; +import { ListView } from '../../../../store/src/actions/list.actions'; +import { AppState } from '../../../../store/src/public-api'; +import { defaultHelmKubeListPageSize } from '../../kubernetes/list-types/kube-helm-list-types'; +import { HELM_ENDPOINT_TYPE } from '../helm-entity-factory'; +import { ChartsService } from '../monocular/shared/services/charts.service'; +import { MonocularChart } from '../store/helm.types'; +import { MonocularChartCardComponent } from './monocular-chart-card/monocular-chart-card.component'; +import { MonocularChartsDataSource } from './monocular-charts-data-source'; + +@Injectable() +export class MonocularChartsListConfig implements IListConfig { + dataSource: MonocularChartsDataSource; + isLocal = true; + multiFilterConfigs: IListMultiFilterConfig[]; + + columns: Array> = [ + { + columnId: 'name', headerCell: () => 'Name', + cellDefinition: { + getValue: row => row.name, + getLink: row => this.chartsService.getChartSummaryRoute( + row.attributes.repo.name, + row.name, + null, + null, + row + ), + }, + sort: { + type: 'sort', + orderKey: 'name', + field: 'name' + }, + cellFlex: '2', + }, + { + columnId: 'description', headerCell: () => 'Description', + cellDefinition: { + getValue: (row) => row.attributes.description, + }, + sort: { + type: 'sort', + orderKey: 'description', + field: 'attributes.description' + }, + cellFlex: '5', + }, + { + columnId: 'repository', headerCell: () => 'Repository', + cellDefinition: { + getValue: (row) => row.attributes.repo.name + }, + sort: { + type: 'sort', + orderKey: 'repository', + field: 'attributes.repo.name' + }, + cellFlex: '2', + }, + ]; + + pageSizeOptions = defaultHelmKubeListPageSize; + viewType = ListViewTypes.BOTH; + defaultView = 'cards' as ListView; + cardComponent = MonocularChartCardComponent; + + enableTextFilter = true; + text = { + filter: 'Filter by Name', + noEntries: 'There are no charts' + }; + + private initialised: Observable; + + constructor( + store: Store, + private endpointsService: EndpointsService, + private route: ActivatedRoute, + private chartsService: ChartsService + ) { + + this.initialised = endpointsService.endpoints$.pipe( + filter(endpoints => !!endpoints), + map(endpoints => { + this.dataSource = new MonocularChartsDataSource(store, this, endpoints); + return true; + }), + ); + } + + getGlobalActions = () => []; + getMultiActions = () => []; + getSingleActions = () => []; + getColumns = () => this.columns; + getDataSource = () => this.dataSource; + getMultiFiltersConfigs = () => [this.createRepositoryFilterConfig()]; + getInitialised = () => this.initialised; + + private createRepositoryFilterConfig(): IListMultiFilterConfig { + return { + key: 'repository', + label: 'Source', + allLabel: 'All Sources', + list$: this.helmRepositories(), + loading$: observableOf(false), + select: new BehaviorSubject(this.route.snapshot.params.repo) + }; + } + + private helmRepositories(): Observable { + return this.endpointsService.endpoints$.pipe( + map(endpoints => { + const repos = []; + Object.values(endpoints).forEach(ep => { + if (ep.cnsi_type === HELM_ENDPOINT_TYPE) { + repos.push({ label: ep.name, item: ep.name, value: ep.name }); + } + }); + return repos; + }) + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.html b/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.html new file mode 100644 index 0000000000..73783346ce --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.html @@ -0,0 +1,4 @@ + +

Helm

+
+ \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.spec.ts new file mode 100644 index 0000000000..44d51292b1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.spec.ts @@ -0,0 +1,35 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../core/src/tab-nav.service'; +import { BaseTestModulesNoShared } from '../../../../core/test-framework/core-test.helper'; +import { HelmModule } from '../helm.module'; +import { MonocularTabBaseComponent } from './monocular-tab-base.component'; + +describe('MonocularTabBaseComponent', () => { + let component: MonocularTabBaseComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [], + imports: [ + ...BaseTestModulesNoShared, + HelmModule + ], + providers: [ + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MonocularTabBaseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.ts new file mode 100644 index 0000000000..236ad2c157 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular-tab-base/monocular-tab-base.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from 'frontend/packages/store/src/app-state'; +import { endpointOfTypeSelector } from 'frontend/packages/store/src/selectors/endpoint.selectors'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HELM_ENDPOINT_TYPE } from '../helm-entity-factory'; + +@Component({ + selector: 'app-monocular-tab-base', + templateUrl: './monocular-tab-base.component.html', + styleUrls: ['./monocular-tab-base.component.scss'] +}) +export class MonocularTabBaseComponent implements OnInit { + + public endpointIds$: Observable; + + constructor(private store: Store) { } + + ngOnInit() { + this.endpointIds$ = this.store.select(endpointOfTypeSelector(HELM_ENDPOINT_TYPE)).pipe( + map(endpoints => Object.keys(endpoints)) + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular.interceptor.ts b/src/frontend/packages/kubernetes/src/helm/monocular.interceptor.ts new file mode 100644 index 0000000000..ddb24966a1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular.interceptor.ts @@ -0,0 +1,67 @@ +import { HttpBackend, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; + +import { stratosMonocularEndpointGuid } from './monocular/stratos-monocular.helper'; + +/** + * Add information to request to monocular to differ between stratos and helm hub monocular instances + */ +@Injectable() +export class MonocularInterceptor implements HttpInterceptor { + + constructor(private route: ActivatedRoute) { } + + /** + * The interceptor should only run for http clients provided in the helm module, but just in case only apply self for specific urls.. + */ + private readonly includeUrls = [ + '/pp/v1/chartsvc' + ]; + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + const validUrl = this.includeUrls.find(part => req.url.indexOf(part) >= 0); + const endpoint = this.route.snapshot.params.endpoint; + const hasEndpoint = !!endpoint; + const isExternalMonocular = endpoint !== stratosMonocularEndpointGuid; + + const newReq = validUrl && hasEndpoint && isExternalMonocular ? req.clone({ + // Endpoint guid will be helm hub's endpoint + headers: req.headers.set('x-cap-cnsi-list', endpoint) + }) : req; + + return next.handle(newReq); + } +} + +class HttpInterceptorHandler implements HttpHandler { + constructor(private next: HttpHandler, private interceptor: HttpInterceptor) { } + + handle(req: HttpRequest): Observable> { + return this.interceptor.intercept(req, this.next); + } +} +export class HttpInterceptingHandler implements HttpHandler { + private chain: HttpHandler = null; + + constructor( + private backend: HttpBackend, + private interceptors: HttpInterceptor[], + private intercept?: (req: HttpRequest) => HttpRequest + ) { } + + handle(req: HttpRequest): Observable> { + if (this.intercept) { + req = this.intercept(req); + } + + if (this.chain === null) { + this.chain = this.interceptors.reduceRight( + (next, interceptor) => new HttpInterceptorHandler(next, interceptor), + this.backend + ); + } + return this.chain.handle(req); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/app.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/app.component.scss new file mode 100644 index 0000000000..d78c1e119c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/app.component.scss @@ -0,0 +1,59 @@ +// Import your custom theme +@import '../theme.scss'; + +$menu-height: 140px; + +.App { + + &__Wrap { + background: $background-white; + display: flex; + min-height: 100vh; + flex-direction: column; + transition: transform .3s ease-out; + will-change: transform; + + .App__Content { + flex: 1; + padding-top: 70px; + } + } + + &__Menu { + position: absolute; + background-color: $layout-base; + top: 0; + height: 0; + width: 100%; + transition: height .3s ease-out; + will-change: height; + display: flex; + align-items: center; + + ul { + list-style: none; + } + + li { + font-weight: normal; + margin-bottom: .5em; + font-size: 1.2em; + color: mat-color($monocular-app-primary); + a.active { + font-weight: bold; + color: $text-white; + } + } + } + + &--openMenu { + + .App__Wrap { + transform: translateY($menu-height); + } + + .App__Menu { + height: $menu-height; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.html new file mode 100644 index 0000000000..52ab1483fe --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.html @@ -0,0 +1,39 @@ +
+ + + + + +
+ +
+
+

Home

+ +
+
+

Maintainers

+ +
+ + + +
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.scss new file mode 100644 index 0000000000..558bef53f1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.scss @@ -0,0 +1,28 @@ +.chartInfo { + + h1 { + font-size: 16px; + } + + app-panel { + box-shadow: 1px 1px 10px rgba(black, 0.07); + border: 0 !important; + } + + &__source, &__related, &__home{ + a { + word-break: break-all; + } + } + + &__link { + font-size: 14px; + } + + &__properties { + overflow: auto; + display: block; + margin-bottom: 2em; + max-width: 260px; + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.spec.ts new file mode 100644 index 0000000000..74d65fd3c7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.spec.ts @@ -0,0 +1,26 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, TestBed } from '@angular/core/testing'; + +import { MockChartService } from '../../shared/services/chart.service.mock'; +import { ChartsService } from '../../shared/services/charts.service'; +import { ChartDetailsInfoComponent } from './chart-details-info.component'; + + +describe('Component: ChartDetailsInfo', () => { + beforeEach( + async(() => { + TestBed.configureTestingModule({ + declarations: [ChartDetailsInfoComponent], + imports: [], + providers: [ + { provide: ChartsService, useValue: new MockChartService() }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }) + ); + it('should create an instance', () => { + const component = TestBed.createComponent(ChartDetailsInfoComponent); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.ts new file mode 100644 index 0000000000..7a273df5e6 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-info/chart-details-info.component.ts @@ -0,0 +1,81 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { of } from 'rxjs'; +import { catchError, first } from 'rxjs/operators'; + +import { Chart } from '../../shared/models/chart'; +import { ChartVersion } from '../../shared/models/chart-version'; +import { Maintainer } from '../../shared/models/maintainer'; +import { ChartsService } from '../../shared/services/charts.service'; + +@Component({ + selector: 'app-chart-details-info', + templateUrl: './chart-details-info.component.html', + styleUrls: ['./chart-details-info.component.scss'] +}) +export class ChartDetailsInfoComponent implements OnInit { + @Input() chart: Chart; + versions: ChartVersion[]; + schema: any = null; + + _currentVersion: ChartVersion; + + get currentVersion(): ChartVersion { + return this._currentVersion; + } + + @Input() set currentVersion (version: ChartVersion) { + this._currentVersion = version; + if (version) { + this.getSchema(this._currentVersion, this.chart); + } + } + + constructor(private chartsService: ChartsService) { } + + ngOnInit() { + this.loadVersions(this.chart); + } + + get sources() { + return this.chart.attributes.sources || []; + } + + get maintainers(): Maintainer[] { + return this.chart.attributes.maintainers || []; + } + + loadVersions(chart: Chart): void { + this.chartsService + .getVersions(chart.attributes.repo.name, chart.attributes.name) + .subscribe(versions => { + this.versions = versions; + }); + } + + maintainerUrl(maintainer: Maintainer): string { + // Use GitHub URL with maintainer name if this is an upstream Helm repo from + // github.com/helm/charts (i.e. stable or incubator) + if (this.isUpstreamHelmRepo(this.chart.attributes.repo.url)) { + return `https://github.com/${maintainer.name}`; + } else { + return `mailto:${maintainer.email}`; + } + } + + private isUpstreamHelmRepo(repoURL: string): boolean { + return ( + repoURL === 'https://kubernetes-charts.storage.googleapis.com' || + repoURL === 'https://kubernetes-charts-incubator.storage.googleapis.com' + ); + } + + private getSchema(currentVersion: ChartVersion, chart: Chart) { + this.chartsService.getChartSchema(currentVersion, chart).pipe( + first(), + catchError(() => of(null)) + ).subscribe(schema => { + this.schema = schema; + }); + } + +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.html new file mode 100644 index 0000000000..3f536fdbeb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.html @@ -0,0 +1,5 @@ +
+ +
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.spec.ts new file mode 100644 index 0000000000..287b751c51 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.spec.ts @@ -0,0 +1,24 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { MockChartService } from '../../shared/services/chart.service.mock'; +import { ChartsService } from '../../shared/services/charts.service'; +import { ChartDetailsReadmeComponent } from './chart-details-readme.component'; + +describe('Component: ChartDetailsReadme', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [ChartDetailsReadmeComponent], + providers: [ + { provide: ChartsService, useValue: new MockChartService() }, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + it('should create an instance', () => { + const component = TestBed.createComponent(ChartDetailsReadmeComponent); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.ts new file mode 100644 index 0000000000..171a260696 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-readme/chart-details-readme.component.ts @@ -0,0 +1,52 @@ +import { Component, Input } from '@angular/core'; +import markdown from 'marked'; +import { Observable, of as observableOf } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +import { ChartVersion } from '../../shared/models/chart-version'; +import { ChartsService } from '../../shared/services/charts.service'; + +@Component({ + selector: 'app-chart-details-readme', + templateUrl: './chart-details-readme.component.html', + styleUrls: ['./chart-details-readme.component.scss'] +}) +export class ChartDetailsReadmeComponent { + + @Input() set currentVersion(currentVersion: ChartVersion) { + if (currentVersion) { + this.readmeContent$ = this.getReadme(currentVersion); + } + } + + public loading = false; + public readmeContent$: Observable; + private renderer = new markdown.Renderer(); + private loadingDelay: any; + + constructor(private chartsService: ChartsService) { + this.renderer.link = (href, title, text) => `${text}`; + this.renderer.code = (text: string) => `${text}`; + this.loadingDelay = setTimeout(() => this.loading = true, 100); + } + + // TODO: See #150 - This should not require loading the specific version and then the readme + private getReadme(currentVersion: ChartVersion): Observable { + return this.chartsService.getChartReadme(currentVersion).pipe( + map(resp => { + clearTimeout(this.loadingDelay); + this.loading = false; + return markdown(resp, { + renderer: this.renderer + }); + }), + catchError((error) => { + this.loading = false; + if (error.status === 404) { + return observableOf('

No Readme available for this chart

'); + } else { + return observableOf('

An error occurred retrieving Readme

'); + } + })); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.html new file mode 100644 index 0000000000..aa041718ae --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.html @@ -0,0 +1,13 @@ +
+ +
+ + + +
+ +
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.scss new file mode 100644 index 0000000000..955e8885c7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.scss @@ -0,0 +1,66 @@ +// Import your custom theme +@import '../../../theme.scss'; + +mat-tab-body { + margin-top: 1em; +} +.chart-details-usage { + display: block; + margin: 2em 0; + box-shadow: 1px 1px 10px rgba(black, 0.07); + h1 { + font-size: 18px; + margin: 0; + } + + &__label { + margin: 0 0 .1em; + font-size: 1em; + } + + &__repository { + margin-bottom: 1.5em; + } + + &__section { + display: flex; + flex-direction: column; + } + + &__install { + display: flex; + justify-content: flex-end; + margin: 35px 0; + } + + .mat-input-element { + .mat-input-wrapper { + margin: 1em 0 .5em; + } + } + + .mat-tab-label { + min-width: auto !important; + } + [mat-button] { + margin-left: 1em; + min-width: auto; + width: 4em; + &.chart-details-usage__install { + width: auto; + } + } + + svg { + fill: mat-color($monocular-app-primary); + } + + p { + &.help-link { + font-size: 0.8em; + text-align: center; + margin-top: 20px; + margin-bottom: 0; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.spec.ts new file mode 100644 index 0000000000..dcd88dc1ab --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.spec.ts @@ -0,0 +1,28 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { EndpointsService } from '../../../../../../core/src/core/endpoints.service'; +import { UtilsService } from '../../../../../../core/src/core/utils.service'; +import { BaseTestModulesNoShared } from '../../../../../../core/test-framework/core-test.helper'; +import { PaginationMonitorFactory } from '../../../../../../store/src/monitors/pagination-monitor.factory'; +import { ChartDetailsUsageComponent } from './chart-details-usage.component'; + +describe('Component: ChartDetailsUsage', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [...BaseTestModulesNoShared], + declarations: [ChartDetailsUsageComponent], + providers: [ + EndpointsService, + UtilsService, + PaginationMonitorFactory + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + it('should create an instance', () => { + const component = TestBed.createComponent(ChartDetailsUsageComponent); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.ts new file mode 100644 index 0000000000..425fe87746 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.ts @@ -0,0 +1,45 @@ +import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'; +import { MatIconRegistry } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { DomSanitizer } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; + +import { EndpointsService } from '../../../../../../core/src/core/endpoints.service'; +import { Chart } from '../../shared/models/chart'; +import { getMonocularEndpoint } from '../../stratos-monocular.helper'; + +@Component({ + selector: 'app-chart-details-usage', + templateUrl: './chart-details-usage.component.html', + styleUrls: ['./chart-details-usage.component.scss'], + viewProviders: [MatIconRegistry], + encapsulation: ViewEncapsulation.None +}) +export class ChartDetailsUsageComponent implements OnInit { + @Input() chart: Chart; + @Input() currentVersion: string; + installing: boolean; + + constructor( + private mdIconRegistry: MatIconRegistry, + private sanitizer: DomSanitizer, + public snackBar: MatSnackBar, + public endpointsService: EndpointsService, + private route: ActivatedRoute, + ) { } + + ngOnInit() { + this.mdIconRegistry.addSvgIcon( + 'content-copy', + this.sanitizer.bypassSecurityTrustResourceUrl( + // TODO: See #150 - content-copy.svg doesn't exist + '/assets/icons/content-copy.svg' + ) + ); + } + + get installUrl(): string { + return `/workloads/install/${getMonocularEndpoint(this.route, this.chart)}/${this.chart.id}/${this.currentVersion}`; + } + +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.html new file mode 100644 index 0000000000..68e0d21e6f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.html @@ -0,0 +1,23 @@ +
+

Chart Versions

+
+
+ +
{{ version.attributes.created | date: 'MMM d, y' }}
+
+
+ +
+
+

Application Version

+
+ {{ currentVersion.attributes.app_version }} +
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.scss new file mode 100644 index 0000000000..7a82f45467 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.scss @@ -0,0 +1,51 @@ +@import '../../../theme.scss'; + +.versions { + + h1 { + font-size: 16px; + margin-top: 0; + } + .more-link { + cursor: pointer; + font-size: 14px; + margin-top: .5em; + + &:hover { + text-decoration: underline; + } + } +} + +.version__cell { + + &.creation-date { + color: lighten($layout-base, 20%); + } + + &.number.selected { + font-weight: bold; + } +} + +.version { + font-size: 14px; + + &__table { + display: table; + width: 100%; + } + &__row { + display: table-row; + } + &__cell { + display: table-cell; + font-size: 14px; + } +} + +.app-version { + h1 { + font-size: 16px; + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.spec.ts new file mode 100644 index 0000000000..450d53069d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { PanelComponent } from '../../panel/panel.component'; +import { MockChartService } from '../../shared/services/chart.service.mock'; +import { ChartsService } from '../../shared/services/charts.service'; +import { ChartDetailsVersionsComponent } from './chart-details-versions.component'; + +describe('ChartDetailsVersionsComponent', () => { + let component: ChartDetailsVersionsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ChartDetailsVersionsComponent, PanelComponent], + imports: [RouterTestingModule], + providers: [{ provide: ChartsService, useValue: new MockChartService() },] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChartDetailsVersionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.ts new file mode 100644 index 0000000000..b69c28e3be --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-versions/chart-details-versions.component.ts @@ -0,0 +1,56 @@ +import { Component, Input } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { ChartAttributes } from '../../shared/models/chart'; +import { ChartVersion } from '../../shared/models/chart-version'; +import { ChartsService } from '../../shared/services/charts.service'; + +@Component({ + selector: 'app-chart-details-versions', + templateUrl: './chart-details-versions.component.html', + styleUrls: ['./chart-details-versions.component.scss'] +}) +export class ChartDetailsVersionsComponent { + @Input() versions: ChartVersion[]; + @Input() currentVersion: ChartVersion; + showAllVersions: boolean; + + constructor( + private route: ActivatedRoute, + private chartService: ChartsService + ) { } + + goToVersionUrl(version: ChartVersion): string { + const chart: ChartAttributes = version.relationships.chart.data; + return this.chartService.getChartSummaryRoute(chart.repo.name, chart.name, version.attributes.version, this.route); + } + + isSelected(version: ChartVersion): boolean { + return this.currentVersion && version.attributes.version === this.currentVersion.attributes.version; + } + + showMoreLink(): boolean { + return this.versions && this.versions.length > 5 && !this.showAllVersions; + } + + setShowAllVersions() { + this.showAllVersions = true; + } + + shownVersions(): ChartVersion[] { + if (this.versions) { + return this.showAllVersions ? this.versions : this.getNonDevelopmentVersion().slice(0, 5); + } + return []; + } + + getNonDevelopmentVersion(): ChartVersion[] { + const nonDevel: ChartVersion[] = []; + for (const version of this.versions) { + if (version.attributes.version.indexOf('-') === -1) { + nonDevel.push(version); + } + } + return nonDevel; + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.html new file mode 100644 index 0000000000..f89ef9ce0a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.html @@ -0,0 +1,20 @@ +
+ +

Sorry, we couldn't find the chart

+
+ +
+ +
+
+ +
+
+
+ +
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.scss new file mode 100644 index 0000000000..918a6d6511 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.scss @@ -0,0 +1,124 @@ +// Import your custom theme +@import '../../theme.scss'; + +$header-height: 200px; +$icon-height: 150px; +.summary-title { + flex: 3; + display: block; +} +.chart-details { + display: flex; + flex-direction: column; + + &__wrapper { + display: flex; + } + + &__header { + margin-top: -80px; + margin-bottom: 40px; + + &__background { + height: $header-height; + background: $header-backgound-gradient; + } + + &__content { + padding: 0 2em; + max-width: $layout-max-width; + margin: auto; + margin-top: -$icon-height / 2; + display: flex; + } + + &__text { + flex: 1; + + h1 { + min-height: $icon-height / 2; + color: $text-white; + margin: 0; + margin-top: -5px; + display: flex; + align-items: flex-end; + } + + p { + line-height: 1.6em; + } + + &__repo { + color: mat-color($monocular-app-accent, 500); + + &.repo-incubator { + color: mat-color($monocular-app-warn, 600); + } + + a { + color: inherit; + } + } + } + + &__icon { + width: $icon-height; + height: $icon-height; + background: $background-white; + border: 2px solid $layout-base; + margin-right: 1em; + border-radius: $border-radius; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 70%; + max-height: 70%; + } + } + } + + &__content { + width: 100%; + display: flex; + flex-direction: column-reverse; + padding: 0 2em 2em 2em; + word-break: break-word; + } + + @include mappy-bp(medium) { + display: block; + &__content { + flex-direction: row; + height: 100%; + + + &__info { + margin-right: 50px; + overflow: auto; + } + + &__docs { + overflow: auto; + padding-right: 1em; + min-width: 0; + flex: 3; + } + } + } + + @include mappy-bp(max-width small) { + + &__header { + + &__content { + flex-direction: column; + + h1 { + color: inherit; + } + } + } + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.spec.ts new file mode 100644 index 0000000000..4e1be686cd --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.spec.ts @@ -0,0 +1,70 @@ +import { HttpClient, HttpClientModule } from '@angular/common/http'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserModule } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { + EntitySummaryTitleComponent, +} from '../../../../../core/src/shared/components/entity-summary-title/entity-summary-title.component'; +import { BaseTestModulesNoShared } from '../../../../../core/test-framework/core-test.helper'; +import { PaginationMonitorFactory } from '../../../../../store/src/monitors/pagination-monitor.factory'; +import { ChartItemComponent } from '../chart-item/chart-item.component'; +import { ListItemComponent } from '../list-item/list-item.component'; +import { LoaderComponent } from '../loader/loader.component'; +import { PanelComponent } from '../panel/panel.component'; +import { MockChartService } from '../shared/services/chart.service.mock'; +import { ChartsService } from '../shared/services/charts.service'; +import { ConfigService } from '../shared/services/config.service'; +import { MenuService } from '../shared/services/menu.service'; +import { ChartDetailsInfoComponent } from './chart-details-info/chart-details-info.component'; +import { ChartDetailsReadmeComponent } from './chart-details-readme/chart-details-readme.component'; +import { ChartDetailsUsageComponent } from './chart-details-usage/chart-details-usage.component'; +import { ChartDetailsVersionsComponent } from './chart-details-versions/chart-details-versions.component'; +import { ChartDetailsComponent } from './chart-details.component'; + +describe('ChartDetailsComponent', () => { + let component: ChartDetailsComponent; + let fixture: ComponentFixture; + + beforeEach( + async(() => { + TestBed.configureTestingModule({ + imports: [ + BrowserModule, + RouterTestingModule, + HttpClientModule, + ...BaseTestModulesNoShared + ], + declarations: [ + ChartDetailsComponent, + ChartDetailsVersionsComponent, + ChartDetailsInfoComponent, + ChartDetailsReadmeComponent, + ChartDetailsUsageComponent, + LoaderComponent, + PanelComponent, + ChartItemComponent, + ListItemComponent, + EntitySummaryTitleComponent + ], + providers: [ + HttpClient, + { provide: ChartsService, useValue: new MockChartService() }, + { provide: ConfigService, useValue: { appName: 'appName' } }, + { provide: MenuService }, + PaginationMonitorFactory, + ] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(ChartDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.ts new file mode 100644 index 0000000000..49f0f11661 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details.component.ts @@ -0,0 +1,73 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; +import { first } from 'rxjs/operators'; + +import { Chart } from '../shared/models/chart'; +import { ChartVersion } from '../shared/models/chart-version'; +import { ChartsService } from '../shared/services/charts.service'; +import { ConfigService } from '../shared/services/config.service'; +import { getMonocularEndpoint, stratosMonocularEndpointGuid } from '../stratos-monocular.helper'; + +@Component({ + selector: 'app-chart-details', + templateUrl: './chart-details.component.html', + styleUrls: ['./chart-details.component.scss'] +}) +export class ChartDetailsComponent implements OnInit { + /* This resource will be different, probably ChartVersion */ + chart: Chart; + loading = false; + initing = true; + currentVersion: ChartVersion; + iconUrl: string; + titleVersion: string; + chartSubTitle: string; + + loadingDelay: any; + + constructor( + private route: ActivatedRoute, + private chartsService: ChartsService, + private config: ConfigService, + ) { + this.loadingDelay = setTimeout(() => this.loading = true, 100); + } + + ngOnInit() { + this.route.params.forEach((params: Params) => { + const repo = params.repo; + const chartName = params.chartName; + + if (!!chartName) { + this.chartsService.getChart(repo, chartName).pipe(first()).subscribe(chart => { + clearTimeout(this.loadingDelay); + this.loading = false; + this.initing = false; + this.chart = chart; + this.chartSubTitle = chart.attributes.repo.name; + if (getMonocularEndpoint(this.route, chart) !== stratosMonocularEndpointGuid) { + this.chartSubTitle = 'Helm Hub - ' + this.chartSubTitle; + } + const version = params.version || this.chart.relationships.latestChartVersion.data.version; + this.chartsService.getVersion(repo, chartName, version).pipe(first()) + .subscribe(chartVersion => { + this.currentVersion = chartVersion; + this.titleVersion = this.currentVersion.attributes.app_version || ''; + this.updateMetaTags(); + this.iconUrl = this.chartsService.getChartIconURL(this.chart, chartVersion); + }); + }); + } + }); + } + + // TODO: See #150 - Is this to be implemented? + /** + * Update the metatags with the name and the description of the application. + */ + updateMetaTags(): void { } + + goToRepoUrl(): string { + return `/charts/${getMonocularEndpoint(null, this.chart)}/${this.chart.attributes.repo.name}`; + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.html new file mode 100644 index 0000000000..49b4dd9fa9 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.html @@ -0,0 +1,12 @@ + +
+ + +
+ +
+
+
+
diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.scss new file mode 100644 index 0000000000..a48f93b759 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.scss @@ -0,0 +1,14 @@ + +@import '../../theme.scss'; + +.chart-list { + width: 100%; + max-width: $layout-max-width; + margin: auto; + display: flex; + align-items: center; + + app-chart-list { + flex: 1; + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.spec.ts new file mode 100644 index 0000000000..26a9203f0a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.spec.ts @@ -0,0 +1,55 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { of } from 'rxjs'; + +import { ChartItemComponent } from '../chart-item/chart-item.component'; +import { ChartListComponent } from '../chart-list/chart-list.component'; +import { LoaderComponent } from '../loader/loader.component'; +import { PanelComponent } from '../panel/panel.component'; +import { ChartsService } from '../shared/services/charts.service'; +import { ConfigService } from '../shared/services/config.service'; +import { MenuService } from '../shared/services/menu.service'; +import { ChartIndexComponent } from './chart-index.component'; + +/* tslint:disable:no-unused-variable */ +// import { HeaderBarComponent } from '../header-bar/header-bar.component'; +// import { MainHeaderComponent } from '../main-header/main-header.component'; +// import { SeoService } from '../shared/services/seo.service'; + +export class MockChartService { + + public getCharts() { + return of([]); + } +} + +describe('Component: ChartIndex', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + declarations: [ + ChartIndexComponent, + ChartListComponent, + ChartItemComponent, + LoaderComponent, + PanelComponent, + // HeaderBarComponent, + // MainHeaderComponent + ], + providers: [ + ConfigService, + MenuService, + { provide: ChartsService, useValue: new MockChartService() }, + // { provide: SeoService }, + { provide: Router } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + it('should create an instance', () => { + const component = TestBed.createComponent(ChartIndexComponent); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.ts new file mode 100644 index 0000000000..cd2e60105d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-index/chart-index.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit } from '@angular/core'; +import { first } from 'rxjs/operators'; + +import { Chart } from '../shared/models/chart'; +import { ChartsService } from '../shared/services/charts.service'; + +@Component({ + selector: 'app-chart-index', + templateUrl: './chart-index.component.html', + styleUrls: ['./chart-index.component.scss'] +}) +export class ChartIndexComponent implements OnInit { + charts: Chart[]; + loading = true; + totalChartsNumber: number; + + constructor( + private chartsService: ChartsService, + ) { } + + ngOnInit() { + this.loadCharts(); + } + + loadCharts(): void { + this.chartsService.getCharts().pipe(first()).subscribe(charts => { + this.loading = false; + this.charts = charts || []; + this.totalChartsNumber = charts.length; + }); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.html new file mode 100644 index 0000000000..b94c5bc9ef --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.html @@ -0,0 +1,16 @@ + + +
+

+ {{ chart.attributes.repo.name }}/{{ chart.attributes.name }} +

+

+ + {{ chart.relationships.latestChartVersion.data.app_version }} +

+
+ {{ chart.attributes.description }} +
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.scss new file mode 100644 index 0000000000..813c0b946e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.scss @@ -0,0 +1,63 @@ +// Import your custom theme +@import '../../theme.scss'; + +// Vars +$chart-item-content-height: 150px; +$chart-item-logo-width: 80px; + +.chart-item { + + &-logo { + max-height: 80%; + max-width: 80%; + } + + &-info { + + &__title { + margin: 0; + font-weight: 500; + font-size: 16px; + cursor: pointer; + margin-bottom: 4px; + + a { + color: inherit; + } + } + + p { + margin: 0; + } + + &__version { + font-size: 14px; + } + + // Remove this when chips are ready: + // https://github.com/angular/material2/issues/120 + &__repo { + color: mat-color($monocular-app-accent, 500); + font-size: .8em; + padding: 2px 4px; + border-radius: 4px; + + &:hover { + background: $background-white; + } + + &.repo-incubator { + color: mat-color($monocular-app-warn, 600); + } + + a { + color: inherit; + } + } + + &__description { + margin: 1em; + } + } + +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.spec.ts new file mode 100644 index 0000000000..e8e62854b0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.spec.ts @@ -0,0 +1,44 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { createBasicStoreModule } from '../../../../../store/testing/public-api'; +import { ChartsService } from '../shared/services/charts.service'; +import { ConfigService } from '../shared/services/config.service'; +import { ChartItemComponent } from './chart-item.component'; + + +describe('Component: ChartItem', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + createBasicStoreModule() + ], + declarations: [ChartItemComponent], + providers: [ + HttpClient, + ConfigService, + ChartsService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + }, + queryParams: {} + }, + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + it('should create an instance', () => { + const component = TestBed.createComponent(ChartItemComponent); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.ts new file mode 100644 index 0000000000..777f0e8d48 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-item/chart-item.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit } from '@angular/core'; + +import { Chart } from '../shared/models/chart'; +import { ChartsService } from '../shared/services/charts.service'; + +@Component({ + selector: 'app-chart-item', + templateUrl: './chart-item.component.html', + styleUrls: ['./chart-item.component.scss'], + /* tslint:disable-next-line:no-inputs-metadata-property */ + inputs: ['chart', 'showVersion', 'showDescription'] +}) +export class ChartItemComponent implements OnInit { + public iconUrl: string; + // Chart to represent + public chart: Chart; + // Show version form by default + public showVersion = true; + // Truncate the description + public showDescription = true; + + constructor(private chartsService: ChartsService) { } + + ngOnInit() { + this.iconUrl = this.chartsService.getChartIconURL(this.chart); + } + + goToDetailUrl(): string { + return this.chartsService.getChartSummaryRoute(this.chart.attributes.repo.name, this.chart.attributes.name, null, null, this.chart); + } + +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.html new file mode 100644 index 0000000000..00afaae077 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.html @@ -0,0 +1,4 @@ +
+

There are no charts

+ +
diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.scss new file mode 100644 index 0000000000..5274b4b828 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.scss @@ -0,0 +1,44 @@ +// Import your custom theme +@import '../../theme.scss'; + +.chart-list { + flex: 1; + display: flex; + flex-flow: row wrap; + + &__empty { + align-self: center; + margin: auto; + opacity: 0.7; + } + + app-chart-item { + padding-bottom: 1.5em; + width: 100%; + + @include mappy-bp(medium large) { + width: 50%; + padding-right: 1.5em; + &:nth-child(2n) { + padding-right: 0; + } + } + + @include mappy-bp(large xlarge) { + width: 33.3%; + padding-right: 1.5em; + &:nth-child(3n) { + padding-right: 0; + } + } + + @include mappy-bp(xlarge) { + width: 25%; + padding-right: 1.5em; + &:nth-child(4n) { + padding-right: 0; + } + } + + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.spec.ts new file mode 100644 index 0000000000..71ac617ce4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.spec.ts @@ -0,0 +1,9 @@ +import { ChartListComponent } from './chart-list.component'; + + +describe('Component: ChartList', () => { + it('should create an instance', () => { + const component = new ChartListComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.ts new file mode 100644 index 0000000000..73db0ddf57 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-list/chart-list.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; + +import { Chart } from '../shared/models/chart'; + +@Component({ + selector: 'app-chart-list', + templateUrl: './chart-list.component.html', + styleUrls: ['./chart-list.component.scss'] +}) +export class ChartListComponent { + @Input() charts: Chart[]; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.html new file mode 100644 index 0000000000..742b852181 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.html @@ -0,0 +1,29 @@ + + +
+
+
+

+ Charts +
+ + + Filters +
+

+
+
+ +
+
+
diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.scss new file mode 100644 index 0000000000..6ec9d41b90 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.scss @@ -0,0 +1,112 @@ +@import '../../theme.scss'; + +$filters-width: 175px; + +.charts { + max-width: $layout-max-width; + margin: auto; + + &__header { + + &__title { + margin-top: 0; + } + + &__filters { + display: none; + font-size: 0.5em; + + mat-icon { + margin-left: 10px; + top: 6px; + position: relative; + } + } + } + + &__gallery { + width: 100%; + display: flex; + flex-direction: row; + + app-list-filters { + width: $filters-width; + display: flex; + overflow: hidden; + } + + &__content { + flex: 1; + display: flex; + flex-direction: column; + border-left: 1px solid $border-color; + + app-chart-list { + flex: 1; + display: flex; + padding: 1.5em 0 0 1.5em; + } + + &__topbar { + display: flex; + height: 40px; + border-bottom: 1px solid $border-color; + align-items: center; + padding-left: 10px; + + * { + fill: lighten($layout-base, 50%); + } + + input { + width: 100%; + height: 100%; + border: 0; + font-size: 1.1em; + background: none; + outline: none; + margin-left: 5px; + color: $layout-base; + @include placeholder { + color: lighten($layout-base, 50%); + } + } + } + } + } + + @include mappy-bp(max-width small) { + + &__header__filters { + display: inline-block; + } + + &__gallery { + flex-direction: column; + + app-list-filters { + position: relative; + left: -100%; + width: auto; + height: 0; + transition: left .2s ease-out; + &.open { + height: auto; + left: 0; + } + } + + &__content { + border: 0; + + &__topbar { + padding-left: 0; + } + + app-chart-list { + padding: 1.5em 0 0 0; + } + } + } + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.spec.ts new file mode 100644 index 0000000000..abe09fe639 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.spec.ts @@ -0,0 +1,62 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of } from 'rxjs'; + +import { createBasicStoreModule } from '../../../../../store/testing/public-api'; +import { ChartItemComponent } from '../chart-item/chart-item.component'; +import { ChartListComponent } from '../chart-list/chart-list.component'; +import { LoaderComponent } from '../loader/loader.component'; +import { PanelComponent } from '../panel/panel.component'; +import { MockChartService } from '../shared/services/chart.service.mock'; +import { ChartsService } from '../shared/services/charts.service'; +import { ConfigService } from '../shared/services/config.service'; +import { MenuService } from '../shared/services/menu.service'; +import { ReposService } from '../shared/services/repos.service'; +import { ChartsComponent } from './charts.component'; + +describe('ChartsComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + createBasicStoreModule(), + RouterTestingModule + ], + declarations: [ + ChartsComponent, + ChartListComponent, + ChartItemComponent, + LoaderComponent, + PanelComponent, + ], + providers: [ + HttpClient, + ConfigService, + MenuService, + { provide: ChartsService, useValue: new MockChartService() }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: {}, + queryParams: {} + }, + queryParams: of({}), + params: of({}) + } + }, + ReposService, + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + it('should create an instance', () => { + const component = TestBed.createComponent(ChartsComponent); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.ts new file mode 100644 index 0000000000..aaffa19cf0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/charts/charts.component.ts @@ -0,0 +1,181 @@ +import { Component, OnInit } from '@angular/core'; +import { MatIconRegistry } from '@angular/material/icon'; +import { DomSanitizer } from '@angular/platform-browser'; +import { ActivatedRoute, Params, Router } from '@angular/router'; + +import { Chart } from '../shared/models/chart'; +import { ChartsService } from '../shared/services/charts.service'; +import { ConfigService } from '../shared/services/config.service'; +import { ReposService } from '../shared/services/repos.service'; + +@Component({ + selector: 'app-charts', + templateUrl: './charts.component.html', + styleUrls: ['./charts.component.scss'], + viewProviders: [MatIconRegistry] +}) +export class ChartsComponent implements OnInit { + charts: Chart[] = []; + orderedCharts: Chart[] = []; + loading = true; + searchTerm: string; + searchTimeout: any; + filtersOpen = false; + + // Default filters + filters = [ + { + title: 'Repository', + onSelect: i => this.onSelectRepo(i), + items: [{ title: 'All', value: 'all', selected: true }] + }, + { + title: 'Order By', + onSelect: i => this.onSelectOrderBy(i), + items: [ + { title: 'Name', value: 'name', selected: true }, + { title: 'Created At', value: 'created', selected: false } + ] + } + ]; + + // Order elements + orderBy = 'name'; + + // Repos + repoName: string; + + constructor( + private chartsService: ChartsService, + private reposService: ReposService, + private route: ActivatedRoute, + private router: Router, + private config: ConfigService, + private mdIconRegistry: MatIconRegistry, + private sanitizer: DomSanitizer + ) { } + + ngOnInit() { + this.mdIconRegistry.addSvgIcon( + 'search', + this.sanitizer.bypassSecurityTrustResourceUrl(`/assets/icons/search.svg`) + ); + this.mdIconRegistry.addSvgIcon( + 'close', + this.sanitizer.bypassSecurityTrustResourceUrl(`/assets/icons/close.svg`) + ); + this.mdIconRegistry.addSvgIcon( + 'menu', + this.sanitizer.bypassSecurityTrustResourceUrl(`/assets/icons/menu.svg`) + ); + this.route.queryParams.forEach((params: Params) => { + this.searchTerm = params.q ? params.q : undefined; + if (this.searchTerm) { + this.searchCharts(); + } + }); + this.route.params.forEach((params: Params) => { + this.repoName = params.repo ? params.repo : undefined; + this.updateMetaTags(); + this.loadCharts(); + this.loadRepos(); + }); + } + + loadCharts(): void { + this.chartsService.getCharts(this.repoName).subscribe(charts => { + this.loading = false; + this.charts = charts; + if (!this.searchTerm) { + this.orderedCharts = this.orderCharts(this.charts); + } + }); + } + + loadRepos(): void { + this.reposService.getRepos().subscribe(repos => { + // Ensure the "all" link is appended to the list of repos + repos = [{ name: 'all', url: '' }, ...repos]; + this.filters[0].items = repos.map(r => ({ + title: r.name, + value: r.name, + selected: this.repoName ? r.name === this.repoName : r.name === 'all' + })); + }); + } + + onSelectRepo(index) { + this.repoName = this.filters[0].items[index].value; + this.filters[0].items = this.filters[0].items.map(r => { + r.selected = r.value === this.repoName; + return r; + }); + this.router.navigate( + ['/charts', this.repoName === 'all' ? '' : this.repoName], + { replaceUrl: true } + ); + } + + onSelectOrderBy(index) { + this.orderBy = this.filters[1].items[index].value; + this.filters[1].items = this.filters[1].items.map(o => { + o.selected = o.value === this.orderBy; + return o; + }); + this.orderedCharts = this.orderCharts(this.orderedCharts); + } + + searchChange(e) { + this.searchTerm = e.target.value; + clearTimeout(this.searchTimeout); + if (!this.searchTerm) { + return (this.orderedCharts = this.orderCharts(this.charts)); + } + this.searchTimeout = setTimeout(() => this.searchCharts(), 1000); + } + + searchCharts() { + if (!this.searchTerm) { + return false; + } + this.loading = true; + this.chartsService + .searchCharts(this.searchTerm, this.repoName) + .subscribe(charts => { + this.loading = false; + this.orderedCharts = this.orderCharts(charts); + }); + } + + // Sort charts + orderCharts(charts): Chart[] { + switch (this.orderBy) { + case 'created': { + return charts.sort(this.sortByCreated).reverse(); + } + default: { + return charts.sort((a, b) => + a.attributes.name.localeCompare(b.attributes.name) + ); + } + } + } + + sortByCreated(a: Chart, b: Chart) { + const aVersion = a.relationships.latestChartVersion.data; + const bVersion = b.relationships.latestChartVersion.data; + if (aVersion.created < bVersion.created) { + return -1; + } else if (aVersion.created > bVersion.created) { + return 1; + } + return 0; + } + + // TODO: See #150 - is this to be implemented? + updateMetaTags(): void { } + + capitalize(input: string) { + return input.charAt(0).toUpperCase() + input.slice(1); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.html new file mode 100644 index 0000000000..c8d43b18d8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.html @@ -0,0 +1,8 @@ +
+
+

{{ filter.title }}

+ +
+
diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.scss new file mode 100644 index 0000000000..fab52a4a76 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.scss @@ -0,0 +1,26 @@ +@import '../../theme.scss'; + +.list-filters { + flex: 1; + height: 100%; + width: 100%; + + &_section { + margin-bottom: 40px; + + &_title { + margin-bottom: 10px; + } + + &_link { + cursor: pointer; + font-size: 1.1em; + margin-bottom: 0.3em; + opacity: 0.5; + &.active { + opacity: 1; + color: mat-color($monocular-app-primary); + } + } + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.spec.ts new file mode 100644 index 0000000000..fe5419226b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.spec.ts @@ -0,0 +1,9 @@ +import { ListFiltersComponent } from './list-filters.component'; + + +describe('Component: ListFilters', () => { + it('should create an instance', () => { + const component = new ListFiltersComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.ts new file mode 100644 index 0000000000..cb4e7a538a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/list-filters/list-filters.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-list-filters', + templateUrl: './list-filters.component.html', + styleUrls: ['./list-filters.component.scss'], + /* tslint:disable-next-line:no-inputs-metadata-property */ + inputs: ['filters'] +}) + +export class ListFiltersComponent { + public filters: { title: string, items: Array<{}>, }[] = []; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.html new file mode 100644 index 0000000000..5964674ba8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.html @@ -0,0 +1,12 @@ + diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.scss new file mode 100644 index 0000000000..15e6632b19 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.scss @@ -0,0 +1,59 @@ +// Import your custom theme +@import '../../theme.scss'; + +// Vars +$list-item-content-height: 200px; +$list-item-content-min-height: 140px; + +.list-item { + border-radius: $border-radius; + overflow: hidden; + border: 1px solid $border-color; + transition: transform 0.2s ease; + background-color: $background-light; + + &:hover { + transform: scale(1.02); + } + + &:active { + transform: scale(0.98); + } + + &-content { + width: 100%; + height: $list-item-content-height; + display: flex; + flex-direction: column; + color: $layout-text; + } + + &-logo { + width: 100%; + height: $list-item-content-height/2 + 10px; + background-color: $background-white; + display: flex; + align-items: center; + justify-content: center; + } + + &-info { + position: relative; + flex: 1; + border-top: 1px solid $border-color; + padding: 0 1em; + display: flex; + flex-direction: column; + justify-content: center; + word-wrap: break-word; + text-align: center; + } + + &__auto-height { + height: auto; + + .list-item-info { + padding: 10px 0; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.spec.ts new file mode 100644 index 0000000000..c200f2f498 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.spec.ts @@ -0,0 +1,10 @@ +import { ListItemComponent } from './list-item.component'; + +/* tslint:disable:no-unused-variable */ + +describe('Component: ListItem', () => { + it('should create an instance', () => { + const component = new ListItemComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.ts new file mode 100644 index 0000000000..20cd266321 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/list-item/list-item.component.ts @@ -0,0 +1,15 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-list-item', + templateUrl: './list-item.component.html', + styleUrls: ['./list-item.component.scss'], + /* tslint:disable-next-line:no-inputs-metadata-property */ + inputs: ['detailUrl'] +}) +export class ListItemComponent { + + @Input() height = 'default'; + + public detailUrl: string; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.html new file mode 100644 index 0000000000..7ae53d4392 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.html @@ -0,0 +1,6 @@ +
+
+ +
+ +
diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.scss new file mode 100644 index 0000000000..ff3c8602d6 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.scss @@ -0,0 +1,10 @@ +.loader { + text-align: center; + margin: 2em 0; +} + +mat-spinner { + height: 60px; + width: 60px; + margin: 0 auto; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.spec.ts new file mode 100644 index 0000000000..61bd1aa4ce --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.spec.ts @@ -0,0 +1,28 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CoreModule } from '@stratosui/core'; + +import { LoaderComponent } from './loader.component'; + +describe('LoaderComponent', () => { + let component: LoaderComponent; + let fixture: ComponentFixture; + + beforeEach( + async(() => { + TestBed.configureTestingModule({ + imports: [CoreModule], + declarations: [LoaderComponent] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(LoaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.ts new file mode 100644 index 0000000000..9702102959 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/loader/loader.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-loader', + templateUrl: './loader.component.html', + styleUrls: ['./loader.component.scss'], + /* tslint:disable-next-line:no-inputs-metadata-property */ + inputs: ['loading'] +}) +export class LoaderComponent { + // Show the loader or the content + public loading = false; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/monocular.module.ts b/src/frontend/packages/kubernetes/src/helm/monocular/monocular.module.ts new file mode 100644 index 0000000000..450e9f8864 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/monocular.module.ts @@ -0,0 +1,53 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { CoreModule, SharedModule } from '../../../../core/src/public-api'; +import { ChartDetailsInfoComponent } from './chart-details/chart-details-info/chart-details-info.component'; +import { ChartDetailsReadmeComponent } from './chart-details/chart-details-readme/chart-details-readme.component'; +import { ChartDetailsUsageComponent } from './chart-details/chart-details-usage/chart-details-usage.component'; +import { ChartDetailsVersionsComponent } from './chart-details/chart-details-versions/chart-details-versions.component'; +import { ChartDetailsComponent } from './chart-details/chart-details.component'; +import { ChartIndexComponent } from './chart-index/chart-index.component'; +import { ChartItemComponent } from './chart-item/chart-item.component'; +import { ChartListComponent } from './chart-list/chart-list.component'; +import { ChartsComponent } from './charts/charts.component'; +import { ListFiltersComponent } from './list-filters/list-filters.component'; +import { ListItemComponent } from './list-item/list-item.component'; +import { LoaderComponent } from './loader/loader.component'; +import { PanelComponent } from './panel/panel.component'; +import { createMonocularProviders } from './stratos-monocular-providers.helpers'; + +const components = [ + PanelComponent, + ChartListComponent, + ChartItemComponent, + ListItemComponent, + ListFiltersComponent, + LoaderComponent, + ChartsComponent, + ChartIndexComponent, + ChartDetailsComponent, + ChartDetailsUsageComponent, + ChartDetailsVersionsComponent, + ChartDetailsReadmeComponent, + ChartDetailsInfoComponent, +]; + +@NgModule({ + imports: [ + CoreModule, + CommonModule, + SharedModule, + ], + declarations: [ + ...components, + ], + providers: [ + // Note - not really needed here, given need to bring in with a component where route with endpoint id param exists + ...createMonocularProviders() + ], + exports: [ + ...components + ] +}) +export class MonocularModule { } \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.html b/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.html new file mode 100644 index 0000000000..6ad2a66494 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.html @@ -0,0 +1,4 @@ +
+

{{ title }}

+ +
diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.scss b/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.scss new file mode 100644 index 0000000000..43902a8ea7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.scss @@ -0,0 +1,24 @@ +@import '../../theme.scss'; + +.panel { + padding: 2em; + + &--container { + width: 80%; + margin-left: auto; + margin-right: auto; + } + + &--background { + background-color: $layout-light; + } + + &--border { + border-radius: $border-radius; + border: 1px solid darken($layout-light, 5); + } + + &__title { + margin-top: 0; + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.spec.ts new file mode 100644 index 0000000000..7f71affbd2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.spec.ts @@ -0,0 +1,9 @@ +import { PanelComponent } from './panel.component'; + + +describe('Component: Panel', () => { + it('should create an instance', () => { + const component = new PanelComponent(); + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.ts b/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.ts new file mode 100644 index 0000000000..957bcd01c1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/panel/panel.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-panel', + templateUrl: './panel.component.html', + styleUrls: ['./panel.component.scss'], + /* tslint:disable-next-line:no-inputs-metadata-property */ + inputs: ['title', 'background', 'container', 'border'] +}) +export class PanelComponent { + // Title of the panel + public title = ''; + // Display a gray background + public background = false; + // Show a border + public border = false; + // Set the size of the panel to 80% + public container = false; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/chart-version.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/chart-version.ts new file mode 100644 index 0000000000..56a2030021 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/chart-version.ts @@ -0,0 +1,36 @@ +import { ChartAttributes } from './chart'; + +export class ChartVersion { + id: string; + type: string; + attributes: ChartVersionAttributes; + relationships: ChartVersionRelationships; +} + +export class ChartVersionAttributes { + created: Date; + digest: string; + icons: ChartVersionIcon[]; + readme: string; + version: string; + schema?: string; + /* tslint:disable-next-line:variable-name */ + app_version: string; + urls: string[]; +} + +class ChartVersionIcon { + name: string; + path: string; +} + +class ChartVersionRelationships { + chart: ChartVersionChart; +} + +class ChartVersionChart { + data: ChartAttributes; + links: { + self: string + }; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/chart.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/chart.ts new file mode 100644 index 0000000000..4953b56f06 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/chart.ts @@ -0,0 +1,35 @@ +import { ChartVersionAttributes } from './chart-version'; +import { Maintainer } from './maintainer'; +import { RepoAttributes } from './repo'; + +export class Chart { + id: string; + type: string; + links: string[]; + attributes: ChartAttributes; + relationships: ChartRelationships; + monocularEndpointId?: string; +} + + +export class ChartAttributes { + description: string; + name: string; + icon: string; + repo: RepoAttributes; + home: string; + sources: string[]; + keywords: string[]; + maintainers: Maintainer[]; +} + +class ChartRelationships { + latestChartVersion: ChartVersionRelationship; +} + +class ChartVersionRelationship { + data: ChartVersionAttributes; + links: { + self: string, + }; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/maintainer.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/maintainer.ts new file mode 100644 index 0000000000..5d358d8314 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/maintainer.ts @@ -0,0 +1,4 @@ +export class Maintainer { + name: string; + email: string; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/repo.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/repo.ts new file mode 100644 index 0000000000..2f4878ca8e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/models/repo.ts @@ -0,0 +1,10 @@ +export class Repo { + id: string; + type: string; + attributes: RepoAttributes; +} + +export class RepoAttributes { + name = ''; + url = ''; +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/seo.data.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/seo.data.ts new file mode 100644 index 0000000000..3ad274e85d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/seo.data.ts @@ -0,0 +1,33 @@ +/** + * This files contains the titles and descriptions for the different sections of the site + */ +export default { + index: { + title: '{ appName }: Discover & launch great Kubernetes-ready apps', + description: + '{ appName } is a platform for discovering & launching great Kubernetes-ready' + + 'apps. Browse the catalog and deploy your applications in your Kubernetes cluster' + }, + charts: { + title: 'Kubernetes-ready applications catalog | { appName }', + description: + 'Browse the { appName } catalog of Kubernetes-ready apps. Deploy all apps you need ' + + 'in your infrastructure or the cloud with a command using Helm Charts' + }, + repoCharts: { + title: '{ repo } repository of Kubernetes-ready applications | { appName }', + description: + 'Browse the { appName } catalog of the { repo } repository of Kubernetes-ready apps. ' + + 'Deploy all apps you need in your infrastructure or the cloud with a command using ' + + 'Helm Charts' + }, + chartDetails: { + title: '{ name } for Kubernetes | { appName }', + description: 'Deploy the latest { name } in Kubernetes. { description }' + }, + chartDetailsWithVersion: { + title: '{ name } { version } for Kubernetes | { appName }', + description: + 'Deploy the { name } { version } in Kubernetes. { description }' + }, +}; diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/chart.service.mock.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/chart.service.mock.ts new file mode 100644 index 0000000000..09e1a7f191 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/chart.service.mock.ts @@ -0,0 +1,114 @@ +import { Observable, of as observableOf } from 'rxjs'; +import { Chart } from '../models/chart'; +import { ChartVersion } from '../models/chart-version'; + + +const mockChart: Chart = { + id: 'incubator/test', + type: 'chart', + links: [], + attributes: { + description: 'Testing the chart', + home: 'helm.sh', + keywords: ['artifactory'], + maintainers: [ + { + email: 'test@example.com', + name: 'Test' + } + ], + name: 'test', + repo: { + name: 'incubator', + url: 'test' + }, + icon: 'icon', + sources: ['https://github.com/'] + }, + relationships: { + latestChartVersion: { + data: { + app_version: '1.0', + created: new Date('2017-02-13T04:33:57.218083521Z'), + digest: + 'eba0c51d4bc5b88d84f83d8b2ba0c5e5a3aad8bc19875598198bdbb0b675f683', + icons: [ + { + name: '160x160-fit', + path: '/assets/incubator/test/4.16.0/logo-160x160-fit.png' + } + ], + readme: '/assets/incubator/test/4.16.0/README.md', + urls: [ + 'https://kubernetes-charts-incubator.storage.googleapis.com/test-4.16.0.tgz' + ], + version: '4.16.0' + }, + links: { + self: '/v1/charts/incubator/test/versions/4.16.0' + } + } + } +}; + +const mockChartVersion: ChartVersion = { + id: 'incubator/test', + type: 'chart', + attributes: { + app_version: '1.0', + created: new Date('2017-02-13T04:33:57.218083521Z'), + digest: + 'eba0c51d4bc5b88d84f83d8b2ba0c5e5a3aad8bc19875598198bdbb0b675f683', + icons: [ + { + name: '160x160-fit', + path: '/assets/incubator/test/4.16.0/logo-160x160-fit.png' + } + ], + readme: '/assets/incubator/test/4.16.0/README.md', + urls: [ + 'https://kubernetes-charts-incubator.storage.googleapis.com/test-4.16.0.tgz' + ], + version: '4.16.0' + }, + relationships: { + chart: { + data: mockChart.attributes, + links: { + self: '/v1/charts/incubator/test/versions/4.16.0' + } + } + } +}; + +export class MockChartService { + + public getCharts() { + return observableOf([]); + } + + public getChart(): Observable { + return observableOf(mockChart); + } + + public getChartReadme(): Observable { + return observableOf({} as Response); + } + + public getVersions(): Observable { + return observableOf([]); + } + + public getVersion(): Observable { + return observableOf(mockChartVersion); + } + + public searchCharts(): Observable { + return observableOf([]); + } + + public getChartIconURL() { + return null; + } + +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/charts.service.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/charts.service.ts new file mode 100644 index 0000000000..f239ab2c97 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/charts.service.ts @@ -0,0 +1,267 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map, tap } from 'rxjs/operators'; + +import { getMonocularEndpoint, stratosMonocularEndpointGuid } from '../../stratos-monocular.helper'; +import { Chart } from '../models/chart'; +import { ChartVersion } from '../models/chart-version'; +import { RepoAttributes } from '../models/repo'; +import { ConfigService } from './config.service'; + + + +/* Most of this code should be in an effect and we should store the data in the app store */ +@Injectable() +export class ChartsService { + hostname: string; + cacheCharts: any; + + constructor( + private http: HttpClient, + config: ConfigService, + private route: ActivatedRoute, + ) { + this.cacheCharts = {}; + this.hostname = '/pp/v1/chartsvc'; + } + + /** + * Update url to go to a monocular instance other than stratos + * These are used as img src values so won't hit our http interceptor + */ + private updateStratosUrl(chart: Chart, url: string): string { + const endpoint = getMonocularEndpoint(this.route, chart); + if (!endpoint || endpoint === stratosMonocularEndpointGuid) { + return url; + } + const parts = url.split('/'); + const chartsvcIndex = parts.findIndex(part => part === 'chartsvc'); + if (chartsvcIndex >= 0) { + parts.splice(chartsvcIndex, 0, `monocular/${endpoint}`); + } + return parts.join('/'); + } + + /** + * Get all charts from the API + * + * @return An observable that will an array with all Charts + */ + getCharts(repo: string = 'all'): Observable { + let url: string; + switch (repo) { + case 'all': { + url = `${this.hostname}/v1/charts`; + break; + } + default: { + url = `${this.hostname}/v1/charts/${repo}`; + } + } + + if (this.cacheCharts[repo] && this.cacheCharts[repo].length > 0) { + return Observable.create((observer) => { + observer.next(this.cacheCharts[repo]); + }); + } else { + return this.http.get<{ data: any, }>(url).pipe( + map(r => this.extractData(r)), + tap((data) => this.storeCache(data, repo)), + catchError(this.handleError) + ); + } + } + + /** + * Get a chart using the API + * + * @param repo Repository name + * @param chartName Chart name + * @return An observable that will a chart instance + */ + getChart(repo: string, chartName: string): Observable { + // Transform Observable into Observable[] + return this.http.get(`${this.hostname}/v1/charts/${repo}/${chartName}`).pipe( + map(this.extractData), + catchError(this.handleError) + ); + } + + // TODO: use backend search API endpoint + searchCharts(query, repo?: string): Observable { + const re = new RegExp(query, 'i'); + return this.getCharts(repo).pipe( + map(charts => { + return charts.filter(chart => { + return chart.attributes.name.match(re) || + chart.attributes.description.match(re) || + chart.attributes.repo.name.match(re) || + this.arrayMatch(chart.attributes.keywords, re) || + this.arrayMatch((chart.attributes.maintainers || []).map((m) => m.name), re) || + this.arrayMatch(chart.attributes.sources, re); + }); + }) + ); + } + + arrayMatch(keywords: string[], re): boolean { + if (!keywords) { return false; } + + return keywords.some((keyword) => { + return !!keyword.match(re); + }); + } + + /** + * Get a chart Readme using the API + * + * @param repo Repository name + * @param chartName Chart name + * @param version Chart version + * @return An observable that will be a chartReadme + */ + getChartReadme(chartVersion: ChartVersion): Observable { + return chartVersion.attributes.readme ? this.http.get(`${this.hostname}${chartVersion.attributes.readme}`, { + responseType: 'text' + }) : of('

No Readme available for this chart

'); + } + + /** + * Get a chart's Schema using the API + * + * @param repo Repository name + * @param chartName Chart name + * @param version Chart version + * @return An observable that will be the json schema + */ + getChartSchema(chartVersion: ChartVersion, chart: Chart): Observable { + const url = this.getChartSchemaURL(chartVersion, chart.attributes.name, chart.attributes.repo); + return url ? this.http.get(url) : of(null); + } + + // Get the URL for obtaining a Chart's schema + getChartSchemaURL(chartVersion: ChartVersion, name: string, repo: RepoAttributes): string { + // Helm Hub does not give us the schema information so we have to use an additional backend API to fetch the chart and check + if (chartVersion.attributes.schema === undefined) { + let url = this.getChartURL(chartVersion, repo); + url = btoa(url); + return `/pp/v1/monocular/schema/${name}/${url}`; + } + + // We have the schema URL, so we can fetch that directly + return chartVersion.attributes.schema ? `${this.hostname}${chartVersion.attributes.schema}` : null; + } + + getChartURL(chartVersion: ChartVersion, repo?: RepoAttributes): string { + const firstUrl = this.getFirstChartUrl(chartVersion); + if (firstUrl.length > 0) { + // Check if url is absolute, if not assume it's a filename + if (!firstUrl.startsWith('http://') && !firstUrl.startsWith('https://')) { + const repoUrl = repo ? repo.url : ''; + return repoUrl || this.getChartRepoUrl(chartVersion) + '/' + firstUrl; + } + } + return firstUrl; + } + + private getFirstChartUrl(chart: ChartVersion): string { + if (chart && chart.attributes && chart.attributes.urls && chart.attributes.urls.length > 0) { + return chart.attributes.urls[0]; + } + return ''; + } + + private getChartRepoUrl(chart: ChartVersion): string { + if (chart && + chart.relationships && + chart.relationships.chart && + chart.relationships.chart.data && + chart.relationships.chart.data.repo + ) { + return chart.relationships.chart.data.repo.url; + } + return ''; + } + + /** + * Get chart versions using the API + * + * @param repo Repository name + * @param chartName Chart name + * @return An observable containing an array of ChartVersions + */ + getVersions(repo: string, chartName: string): Observable { + return this.http.get<{ data: any, }>(`${this.hostname}/v1/charts/${repo}/${chartName}/versions`).pipe( + map(m => this.extractData(m)), + catchError(this.handleError) + ); + } + + /** + * Get chart version using the API + * + * @param repo Repository name + * @param chartName Chart name + * @return An observable containing an array of ChartVersions + */ + getVersion(repo: string, chartName: string, version: string): Observable { + return this.http.get(`${this.hostname}/v1/charts/${repo}/${chartName}/versions/${version}`).pipe( + map(this.extractData), + catchError(this.handleError) + ); + } + + getVersionFromEndpoint(endpoint: string, repo: string, chartName: string, version: string): Observable { + const requestArgs = { headers: { 'x-cap-cnsi-list': endpoint !== stratosMonocularEndpointGuid ? endpoint :'' } }; + return this.http.get( + `${this.hostname}/v1/charts/${repo}/${chartName}/versions/${version}`, requestArgs).pipe( + map(this.extractData), + catchError(this.handleError) + ); + } + + /** + * Get the URL for retrieving the chart's icon + * + * @param chart Chart object + */ + getChartIconURL(chart: Chart, version?: ChartVersion): string { + let url = version ? version.relationships.chart.data.icon : null; + url = url || chart.attributes.icon; + if (url) { + return this.updateStratosUrl(chart, `${this.hostname}${url}`); + } else { + return '/core/assets/custom/placeholder.png'; + } + } + + getChartSummaryRoute(repoName: string, chartName: string, version?: string, route?: ActivatedRoute, chart?: Chart): string { + return `/monocular/charts/${getMonocularEndpoint(route, chart)}/${repoName}/${chartName}${version ? `/${version}` : ''}`; + } + + /** + * Store the charts in the cache + * + * @param data Elements in the response + * @return Return the same response + */ + private storeCache(data: Chart[], repo: string): Chart[] { + this.cacheCharts[repo] = data; + return data; + } + + + private extractData(res: { data: any, }) { + return res.data || {}; + } + + private handleError(error: any) { + const errMsg = (error.message) ? error.message : + error.status ? `${error.status} - ${error.statusText}` : 'Server error'; + console.error(errMsg); + return throwError(errMsg); + } + +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/config.service.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/config.service.ts new file mode 100644 index 0000000000..3d87ef78b4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/config.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class ConfigService { + // Configurable options + // They can be overriden using assets/js/overrides.js + backendHostname: string; + appName: string; + aboutUrl: string; + // EO configurable options + + constructor() { + let overrides: any = {}; + // Object.keys(window).find(param => param === 'monocular'); + /* tslint:disable-next-line:no-string-literal */ + const monocular = window['monocular']; + if (monocular) { + overrides = monocular.overrides || {}; + } + + this.backendHostname = overrides.backendHostname || '/api'; + this.appName = overrides.appName || 'Monocular'; + this.aboutUrl = overrides.aboutUrl || 'https://github.com/helm/monocular/blob/master/docs/about.md'; + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/menu.service.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/menu.service.ts new file mode 100644 index 0000000000..20f2a9d292 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/menu.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; + + +@Injectable() +export class MenuService { + // Emitter + private menuOpenSource = new Subject(); + private open = false; + // Observable boolean streams + public menuOpen$ = this.menuOpenSource.asObservable(); + + showMenu() { + this.open = true; + this.menuOpenSource.next(this.open); + } + + hideMenu() { + this.open = false; + this.menuOpenSource.next(this.open); + } + + // Service message commands + toggleMenu() { + this.open = !this.open; + this.menuOpenSource.next(this.open); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/repos.service.ts b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/repos.service.ts new file mode 100644 index 0000000000..56dbe19649 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/shared/services/repos.service.ts @@ -0,0 +1,44 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +import { RepoAttributes } from '../models/repo'; +import { ConfigService } from './config.service'; + + +@Injectable() +export class ReposService { + + hostname: string; + + constructor( + private http: HttpClient, + private config: ConfigService, + ) { + this.hostname = `/pp/v1/chartrepos`; + } + + /** + * Get all repos from the API + * + * @return An observable that will an array with all repos + */ + getRepos(): Observable { + return this.http.get(`${this.hostname}`).pipe( + map(this.extractData), + catchError(this.handleError) + ); + } + + private extractData(res: { data: any, }) { + return res.data || {}; + } + + private handleError(error: any) { + const errMsg = (error.json().message) ? error.json().message : + error.status ? `${error.status} - ${error.statusText}` : 'Server error'; + console.error(errMsg); + return throwError(errMsg); + } +} diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular-providers.helpers.ts b/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular-providers.helpers.ts new file mode 100644 index 0000000000..ac028762ec --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular-providers.helpers.ts @@ -0,0 +1,28 @@ +import { HTTP_INTERCEPTORS, HttpBackend, HttpClient, HttpInterceptor } from '@angular/common/http'; + +import { HttpInterceptingHandler, MonocularInterceptor } from '../monocular.interceptor'; +import { ChartsService } from './shared/services/charts.service'; +import { ConfigService } from './shared/services/config.service'; +import { MenuService } from './shared/services/menu.service'; +import { ReposService } from './shared/services/repos.service'; + +/** + * Helm Method to ensure http client with custom monocular interceptor is used in the monocular services + */ +export const createMonocularProviders = () => [ + ChartsService, + ConfigService, + MenuService, + ReposService, + MonocularInterceptor, + { + provide: HttpClient, + useFactory: (httpBackend: HttpBackend, interceptors: HttpInterceptor[], monocularInterceptor: MonocularInterceptor) => { + return new HttpClient(new HttpInterceptingHandler(httpBackend, [ + ...interceptors, + monocularInterceptor + ])); + }, + deps: [HttpBackend, HTTP_INTERCEPTORS, MonocularInterceptor] + } +]; \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular.helper.ts b/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular.helper.ts new file mode 100644 index 0000000000..7a79192d1f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/monocular/stratos-monocular.helper.ts @@ -0,0 +1,19 @@ +import { ActivatedRoute } from '@angular/router'; + +import { Chart } from './shared/models/chart'; + + +/** + * Stratos Monocular has no concept of an endpoint (it has monocular repo endpoints...) so give it a default string + * Note - This could be the guid for the helm hub endpoint + */ +export const stratosMonocularEndpointGuid = 'default'; + +/** + * Add the monocular endpoint id to a url. This could be the helm hub endpoint guid or `default` for stratos monocular + */ +export const getMonocularEndpoint = (route?: ActivatedRoute, chart?: Chart, ifEmpty = stratosMonocularEndpointGuid) => { + const endpointFromRoute = route ? route.snapshot.params.endpoint : null; + const endpointFromChart = chart ? chart.monocularEndpointId : null; + return endpointFromRoute || endpointFromChart || ifEmpty; +}; diff --git a/src/frontend/packages/kubernetes/src/helm/store/helm.action-builders.ts b/src/frontend/packages/kubernetes/src/helm/store/helm.action-builders.ts new file mode 100644 index 0000000000..b6b37b9566 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/store/helm.action-builders.ts @@ -0,0 +1,49 @@ +import { OrchestratedActionBuilders } from '../../../../store/src/entity-catalog/action-orchestrator/action-orchestrator'; +import { EndpointModel } from '../../../../store/src/public-api'; +import { GetHelmChartVersions, GetHelmVersions, GetMonocularCharts, HelmInstall, HelmSynchronise } from './helm.actions'; +import { HelmInstallValues } from './helm.types'; + +export interface HelmChartActionBuilders extends OrchestratedActionBuilders { + getMultiple: () => GetMonocularCharts, + // Helm install added to chart action builder and not helm release/workload to ensure action & effect are available in this module + // (others may not have loaded) + install: (values: HelmInstallValues) => HelmInstall, + synchronise: (endpoint: EndpointModel) => HelmSynchronise; +} + +export const helmChartActionBuilders: HelmChartActionBuilders = { + getMultiple: () => new GetMonocularCharts(), + install: (values: HelmInstallValues) => new HelmInstall(values), + synchronise: (endpoint: EndpointModel) => new HelmSynchronise(endpoint) +}; + +export interface HelmVersionActionBuilders extends OrchestratedActionBuilders { + getMultiple: () => GetHelmVersions; +} + +export const helmVersionActionBuilders: HelmVersionActionBuilders = { + getMultiple: () => new GetHelmVersions() +}; + +export interface HelmChartVersionsActionBuilders extends OrchestratedActionBuilders { + getMultiple: ( + endpointGuid: string, + paginationKey: string, + extraArgs: { + monocularEndpoint: string, + repoName: string, + chartName: string; + }) => GetHelmChartVersions; +} + +export const helmChartVersionsActionBuilders: HelmChartVersionsActionBuilders = { + getMultiple: ( + endpointGuid: string, + paginationKey: string, + extraArgs: { + monocularEndpoint: string, + repoName: string, + chartName: string; + }) => + new GetHelmChartVersions(extraArgs.monocularEndpoint, extraArgs.repoName, extraArgs.chartName) +}; diff --git a/src/frontend/packages/kubernetes/src/helm/store/helm.actions.ts b/src/frontend/packages/kubernetes/src/helm/store/helm.actions.ts new file mode 100644 index 0000000000..ef32453dbf --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/store/helm.actions.ts @@ -0,0 +1,115 @@ +import { Action } from '@ngrx/store'; + +import { EndpointModel } from '../../../../store/src/public-api'; +import { PaginatedAction } from '../../../../store/src/types/pagination.types'; +import { EntityRequestAction } from '../../../../store/src/types/request.types'; +import { + HELM_ENDPOINT_TYPE, + helmEntityFactory, + helmVersionsEntityType, + monocularChartsEntityType, + monocularChartVersionsEntityType, +} from '../helm-entity-factory'; +import { HelmInstallValues } from './helm.types'; + +export const GET_MONOCULAR_CHARTS = '[Monocular] Get Charts'; +export const GET_MONOCULAR_CHARTS_SUCCESS = '[Monocular] Get Charts Success'; +export const GET_MONOCULAR_CHARTS_FAILURE = '[Monocular] Get Charts Failure'; + +export const GET_MONOCULAR_CHART_VERSIONS = '[Monocular] Get Chart Versions'; +export const GET_MONOCULAR_CHART_VERSIONS_SUCCESS = '[Monocular] Get Chart Versions Success'; +export const GET_MONOCULAR_CHART_VERSIONS_FAILURE = '[Monocular] Get Chart Versions Failure'; + +export const GET_HELM_VERSIONS = '[Helm] Get Versions'; +export const GET_HELM_VERSIONS_SUCCESS = '[Helm] Get Versions Success'; +export const GET_HELM_VERSIONS_FAILURE = '[Helm] Get Versions Failure'; + +export const HELM_INSTALL = '[Helm] Install'; +export const HELM_INSTALL_SUCCESS = '[Helm] Install Success'; +export const HELM_INSTALL_FAILURE = '[Helm] Install Failure'; + +export const HELM_SYNCHRONISE = '[Helm] Synchronise'; + +export interface MonocularPaginationAction extends PaginatedAction, EntityRequestAction { } + +export class GetMonocularCharts implements MonocularPaginationAction { + constructor() { + this.paginationKey = 'monocular-charts'; + } + type = GET_MONOCULAR_CHARTS; + endpointType = HELM_ENDPOINT_TYPE; + entityType = monocularChartsEntityType; + entity = [helmEntityFactory(monocularChartsEntityType)]; + actions = [ + GET_MONOCULAR_CHARTS, + GET_MONOCULAR_CHARTS_SUCCESS, + GET_MONOCULAR_CHARTS_FAILURE + ]; + paginationKey: string; + initialParams = { + 'order-direction': 'desc', + 'order-direction-field': 'name', + }; + flattenPagination = true; +} + +export class HelmSynchronise implements Action { + public type = HELM_SYNCHRONISE; + public guid: string; + + constructor(public endpoint: EndpointModel) { + this.guid = endpoint.guid; + } +} + +export class HelmInstall implements EntityRequestAction { + type = HELM_INSTALL; + endpointType = HELM_ENDPOINT_TYPE; + entityType = monocularChartsEntityType; + guid: string; + constructor(public values: HelmInstallValues) { + this.guid = '' + this.values.releaseName; + } +} + +export class GetHelmVersions implements MonocularPaginationAction { + constructor() { + this.paginationKey = 'helm-versions'; + } + type = GET_HELM_VERSIONS; + endpointType = HELM_ENDPOINT_TYPE; + entityType = helmVersionsEntityType; + entity = [helmEntityFactory(helmVersionsEntityType)]; + actions = [ + GET_HELM_VERSIONS, + GET_HELM_VERSIONS_SUCCESS, + GET_HELM_VERSIONS_FAILURE + ]; + paginationKey: string; + initialParams = { + 'order-direction': 'asc', + 'order-direction-field': 'version', + }; + flattenPagination = true; +} + +export class GetHelmChartVersions implements MonocularPaginationAction { + constructor(public monocularEndpoint: string, public repoName: string, public chartName: string) { + this.paginationKey = `'monocular-chart-versions-${repoName}-${chartName}`; + } + type = GET_MONOCULAR_CHART_VERSIONS; + endpointType = HELM_ENDPOINT_TYPE; + entityType = monocularChartVersionsEntityType; + entity = [helmEntityFactory(monocularChartVersionsEntityType)]; + actions = [ + GET_MONOCULAR_CHART_VERSIONS, + GET_MONOCULAR_CHART_VERSIONS_SUCCESS, + GET_MONOCULAR_CHART_VERSIONS_FAILURE + ]; + paginationKey: string; + initialParams = { + 'order-direction': 'asc', + 'order-direction-field': 'version', + }; + flattenPagination = true; +} diff --git a/src/frontend/packages/kubernetes/src/helm/store/helm.effects.ts b/src/frontend/packages/kubernetes/src/helm/store/helm.effects.ts new file mode 100644 index 0000000000..a8723b347f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/store/helm.effects.ts @@ -0,0 +1,447 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Action, Store } from '@ngrx/store'; +import { combineLatest, Observable, of } from 'rxjs'; +import { catchError, first, flatMap, map, mergeMap, withLatestFrom } from 'rxjs/operators'; + +import { environment } from '../../../../core/src/environments/environment'; +import { + EndpointActionComplete, + GET_ENDPOINTS_SUCCESS, + GetAllEndpointsSuccess, + REGISTER_ENDPOINTS_SUCCESS, + UNREGISTER_ENDPOINTS_SUCCESS, + UnregisterEndpoint, +} from '../../../../store/src/actions/endpoint.actions'; +import { ClearPaginationOfType, ResetPaginationOfType } from '../../../../store/src/actions/pagination.actions'; +import { EntitySchema } from '../../../../store/src/helpers/entity-schema'; +import { isJetstreamError } from '../../../../store/src/jetstream'; +import { + AppState, + EndpointModel, + entityCatalog, + NormalizedResponse, + WrapperRequestActionSuccess, +} from '../../../../store/src/public-api'; +import { ApiRequestTypes } from '../../../../store/src/reducers/api-request-reducer/request-helpers'; +import { endpointOfTypeSelector } from '../../../../store/src/selectors/endpoint.selectors'; +import { stratosEntityCatalog } from '../../../../store/src/stratos-entity-catalog'; +import { + EntityRequestAction, + StartRequestAction, + WrapperRequestActionFailed, +} from '../../../../store/src/types/request.types'; +import { helmEntityCatalog } from '../helm-entity-catalog'; +import { HELM_ENDPOINT_TYPE, HELM_HUB_ENDPOINT_TYPE, HELM_REPO_ENDPOINT_TYPE } from '../helm-entity-factory'; +import { Chart } from '../monocular/shared/models/chart'; +import { stratosMonocularEndpointGuid } from '../monocular/stratos-monocular.helper'; +import { + GET_HELM_VERSIONS, + GET_MONOCULAR_CHART_VERSIONS, + GET_MONOCULAR_CHARTS, + GetHelmChartVersions, + GetHelmVersions, + GetMonocularCharts, + HELM_INSTALL, + HELM_SYNCHRONISE, + HelmInstall, + HelmSynchronise, +} from './helm.actions'; +import { HelmVersion } from './helm.types'; + +type MonocularChartsResponse = { + data: Chart[]; +}; + +const mapMonocularChartResponse = ( + entityKey: string, + response: MonocularChartsResponse, + schema: EntitySchema +): NormalizedResponse => { + const base: NormalizedResponse = { + entities: { [entityKey]: {} }, + result: [] + }; + + const items = response.data as Array; + const processedData: NormalizedResponse = items.reduce((res, data) => { + const id = schema.getId(data); + res.entities[entityKey][id] = data; + // Promote the name to the top-level object for simplicity + data.name = data.attributes.name; + res.result.push(id); + return res; + }, base); + return processedData; +}; + +const mergeMonocularChartResponses = ( + entityKey: string, + responses: MonocularChartsResponse[], + schema: EntitySchema +): NormalizedResponse => { + const combined = responses.reduce((res, response) => { + res.data = res.data.concat(response.data); + return res; + }, { data: [] }); + return mapMonocularChartResponse(entityKey, combined, schema); +}; + +const addMonocularId = (endpointId: string, response: MonocularChartsResponse): MonocularChartsResponse => { + const data = response.data.map(chart => ({ + ...chart, + monocularEndpointId: endpointId + })); + return { + data + }; +}; + +@Injectable() +export class HelmEffects { + + constructor( + private httpClient: HttpClient, + private actions$: Actions, + private store: Store, + public snackBar: MatSnackBar, + ) { } + + // Endpoints that we know are synchronizing + private syncing = {}; + private syncTimer = null; + + proxyAPIVersion = environment.proxyAPIVersion; + + // Ensure that we refresh the charts when a repository finishes synchronizing + @Effect() + updateOnSyncFinished$ = this.actions$.pipe( + ofType(GET_ENDPOINTS_SUCCESS), + flatMap(action => { + // Look to see if we have any endpoints that are synchronizing + let updated = false; + Object.values(action.payload.entities.stratosEndpoint).forEach(endpoint => { + if (endpoint.cnsi_type === HELM_ENDPOINT_TYPE && endpoint.endpoint_metadata) { + if (endpoint.endpoint_metadata.status === 'Synchronizing') { + // An endpoint is busy, so add it to the list to be monitored + if (!this.syncing[endpoint.guid]) { + this.syncing[endpoint.guid] = true; + updated = true; + } + } + } + }); + + if (updated) { + // Schedule check + this.scheduleSyncStatusCheck(); + } + return []; + }) + ); + + @Effect() + fetchCharts$ = this.actions$.pipe( + ofType(GET_MONOCULAR_CHARTS), + withLatestFrom(this.store), + flatMap(([action, appState]) => { + const entityKey = entityCatalog.getEntityKey(action); + + this.store.dispatch(new StartRequestAction(action)); + + const helmEndpoints = Object.values(endpointOfTypeSelector(HELM_ENDPOINT_TYPE)(appState)); + const helmHubEndpoint = helmEndpoints.find(endpoint => endpoint.sub_type === HELM_HUB_ENDPOINT_TYPE); + + // See https://github.com/SUSE/stratos/issues/466. It would be better to use the standard proxy for this request and go out to all + // valid helm sub types instead of making two requests here + return combineLatest([ + this.createHelmRepoRequest(helmEndpoints), + this.createHelmHubRequest(helmHubEndpoint) + ]).pipe( + map(res => mergeMonocularChartResponses(entityKey, res, action.entity[0])), + mergeMap((response: NormalizedResponse) => [new WrapperRequestActionSuccess(response, action)]), + catchError(error => { + const { status, message } = HelmEffects.createHelmError(error); + const endpointIds = helmEndpoints.map(e => e.guid); + if (helmHubEndpoint) { + endpointIds.push(helmHubEndpoint.guid); + } + return [ + new WrapperRequestActionFailed(message, action, 'fetch', { + endpointIds, + url: null, + eventCode: status, + message, + error + }) + ]; + }) + ); + }) + ); + + @Effect() + fetchVersions$ = this.actions$.pipe( + ofType(GET_HELM_VERSIONS), + flatMap(action => { + const entityKey = entityCatalog.getEntityKey(action); + return this.makeRequest(action, `/pp/${this.proxyAPIVersion}/helm/versions`, (response) => { + const processedData: NormalizedResponse = { + entities: { [entityKey]: {} }, + result: [] + }; + + // Go through each endpoint ID + Object.keys(response).forEach(endpoint => { + const endpointData = response[endpoint] || {}; + if (isJetstreamError(endpointData)) { + throw endpointData; + } + // Maintain typing + const version: HelmVersion = { + endpointId: endpoint, + ...endpointData + }; + processedData.entities[entityKey][action.entity[0].getId(version)] = version; + processedData.result.push(endpoint); + }); + return processedData; + }, []); + }) + ); + + @Effect() + fetchChartVersions$ = this.actions$.pipe( + ofType(GET_MONOCULAR_CHART_VERSIONS), + flatMap(action => { + const entityKey = entityCatalog.getEntityKey(action); + return this.makeRequest(action, `/pp/${this.proxyAPIVersion}/chartsvc/v1/charts/${action.repoName}/${action.chartName}/versions`, + (response) => { + const base: NormalizedResponse = { + entities: { [entityKey]: {} }, + result: [] + }; + + const items = response.data as Array; + const processedData = items.reduce((res, data) => { + const id = action.entity[0].getId(data); + res.entities[entityKey][id] = data; + // Promote the name to the top-level object for simplicity + data.name = data.attributes.name; + res.result.push(id); + return res; + }, base); + return processedData; + }, [], { + 'x-cap-cnsi-list': action.monocularEndpoint && action.monocularEndpoint !== stratosMonocularEndpointGuid ? + action.monocularEndpoint : + '' + }); + }) + ); + + @Effect() + helmInstall$ = this.actions$.pipe( + ofType(HELM_INSTALL), + flatMap(action => { + const requestType: ApiRequestTypes = 'create'; + const url = '/pp/v1/helm/install'; + this.store.dispatch(new StartRequestAction(action, requestType)); + return this.httpClient.post(url, action.values).pipe( + mergeMap(() => { + return [ + new ClearPaginationOfType(action), + new WrapperRequestActionSuccess(null, action) + ]; + }), + catchError(error => { + const { status, message } = HelmEffects.createHelmError(error); + const errorMessage = `Failed to install helm chart: ${message}`; + return [ + new WrapperRequestActionFailed(errorMessage, action, requestType, { + endpointIds: [action.values.endpoint], + url: error.url || url, + eventCode: status, + message: errorMessage, + error + }) + ]; + }) + ); + }) + ); + + @Effect() + helmSynchronise$ = this.actions$.pipe( + ofType(HELM_SYNCHRONISE), + flatMap(action => { + const requestArgs = { + headers: null, + params: null + }; + const proxyAPIVersion = environment.proxyAPIVersion; + const url = `/pp/${proxyAPIVersion}/chartrepos/${action.endpoint.guid}`; + const req = this.httpClient.post(url, requestArgs); + req.subscribe(ok => { + this.snackBar.open('Helm Repository synchronization started', 'Dismiss', { duration: 3000 }); + }, err => { + this.snackBar.open(`Failed to Synchronize Helm Repository '${action.endpoint.name}'`, 'Dismiss', { duration: 5000 }); + }); + return []; + }) + ); + + @Effect() + endpointUnregister$ = this.actions$.pipe( + ofType(UNREGISTER_ENDPOINTS_SUCCESS), + flatMap(action => stratosEntityCatalog.endpoint.store.getEntityMonitor(action.guid).entity$.pipe( + first(), + mergeMap(endpoint => { + if (endpoint.cnsi_type !== HELM_ENDPOINT_TYPE) { + return []; + } + return [ + new ResetPaginationOfType(helmEntityCatalog.chart.getSchema()), + new ResetPaginationOfType(helmEntityCatalog.chartVersions.getSchema()), + new ResetPaginationOfType(helmEntityCatalog.version.getSchema()), + ]; + }) + )) + ); + + @Effect() + registerEndpoint$ = this.actions$.pipe( + ofType(REGISTER_ENDPOINTS_SUCCESS), + flatMap(action => { + const endpoint: EndpointModel = action.endpoint as EndpointModel; + if (endpoint && endpoint.cnsi_type === HELM_ENDPOINT_TYPE && endpoint.sub_type === HELM_HUB_ENDPOINT_TYPE) { + return [ + new ResetPaginationOfType(helmEntityCatalog.chart.getSchema()), + ]; + } + return []; + }) + ); + + private static createHelmErrorMessage(err: any): string { + if (err) { + if (err.error && err.error.message) { + // Kube error + return err.error.message; + } else if (err.message) { + // Http error + return err.message; + } else if (err.error.status) { + // Jetstream error + return err.error.status; + } + } + return 'Helm API request error'; + } + + public static createHelmError(err: any): { status: string, message: string, } { + let unwrapped = err; + if (err.error) { + unwrapped = err.error; + } + const jetstreamError = isJetstreamError(unwrapped); + if (jetstreamError) { + // Wrapped error + return { + status: jetstreamError.error.statusCode.toString(), + message: HelmEffects.createHelmErrorMessage(jetstreamError) + }; + } + return { + status: err && err.status ? err.status + '' : '500', + message: this.createHelmErrorMessage(err) + }; + } + + private createHelmHubRequest(helmHubEndpoint: EndpointModel): Observable { + return helmHubEndpoint ? + this.httpClient.get(`/pp/${this.proxyAPIVersion}/chartsvc/v1/charts`, { + headers: { + 'x-cap-cnsi-list': helmHubEndpoint.guid + } + }).pipe(map(res => addMonocularId(helmHubEndpoint.guid, res))) : + of({ data: [] }); + } + + private createHelmRepoRequest(helmEndpoints: EndpointModel[]): Observable { + const helmRepoEndpoints = helmEndpoints.find(endpoint => endpoint.sub_type === HELM_REPO_ENDPOINT_TYPE); + return helmRepoEndpoints ? + this.httpClient.get(`/pp/${this.proxyAPIVersion}/chartsvc/v1/charts`) : + of({ data: [] }); + } + + private makeRequest( + action: EntityRequestAction, + url: string, + mapResult: (response: any) => NormalizedResponse, + endpointIds: string[], + headers = {} + ): Observable { + this.store.dispatch(new StartRequestAction(action)); + const requestArgs = { + headers, + params: null + }; + return this.httpClient.get(url, requestArgs).pipe( + mergeMap((response: any) => [new WrapperRequestActionSuccess(mapResult(response), action)]), + catchError(error => { + const { status, message } = HelmEffects.createHelmError(error); + return [ + new WrapperRequestActionFailed(message, action, 'fetch', { + endpointIds, + url: error.url || url, + eventCode: status, + message, + error + }) + ]; + }) + ); + } + + private checkSyncStatus() { + // Dispatch request + const url = `/pp/${this.proxyAPIVersion}/chartrepos/status`; + const requestArgs = { + headers: null, + params: null + }; + const req = this.httpClient.post(url, this.syncing, requestArgs); + req.subscribe(data => { + if (data) { + const existing = Object.keys(data).length; + const syncing = {}; + Object.keys(data).forEach(guid => { + if (data[guid]) { + syncing[guid] = true; + } + }); + const remaining = Object.keys(syncing).length; + this.syncing = syncing; + if (remaining !== existing) { + // Dispatch action to refresh charts + helmEntityCatalog.chart.api.getMultiple(); + } + if (remaining > 0) { + this.scheduleSyncStatusCheck(); + } + } + }); + } + + private scheduleSyncStatusCheck() { + if (this.syncTimer !== null) { + clearTimeout(this.syncTimer); + this.syncTimer = null; + } + this.syncTimer = setTimeout(() => this.checkSyncStatus(), 5000); + } + +} diff --git a/src/frontend/packages/kubernetes/src/helm/store/helm.types.ts b/src/frontend/packages/kubernetes/src/helm/store/helm.types.ts new file mode 100644 index 0000000000..0bee35fc0e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/store/helm.types.ts @@ -0,0 +1,71 @@ +import { Chart } from '../monocular/shared/models/chart'; +import { ChartVersion } from '../monocular/shared/models/chart-version'; + +export interface MonocularRepository { + name: string; + url: string; + created: string; + syncInterval: number; + lastSync: number; + status: string; +} + +// Reuse types from the Monocular codebase +export interface MonocularChart extends Chart { + name: string; +} + +export type MonocularVersion = ChartVersion; + +// Basic Chart Metadata +export interface ChartMetadata { + name: string; + description: string; + sources: string[]; +} + +export interface HelmVersion { + endpointId: string; + Version?: { + git_commit: string; + git_tree_state: string; + sem_ver: string; + }; +} + +export enum HelmStatus { + Unknown = 0, + Deployed = 1, + Deleted = 2, + Superseded = 3, + Failed = 4, + Deleting = 5, + Pending_Install = 6, + Pending_Upgrade = 7, + Pending_Rollback = 8 +} + +export interface HelmChartReference { + endpoint?: string; + name: string; + repo: string; + version: string; +} + +export interface HelmUpgradeInstallValues { + monocularEndpoint: string; + values: string; + chart: HelmChartReference; + chartUrl: string; +} + +export interface HelmInstallValues extends HelmUpgradeInstallValues { + endpoint: string; + releaseName: string; + releaseNamespace: string; +} + + +export interface HelmUpgradeValues extends HelmUpgradeInstallValues { + restartPods?: boolean; +} diff --git a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.html b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.html new file mode 100644 index 0000000000..dd2b6cb351 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.scss b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.spec.ts new file mode 100644 index 0000000000..a772479936 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.spec.ts @@ -0,0 +1,32 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HelmBaseTestModules } from '../../helm-testing.module'; +import { MockChartService } from '../../monocular/shared/services/chart.service.mock'; +import { ChartsService } from '../../monocular/shared/services/charts.service'; +import { CatalogTabComponent } from './catalog-tab.component'; + +describe('CatalogTabComponent', () => { + let component: CatalogTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...HelmBaseTestModules + ], + declarations: [CatalogTabComponent], + providers: [{ provide: ChartsService, useValue: new MockChartService() },] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CatalogTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.ts b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.ts new file mode 100644 index 0000000000..f6047d2db4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/tabs/catalog-tab/catalog-tab.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; + +import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { MonocularChartsListConfig } from '../../list-types/monocular-charts-list-config.service'; + +@Component({ + selector: 'app-catalog-tab', + templateUrl: './catalog-tab.component.html', + styleUrls: ['./catalog-tab.component.scss'], + providers: [{ + provide: ListConfig, + useClass: MonocularChartsListConfig, + }] + +}) +export class CatalogTabComponent { } + diff --git a/src/frontend/packages/kubernetes/src/helm/theme.scss b/src/frontend/packages/kubernetes/src/helm/theme.scss new file mode 100644 index 0000000000..68f87746aa --- /dev/null +++ b/src/frontend/packages/kubernetes/src/helm/theme.scss @@ -0,0 +1,128 @@ +@import '~@angular/material/_theming'; +// Plus imports for other components in your app. + +// Include the base styles for Angular Material core. We include this here so that you only +// have to load a single css file for Angular Material in your app. +@include mat-core(); + +// Custom palette for monocular. +$color-base: #3371e3; + +$mat-monocular: ( + 50: #bdd1f6, // #e3f2fd, + 100: #adc6f4, // #bbdefb, + 200: #8fb1f0, // #90caf9, + 300: #709ceb, // #64b5f6, + 400: #5286e7, // #42a5f5, + 500: #3371e3, // #2196f3, + 600: #2b60c1, // #1e88e5, + 700: #244f9f, // #1976d2, + 800: #1c3e7d, // #1565c0, + 900: #142d5b, // #0d47a1, + A100: #adc6f4, // #82b1ff, + A200: #8fb1f0, // #448aff, + A400: #5286e7, // #2979ff, + A700: #244f9f, // #2962ff, + contrast: ( + 50: $black-87-opacity, + 100: $black-87-opacity, + 200: $black-87-opacity, + 300: $black-87-opacity, + 400: white, + 500: white, + 600: white, + 700: white, + 800: white, + 900: white, + A100: $black-87-opacity, + A200: $black-87-opacity, + A400: white, + A700: white, + ) +); + +// Define the palettes for your theme using the Material Design palettes available in palette.scss +// (imported above). For each palette, you can optionally specify a default, lighter, and darker +// hue. +$monocular-app-primary: mat-palette($mat-monocular); +$monocular-app-accent: mat-palette($mat-light-blue); + +// The warn palette is optional (defaults to red). +$monocular-app-warn: mat-palette($mat-red); + +// Create the theme object (a Sass map containing all of the palettes). +$monocular-app-theme: mat-light-theme($monocular-app-primary, $monocular-app-accent, $monocular-app-warn); + +// Include theme styles for core and each component used in your app. +// Alternatively, you can import and @include the theme mixins for each component +// that you are using. +// @include angular-material-theme($monocular-app-theme); + +// Base palette for the project +$layout-dark: #333239; +$layout-base: #303030; +$layout-light: #f8f8f8; +$layout-text: #38383F; +$text-white: white; +$background-light: #EAEDEF; +$background-white: white; +$header-backgound-gradient: linear-gradient(to left, $layout-base, mat-color($monocular-app-primary, 900)); +$border-color: #D7D9DD; + +// Other common sizes +$border-radius: 8px; +$layout-max-width: 1280px; +$header-bar-height: 70px; + +// Responsive. They represents the min width of every display. +$breakpoints: ( + small: 480px, + medium: 840px, + large: 1024px, + xlarge: 1280px +); + +$mappy-queries: ( + phone: mappy-bp(max-width small), + tablet: mappy-bp(small medium), + desktop: mappy-bp(medium xlarge), + wide: mappy-bp(xlarge) +); + +// Import the library. I need to import it after the breakpoints definition +@import '~mappy-breakpoints/mappy-breakpoints'; + +// Add more classes to Material2 components +mat-form-field { + &.mat-input--full { + width: 100%; + } + + .mat-input-infix { + border-top: 0; + } + + &.mat-input--reverse { + .mat-input-underline { + background-color: $layout-light; + } + .mat-input-element { + color: $layout-light; + } + } + + &:not(.mat-focused) .mat-input-placeholder { + color: darken($layout_light, 25); + } + + &.mat-input--big { + font-size: 1.5em; + } +} + +@mixin placeholder { + &::-webkit-input-placeholder {@content;} + &:-moz-placeholder {@content;} + &::-moz-placeholder {@content;} + &:-ms-input-placeholder {@content;} +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kube-package-routing.module.ts b/src/frontend/packages/kubernetes/src/kube-package-routing.module.ts new file mode 100644 index 0000000000..ed3d3e80e7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kube-package-routing.module.ts @@ -0,0 +1,57 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { HELM_ENDPOINT_TYPE } from './helm/helm-entity-factory'; +import { KUBERNETES_ENDPOINT_TYPE } from './kubernetes/kubernetes-entity-factory'; + +const customRoutes: Routes = [ + { + path: 'workloads', + loadChildren: () => import('./kubernetes/workloads/workloads.module').then(m => m.WorkloadsModule), + data: { + reuseRoute: true, + stratosNavigation: { + text: 'Workloads', + matIcon: 'workloads', + matIconFont: 'stratos-icons', + position: 60, + requiresEndpointType: KUBERNETES_ENDPOINT_TYPE + } + } + }, + { + path: 'kubernetes', + loadChildren: () => import('./kubernetes/kubernetes.module').then(m => m.KubernetesModule), + data: { + stratosNavigation: { + text: 'Kubernetes', + matIcon: 'kubernetes', + matIconFont: 'stratos-icons', // TODO: get these from entity config? + position: 64, + requiresEndpointType: KUBERNETES_ENDPOINT_TYPE + } + } + }, + { + path: 'monocular', + loadChildren: () => import('./helm/helm.module').then(m => m.HelmModule), + data: { + reuseRoute: true, + stratosNavigation: { + text: 'Helm', + matIcon: 'helm', + matIconFont: 'stratos-icons', + position: 65, + requiresEndpointType: HELM_ENDPOINT_TYPE + } + } + }, +]; + +@NgModule({ + imports: [ + RouterModule.forRoot(customRoutes), + ], + declarations: [] +}) +export class KubePackageModuleRoutingModule { } diff --git a/src/frontend/packages/kubernetes/src/kube-package.module.ts b/src/frontend/packages/kubernetes/src/kube-package.module.ts new file mode 100644 index 0000000000..286802cf3a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kube-package.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; + +import { CoreModule, MDAppModule, SharedModule } from '../../core/src/public-api'; +import { HelmSetupModule } from './helm/helm.setup.module'; +import { KubernetesSetupModule } from './kubernetes/kubernetes.setup.module'; + + +@NgModule({ + imports: [ + CoreModule, + SharedModule, + MDAppModule, + KubernetesSetupModule, + HelmSetupModule, + ], + // FIXME: Ensure that anything lazy loaded/in kube endpoint pages is not included here - #3675 + declarations: [ + ], + entryComponents: [ + ] +}) +export class KubePackageModule { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.html b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.html new file mode 100644 index 0000000000..6bfa187493 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.html @@ -0,0 +1,18 @@ + +
+ + + + + + + +
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.spec.ts new file mode 100644 index 0000000000..60794e0912 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.spec.ts @@ -0,0 +1,40 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedModule } from '../../../../../core/src/public-api'; +import { SidePanelService } from '../../../../../core/src/shared/services/side-panel.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { AnalysisReportRunnerComponent } from './analysis-report-runner.component'; + +describe('AnalysisReportRunnerComponent', () => { + let component: AnalysisReportRunnerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [AnalysisReportRunnerComponent], + imports: [ + SharedModule, + KubernetesBaseTestModules, + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + SidePanelService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AnalysisReportRunnerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.ts new file mode 100644 index 0000000000..e14601dd36 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.ts @@ -0,0 +1,49 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { SidePanelService } from 'frontend/packages/core/src/shared/services/side-panel.service'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { KubernetesAnalysisService, KubernetesAnalysisType } from '../../services/kubernetes.analysis.service'; +import { + KubernetesAnalysisInfoComponent, +} from '../../tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component'; + +@Component({ + selector: 'app-analysis-report-runner', + templateUrl: './analysis-report-runner.component.html', + styleUrls: ['./analysis-report-runner.component.scss'] +}) +export class AnalysisReportRunnerComponent implements OnInit { + + canShow$: Observable; + analyzers$: Observable; + @Input() kubeId: string; + @Input() namespace: string; + @Input() app: string; + + constructor( + public analysisService: KubernetesAnalysisService, + private sidePanelService: SidePanelService, + ) { + this.canShow$ = analysisService.hideAnalysis$.pipe(map(h => !h)); + } + + public runAnalysis(id: string) { + this.analysisService.run(id, this.kubeId, this.namespace, this.app); + } + + ngOnInit(): void { + if (this.namespace) { + this.analyzers$ = this.analysisService.namespaceAnalyzers$ + } else { + this.analyzers$ = this.analysisService.analyzers$; + } + } + + showAnalyzersInfo() { + this.sidePanelService.showModal(KubernetesAnalysisInfoComponent, { + analyzers$: this.analysisService.analyzers$ + }); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.html b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.html new file mode 100644 index 0000000000..4c13aaac65 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.html @@ -0,0 +1,22 @@ + +
+ + + + + + + + +
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.scss new file mode 100644 index 0000000000..2cd2769879 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.scss @@ -0,0 +1,3 @@ +.analysis-menu-divider { + margin: 4px 0; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.spec.ts new file mode 100644 index 0000000000..7740777a66 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MDAppModule } from '../../../../../core/src/public-api'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { AnalysisReportSelectorComponent } from './analysis-report-selector.component'; + +describe('AnalysisReportSelectorComponent', () => { + let component: AnalysisReportSelectorComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [AnalysisReportSelectorComponent], + imports: [ + KubernetesBaseTestModules, + MDAppModule + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AnalysisReportSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.ts new file mode 100644 index 0000000000..07f430e4cb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.ts @@ -0,0 +1,87 @@ +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import moment from 'moment'; +import { Observable, Subscription } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; + +import { safeUnsubscribe } from '../../../../../core/src/core/utils.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { AnalysisReport } from '../../store/kube.types'; + +@Component({ + selector: 'app-analysis-report-selector', + templateUrl: './analysis-report-selector.component.html', + styleUrls: ['./analysis-report-selector.component.scss'] +}) +export class AnalysisReportSelectorComponent implements OnInit, OnDestroy { + + public selection = { title: 'None' }; + + public canShow$: Observable; + public analyzers$: Observable; + + @Input() endpoint; + @Input() path; + @Input() prompt = 'Overlay Analysis'; + @Input() allowNone = true; + @Input() autoSelect; + + @Output() selected = new EventEmitter(); + @Output() reportCount = new EventEmitter(); + + autoSelected = false; + + subs: Subscription[] = []; + + constructor(public analysisService: KubernetesAnalysisService) { + this.canShow$ = analysisService.hideAnalysis$.pipe(map(h => !h)); + } + + ngOnInit() { + this.analyzers$ = this.analysisService.getByPath(this.endpoint, this.path).pipe( + map(reports => { + const res = []; + if (this.allowNone) { + res.push({ title: 'None' }); + } + if (reports) { + reports.forEach(r => { + const c = { ...r }; + const title = c.type.substr(0, 1).toUpperCase() + c.type.substr(1); + const age = moment(c.created).fromNow(true); + c.title = `${title} (${age})`; + res.push(c); + }); + } + this.reportCount.next(res.length); + return res; + }), + tap(reports => { + if (!this.autoSelected && this.autoSelect && reports.length > 0) { + this.onSelected(reports[0]); + } + }) + ); + } + + + // Selection changed + public onSelected(d) { + this.selection = d; + if (!d.id) { + this.selected.emit(null); + } else { + this.selected.next(d); + } + } + + public refreshReports($event: MouseEvent) { + this.analysisService.getByPath(this.endpoint, this.path, true); + $event.preventDefault(); + $event.cancelBubble = true; + } + + ngOnDestroy() { + safeUnsubscribe(...this.subs); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.html b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.html new file mode 100644 index 0000000000..c527914e5a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.html @@ -0,0 +1 @@ + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.spec.ts new file mode 100644 index 0000000000..6b49e51116 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../kubernetes.testing.module'; +import { KubernetesAnalysisService } from '../services/kubernetes.analysis.service'; +import { AnalysisReportViewerComponent } from './analysis-report-viewer.component'; + +describe('AnalysisReportViewerComponent', () => { + let component: AnalysisReportViewerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AnalysisReportViewerComponent ], + imports: [ + KubernetesBaseTestModules, + ], + providers: [ + KubernetesAnalysisService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AnalysisReportViewerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.ts new file mode 100644 index 0000000000..735ff9b6ce --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/analysis-report-viewer.component.ts @@ -0,0 +1,76 @@ +import { + Component, + ComponentFactoryResolver, + ComponentRef, + Input, + OnDestroy, + Type, + ViewChild, + ViewContainerRef, +} from '@angular/core'; + +import { AnalysisReport } from '../store/kube.types'; +import { KubeScoreReportViewerComponent } from './kube-score-report-viewer/kube-score-report-viewer.component'; +import { PopeyeReportViewerComponent } from './popeye-report-viewer/popeye-report-viewer.component'; + +export interface IReportViewer { + report: AnalysisReport; +} + +@Component({ + selector: 'app-analysis-report-viewer', + templateUrl: './analysis-report-viewer.component.html', + styleUrls: ['./analysis-report-viewer.component.scss'] +}) +export class AnalysisReportViewerComponent implements OnDestroy { + + // Component reference for the dynamically created auth form + @ViewChild('reportViewer', { read: ViewContainerRef, static: true }) + public container: ViewContainerRef; + private reportComponentRef: ComponentRef; + + private id: string; + + @Input('report') + set report(report: AnalysisReport) { + if (report === null || report.id === this.id) { + return; + } + this.id = report.id; + this.updateReport(report); + } + + constructor(private resolver: ComponentFactoryResolver) { } + + updateReport(report) { + switch (report.format) { + case 'popeye': + this.createComponent(PopeyeReportViewerComponent, report); + break; + case 'kubescore': + this.createComponent(KubeScoreReportViewerComponent, report); + break; + } + } + + // Dynamically create the component for the report type type + createComponent(component: Type, report: AnalysisReport) { + if (!component || !this.container) { + return; + } + + if (this.reportComponentRef) { + this.reportComponentRef.destroy(); + } + const factory = this.resolver.resolveComponentFactory(component); + this.reportComponentRef = this.container.createComponent(factory); + // this.reportComponentRef.instance.setReport(report); + this.reportComponentRef.instance.report = report; + } + + ngOnDestroy() { + if (this.reportComponentRef) { + this.reportComponentRef.destroy(); + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.html b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.html new file mode 100644 index 0000000000..a5dfac6b45 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.html @@ -0,0 +1,18 @@ +
+
{{ group._name }}
+
+
+
+ check_circle + info + warning + error + help_outline +
+
{{ check.Check.Name }}
+
+
+
{{ comment.Summary }}
+
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.scss new file mode 100644 index 0000000000..6c76b78b25 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.scss @@ -0,0 +1,23 @@ +.report { + &__group { + margin-bottom: 10px; + } + &__group-name { + font-weight: bold; + padding: 4px 0; + } + &__check { + align-items: center; + display: flex; + flex-direction: row; + margin-left: 10px; + } + &__icon { + margin-right: 4px; + } + &__comment { + display: list-item; + list-style: square; + margin-left: 58px; + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.spec.ts new file mode 100644 index 0000000000..6ad49bf3aa --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MDAppModule } from '../../../../../core/src/public-api'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { KubeScoreReportViewerComponent } from './kube-score-report-viewer.component'; + +describe('KubeScoreReportViewerComponent', () => { + let component: KubeScoreReportViewerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubeScoreReportViewerComponent], + imports: [ + KubernetesBaseTestModules, + MDAppModule + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeScoreReportViewerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.ts new file mode 100644 index 0000000000..0a53ea6921 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.ts @@ -0,0 +1,53 @@ +import { Component, OnInit } from '@angular/core'; + +import { IReportViewer } from '../analysis-report-viewer.component'; + +@Component({ + selector: 'app-kube-score-report-viewer', + templateUrl: './kube-score-report-viewer.component.html', + styleUrls: ['./kube-score-report-viewer.component.scss'] +}) +export class KubeScoreReportViewerComponent implements OnInit, IReportViewer { + + /* + Kube Score grading + + See: https://github.com/zegl/kube-score/blob/eca7bda47f5b3c523a0f41945cb1adda0a4e2e2e/scorecard/scorecard.go + GradeCritical Grade = 1 + GradeWarning Grade = 5 + GradeAlmostOK Grade = 7 + GradeAllOK Grade = 10 + */ + + report: any; + processed: any; + + constructor() { } + + ngOnInit() { + this.processed = []; + // Turn the report into an array + if (this.report) { + Object.keys(this.report.report).forEach(key => { + const filtered = this.filter(this.report.report[key]); + if (filtered.length > 0) { + this.processed.push({ + ...this.report.report[key], + _checks: filtered, + _name: key, + }); + } + }); + } + } + + public filter(report) { + const filtered = []; + report.Checks.forEach(r => { + if (r.Grade !== 10 && !r.Skipped) { + filtered.push(r); + } + }); + return filtered; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.html b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.html new file mode 100644 index 0000000000..6b198581cf --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.html @@ -0,0 +1,60 @@ +
+
+
Report
+
+
Score
+
{{processed.popeye.score }}
+
+
+
Grade
+
{{processed.popeye.grade }}
+
+
+ +
+
+
+
{{ section.sanitizer }}
+
+
OK
+
{{section.tally.ok }}
+
+
+
Info
+
{{section.tally.info }}
+
+
+
Warning
+
{{section.tally.warning }}
+
+
+
Error
+
{{section.tally.error }}
+
+
+
Score
+
{{section.tally.score }}
+
+
+ + + + + + + +
{{ group.name }} +
+
+ check_circle + info + warning + error + help_outline +
+ {{ issue.message }} +
+
+
+
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.scss new file mode 100644 index 0000000000..f70ee0c2c3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.scss @@ -0,0 +1,43 @@ +.report { + &__report-header { + align-items: center; + display: flex; + margin-bottom: 8px; + } + &__header { + align-items: center; + display: flex; + } + &__title { + flex: 1; + } + &__stat { + display: flex; + flex-direction: column; + padding: 5px 12px; + &>div:first-child { + opacity: 0.8; + } + } + &__score { + flex: 0; + font-size: 20px; + } + &__grade { + flex: 0; + font-size: 20px; + } + &__table { + margin-left: 20px; + } + &__issue { + align-items: center; + display: flex; + } + &__icon { + padding-right: 4px; + } + &__table-name { + vertical-align: top; + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.spec.ts new file mode 100644 index 0000000000..eb03a6aa2f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MDAppModule } from '../../../../../core/src/public-api'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { PopeyeReportViewerComponent } from './popeye-report-viewer.component'; + +describe('PopeyeReportViewerComponent', () => { + let component: PopeyeReportViewerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [PopeyeReportViewerComponent], + imports: [ + KubernetesBaseTestModules, + MDAppModule + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PopeyeReportViewerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.ts new file mode 100644 index 0000000000..1537046212 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.ts @@ -0,0 +1,59 @@ +import { Component, OnInit } from '@angular/core'; + +import { IReportViewer } from '../analysis-report-viewer.component'; + +@Component({ + selector: 'app-popeye-report-viewer', + templateUrl: './popeye-report-viewer.component.html', + styleUrls: ['./popeye-report-viewer.component.scss'] +}) +export class PopeyeReportViewerComponent implements OnInit, IReportViewer { + + report: any; + processed: any; + + ngOnInit() { + this.processed = this.apply(this.report); + } + + private apply(response) { + if (response) { + // In order to supplement the sanitizers with extra properties need to create new obj (see spread below and `reduce`) + response = { + ...response, + report: { + ...response.report, + popeye: { + ...response.report.popeye + } + } + } + // Make the response easier to render + response.report.popeye.sanitizers = response.report.popeye.sanitizers.reduce((ss, oldS) => { + const s = { ...oldS } + const groups = []; + let totalIssues = 0; + if (s.issues) { + Object.keys(s.issues).forEach(key => { + const issues = s.issues[key]; + totalIssues += issues.length; + if (issues.length > 0) { + groups.push({ + name: key, + issues + }); + } + }); + s.hide = totalIssues === 0; + } else { + s.hide = true; + } + s.groups = groups; + ss.push(s); + return ss; + }, []); + + return response.report; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.html b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.html new file mode 100644 index 0000000000..0e7a5b9605 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.html @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.scss new file mode 100644 index 0000000000..d7d483462b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.scss @@ -0,0 +1,14 @@ +.alert { + &__info { + align-items: center; + display: flex; + margin: 4px 20px; + } + &__icon { + margin-right: 8px; + } + &__group { + font-weight: bold; + padding: 4px 0; + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.spec.ts new file mode 100644 index 0000000000..f78f26e839 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.spec.ts @@ -0,0 +1,34 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ResourceAlertPreviewComponent } from './resource-alert-preview.component'; +import { ResourceAlertViewComponent } from './resource-alert-view/resource-alert-view.component'; +import { SidePanelService } from 'frontend/packages/core/src/shared/services/side-panel.service'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; + +describe('ResourceAlertPreviewComponent', () => { + let component: ResourceAlertPreviewComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ ResourceAlertPreviewComponent, ResourceAlertViewComponent ], + imports: [ + KubernetesBaseTestModules, + ], + providers: [ + SidePanelService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ResourceAlertPreviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.ts new file mode 100644 index 0000000000..139644c612 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { PreviewableComponent } from 'frontend/packages/core/src/shared/previewable-component'; + +@Component({ + selector: 'app-resource-alert-preview', + templateUrl: './resource-alert-preview.component.html', + styleUrls: ['./resource-alert-preview.component.scss'] +}) +export class ResourceAlertPreviewComponent implements PreviewableComponent { + + title: string; + + resource: any; + alerts: any; + + constructor() { } + + setProps(props: { [key: string]: any, }): void { + this.resource = props.resource; + this.title = `${this.resource.kind} Alerts`; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.html b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.html new file mode 100644 index 0000000000..f9193d84c4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.html @@ -0,0 +1,16 @@ +
+
+
{{group.name}}
+
+
+
+ info + warning + error + help_outline +
+ {{ alert.message }} +
+
+
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.scss new file mode 100644 index 0000000000..d7d483462b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.scss @@ -0,0 +1,14 @@ +.alert { + &__info { + align-items: center; + display: flex; + margin: 4px 20px; + } + &__icon { + margin-right: 8px; + } + &__group { + font-weight: bold; + padding: 4px 0; + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.spec.ts new file mode 100644 index 0000000000..b80151d9b3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MDAppModule } from '../../../../../../core/src/public-api'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { ResourceAlertViewComponent } from './resource-alert-view.component'; + +describe('ResourceAlertViewComponent', () => { + let component: ResourceAlertViewComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ResourceAlertViewComponent], + imports: [ + KubernetesBaseTestModules, + MDAppModule + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ResourceAlertViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.ts new file mode 100644 index 0000000000..15368b31e4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.ts @@ -0,0 +1,46 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-resource-alert-view', + templateUrl: './resource-alert-view.component.html', + styleUrls: ['./resource-alert-view.component.scss'] +}) +export class ResourceAlertViewComponent { + + alertInfo; + + @Input() + set alerts(data: any) { + if (data) { + const alerts = data.alerts ? data.alerts : data; + this.alertInfo = this.normalize(alerts); + } + } + + @Input() showHeader = true; + + normalize(data) { + // Normalize the alerts into groups + const normalized = {}; + data.forEach(item => { + const path = item.namespace ? `${item.namespace}/${item.name}` : item.name; + if (!normalized[path]) { + normalized[path] = []; + } + normalized[path].push({ + ...item, + path + }); + }); + + const arr = []; + Object.keys(normalized).forEach(group => { + arr.push({ + name: group, + alerts: normalized[group] + }); + }); + return arr; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.html b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.html new file mode 100644 index 0000000000..aea61924f0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.html @@ -0,0 +1,13 @@ +
+
+ + + + + + + + + +
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.scss new file mode 100644 index 0000000000..dfaf0f5222 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.scss @@ -0,0 +1,6 @@ +.kube-aws-auth { + &__form { + display: flex; + flex-direction: column; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.spec.ts new file mode 100644 index 0000000000..66400910ac --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { MDAppModule, SharedModule } from '../../../../../core/src/public-api'; +import { KubernetesAWSAuthFormComponent } from './kubernetes-aws-auth-form.component'; + +describe('KubernetesAWSAuthFormComponent', () => { + let component: KubernetesAWSAuthFormComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesAWSAuthFormComponent], + imports: [ + MDAppModule, + SharedModule, + NoopAnimationsModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesAWSAuthFormComponent); + component = fixture.componentInstance; + const fb = new FormBuilder(); + const form = fb.group({ + authValues: fb.group({ + cluster: '', + access_key: '', + secret_key: '' + }), + }); + component.formGroup = form; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.ts new file mode 100644 index 0000000000..78f5d08465 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component.ts @@ -0,0 +1,15 @@ +import { Component, Input } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { IAuthForm } from '../../../../../store/src/extension-types'; + + + +@Component({ + selector: 'app-kubernetes-aws-auth-form', + templateUrl: './kubernetes-aws-auth-form.component.html', + styleUrls: ['./kubernetes-aws-auth-form.component.scss'] +}) +export class KubernetesAWSAuthFormComponent implements IAuthForm { + @Input() formGroup: FormGroup; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.html b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.html new file mode 100644 index 0000000000..80770e8fcb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.html @@ -0,0 +1,26 @@ +
+
+ + +
+ Specify Certificate file: + + + Specify Certificate key file: + + + + +
+
+ +
+ Specify Certificate: + + Specify Certificate key: + +
+
+
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.scss new file mode 100644 index 0000000000..5385ee9711 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.scss @@ -0,0 +1,25 @@ +.kube-certs-auth { + &__form { + display: flex; + flex-direction: column; + height: 250px; + width: 450px; + + &__file, + &__content { + display: flex; + flex-direction: column; + padding-top: 15px; + } + + &__content { + & > * { + margin-bottom: 10px; + } + textarea { + max-height: 75px; + min-height: 60px; + } + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.spec.ts new file mode 100644 index 0000000000..860cbec1e8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.spec.ts @@ -0,0 +1,43 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CoreModule } from 'frontend/packages/core/src/core/core.module'; + +import { SharedModule } from './../../../../../core/src/shared/shared.module'; +import { KubernetesCertsAuthFormComponent } from './kubernetes-certs-auth-form.component'; + +describe('KubernetesCertsAuthFormComponent', () => { + let component: KubernetesCertsAuthFormComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesCertsAuthFormComponent], + imports: [ + CoreModule, + SharedModule, + NoopAnimationsModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesCertsAuthFormComponent); + component = fixture.componentInstance; + const fb = new FormBuilder(); + const form = fb.group({ + authValues: fb.group({ + cert: '', + certKey: '' + }), + }); + component.formGroup = form; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.ts new file mode 100644 index 0000000000..7fae0d007d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component.ts @@ -0,0 +1,38 @@ +import { Component, Input } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { EndpointAuthValues, IEndpointAuthComponent } from '../../../../../store/src/extension-types'; + + +@Component({ + selector: 'app-kubernetes-certs-auth-form', + templateUrl: './kubernetes-certs-auth-form.component.html', + styleUrls: ['./kubernetes-certs-auth-form.component.scss'] +}) +export class KubernetesCertsAuthFormComponent implements IEndpointAuthComponent { + @Input() formGroup: FormGroup; + + + public getValues(values: EndpointAuthValues): EndpointAuthValues { + return {}; + } + + public getBody(): string { + /** Body content is in the following encoding: + * base64encoded:base64encoded + */ + + let certBase64 = this.formGroup.value.authValues.cert; + let certKeyBase64 = this.formGroup.value.authValues.certKey; + + // May already be base64 encoded + if (certBase64.indexOf('-----BEGIN') === 0) { + certBase64 = btoa(this.formGroup.value.authValues.cert); + } + + if (certKeyBase64.indexOf('-----BEGIN') === 0) { + certKeyBase64 = btoa(this.formGroup.value.authValues.certKey); + } + return `${certBase64}:${certKeyBase64}`; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.html b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.html new file mode 100644 index 0000000000..9d85e61578 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.html @@ -0,0 +1,8 @@ +
+
+ Specify `kubeconfig` file: + + + +
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.scss new file mode 100644 index 0000000000..7b53acf245 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.scss @@ -0,0 +1,6 @@ +.kube-config-auth { + &__form { + display: flex; + flex-direction: column; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.spec.ts new file mode 100644 index 0000000000..dba933224c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.spec.ts @@ -0,0 +1,39 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { SharedModule } from './../../../../../core/src/shared/shared.module'; +import { KubernetesConfigAuthFormComponent } from './kubernetes-config-auth-form.component'; + +describe('KubernetesConfigAuthFormComponent', () => { + let component: KubernetesConfigAuthFormComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesConfigAuthFormComponent], + imports: [ + SharedModule, + NoopAnimationsModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesConfigAuthFormComponent); + component = fixture.componentInstance; + const fb = new FormBuilder(); + const form = fb.group({ + authValues: fb.group({ + kubeconfig: '' + }), + }); + component.formGroup = form; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.ts new file mode 100644 index 0000000000..dc7491f0e8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { EndpointAuthValues, IEndpointAuthComponent } from '../../../../../store/src/extension-types'; + + + +@Component({ + selector: 'app-kubernetes-config-auth-form', + templateUrl: './kubernetes-config-auth-form.component.html', + styleUrls: ['./kubernetes-config-auth-form.component.scss'] +}) +export class KubernetesConfigAuthFormComponent implements IEndpointAuthComponent { + @Input() formGroup: FormGroup; + + public getValues(values: EndpointAuthValues): EndpointAuthValues { + return {}; + } + + public getBody(): string { + return this.formGroup.value.authValues.kubeconfig; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.html b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.html new file mode 100644 index 0000000000..4d023829ec --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.html @@ -0,0 +1,8 @@ +
+
+ Select an `Application Default Credentials' file: + + + +
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.scss new file mode 100644 index 0000000000..2910cc4365 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.scss @@ -0,0 +1,6 @@ +.kube-gke-auth { + &__form { + display: flex; + flex-direction: column; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.spec.ts new file mode 100644 index 0000000000..24fe572feb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.spec.ts @@ -0,0 +1,39 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { SharedModule } from './../../../../../core/src/shared/shared.module'; +import { KubernetesGKEAuthFormComponent } from './kubernetes-gke-auth-form.component'; + +describe('KubernetesGKEAuthFormComponent', () => { + let component: KubernetesGKEAuthFormComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesGKEAuthFormComponent], + imports: [ + SharedModule, + NoopAnimationsModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesGKEAuthFormComponent); + component = fixture.componentInstance; + const fb = new FormBuilder(); + const form = fb.group({ + authValues: fb.group({ + gkeconfig: '' + }), + }); + component.formGroup = form; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.ts new file mode 100644 index 0000000000..c810b406a1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component.ts @@ -0,0 +1,23 @@ +import { Component, Input } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { EndpointAuthValues, IEndpointAuthComponent } from '../../../../../store/src/extension-types'; + + + +@Component({ + selector: 'app-kubernetes-gke-auth-form', + templateUrl: './kubernetes-gke-auth-form.component.html', + styleUrls: ['./kubernetes-gke-auth-form.component.scss'] +}) +export class KubernetesGKEAuthFormComponent implements IEndpointAuthComponent { + @Input() formGroup: FormGroup; + + public getValues(values: EndpointAuthValues): EndpointAuthValues { + return {}; + } + + public getBody(): string { + return this.formGroup.value.authValues.gkeconfig; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts new file mode 100644 index 0000000000..e6caba2fd0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-auth.helper.ts @@ -0,0 +1,179 @@ +import { ComponentFactoryResolver, Injector } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; + +import { ConnectEndpointData } from '../../../../core/src/features/endpoints/connect.service'; +import { RowState } from '../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { EndpointAuthTypeConfig, IAuthForm } from '../../../../store/src/extension-types'; +import { entityCatalog } from '../../../../store/src/public-api'; +import { KUBERNETES_ENDPOINT_TYPE } from '../kubernetes-entity-factory'; +import { KubeConfigFileCluster, KubeConfigFileUser } from './kube-config.types'; + +/** + * Auth helper tries to figure out the Kubernetes sub-type and auth to use + * based on the kube config file contents + */ +export class KubeConfigAuthHelper { + + authTypes: { [name: string]: EndpointAuthTypeConfig, } = {}; + + public subTypes = []; + + constructor() { + const epTypeInfo = entityCatalog.getAllEndpointTypes(false); + const k8s = epTypeInfo.find(entity => entity.type === KUBERNETES_ENDPOINT_TYPE); + if (k8s && k8s.definition) { + const defn = k8s.definition; + + // Collect all of the auth types + defn.authTypes.forEach(at => { + this.authTypes[at.value] = at; + }); + + this.subTypes.push({ id: '', name: 'Generic' }); + + // Collect all of the auth types for the sub-types + defn.subTypes.forEach(st => { + if (st.type !== 'config') { + this.subTypes.push({ id: st.type, name: st.labelShort }); + } + st.authTypes.forEach(at => { + this.authTypes[at.value] = at; + }); + }); + + // Sort the subtypes + this.subTypes = this.subTypes.sort((a, b) => a.name.localeCompare(b.name)); + } + } + + // Try and parse the authentication metadata + public parseAuth(cluster: KubeConfigFileCluster, user: KubeConfigFileUser): RowState { + + // Default subtype is generic Kubernetes ('') or previously determined/selected sub type + cluster._subType = cluster._subType || ''; + + // Certificate authentication first + + // In-file certificate authentication + if (user.user['client-certificate-data'] && user.user['client-key-data']) { + // We are good to go - create the form data + + // Default is generic kubernetes + let subType = ''; + const authType = 'kube-cert-auth'; + if (cluster.cluster.server.indexOf('azmk8s.io') >= 0) { + // Probably Azure + subType = 'aks'; + cluster._subType = 'aks'; + } + + const authData = { + authType, + subType, + values: { + cert: user.user['client-certificate-data'], + certKey: user.user['client-key-data'] + } + }; + user._authData = authData; + return {}; + } + + if (user.user['client-certificate'] || user.user['client-key']) { + cluster._additionalUserInfo = true; + return { + message: 'This endpoint will be registered but not connected (additional information is required)', + info: true + }; + } + + const authProvider = user.user['auth-provider']; + if (authProvider && authProvider.config) { + if (authProvider.config['cmd-path'] && authProvider.config['cmd-path'].indexOf('gcloud') !== -1) { + // GKE + cluster._subType = 'gke'; + // Can not connect to GKE - user must do so manually + cluster._additionalUserInfo = true; + return { + message: 'This endpoint will be registered but not connected (additional information is required)', + info: true + }; + } + } + + if ( + cluster.cluster.server.indexOf('eks.amazonaws.com') >= 0 || + (user.user.exec && user.user.exec.command && user.user.exec.command === 'aws-iam-authenticator') + ) { + // Probably EKS + cluster._subType = 'eks'; + cluster._additionalUserInfo = true; + return { + message: 'This endpoint will be registered but not connected (additional information is required)', + info: true + }; + } + + // Username and password auth + if (user.user.username && user.user.password) { + const authData = { + authType: 'creds', + subType: '', + values: { + username: user.user.username, + password: user.user.password + } + }; + user._authData = authData; + return {}; + } + + return { message: 'Authentication mechanism is not supported', warning: true }; + } + + // Use the auto component to get the data in the correct format for connecting to the endpoint + public getAuthDataForConnect(resolver: ComponentFactoryResolver, injector: Injector, fb: FormBuilder, user: KubeConfigFileUser) + : ConnectEndpointData | null { + + let data = null; + + // Get the component to us + if (user && user._authData) { + const authType = this.authTypes[user._authData.authType]; + + const factory = resolver.resolveComponentFactory(authType.component); + + const ref = factory.create(injector); + + const form = fb.group({ + authType: authType.value, + systemShared: false, + authValues: fb.group(user._authData.values) + }); + + ref.instance.formGroup = form; + + // Allow the auth form to supply body content if it needs to + const endpointFormInstance = ref.instance as any; + if (endpointFormInstance.getBody && endpointFormInstance.getValues) { + data = { + authType: authType.value, + authVal: endpointFormInstance.getValues(user._authData.values), + systemShared: false, + bodyContent: endpointFormInstance.getBody() + }; + } else { + // Use values as is + data = { + authType: authType.value, + authVal: user._authData.values, + systemShared: false, + bodyContent: null + }; + } + + ref.destroy(); + } + return data; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.html new file mode 100644 index 0000000000..b78c35d87c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.scss new file mode 100644 index 0000000000..05510cdc8c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.scss @@ -0,0 +1,8 @@ +:host { + display: flex; + flex: 1; +} + +.kubeconfig-import { + flex: 1; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts new file mode 100644 index 0000000000..a006e2debc --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.spec.ts @@ -0,0 +1,29 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubeConfigImportComponent } from './kube-config-import.component'; + +describe('KubeConfigImportComponent', () => { + let component: KubeConfigImportComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [KubeConfigImportComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigImportComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts new file mode 100644 index 0000000000..a8d27482c5 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-import.component.ts @@ -0,0 +1,323 @@ +import { Component, ComponentFactoryResolver, Injector, OnDestroy } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, first, map, pairwise, startWith, withLatestFrom } from 'rxjs/operators'; + +import { EndpointsService } from '../../../../../core/src/core/endpoints.service'; +import { safeUnsubscribe } from '../../../../../core/src/core/utils.service'; +import { + ConnectEndpointConfig, + ConnectEndpointData, + ConnectEndpointService, +} from '../../../../../core/src/features/endpoints/connect.service'; +import { + IActionMonitorComponentState, +} from '../../../../../core/src/shared/components/app-action-monitor-icon/app-action-monitor-icon.component'; +import { + ITableListDataSource, + RowState, +} from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { StepOnNextFunction } from '../../../../../core/src/shared/components/stepper/step/step.component'; +import { AppState } from '../../../../../store/src/public-api'; +import { ActionState } from '../../../../../store/src/reducers/api-request-reducer/types'; +import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-catalog'; +import { KUBERNETES_ENDPOINT_TYPE } from '../../kubernetes-entity-factory'; +import { KubeConfigAuthHelper } from '../kube-config-auth.helper'; +import { KubeConfigFileCluster, KubeConfigImportAction, KubeImportState } from '../kube-config.types'; +import { + KubeConfigTableImportStatusComponent, +} from './kube-config-table-import-status/kube-config-table-import-status.component'; + +const REGISTER_ACTION = 'Register endpoint'; +const CONNECT_ACTION = 'Connect endpoint'; + +@Component({ + selector: 'app-kube-config-import', + templateUrl: './kube-config-import.component.html', + styleUrls: ['./kube-config-import.component.scss'] +}) +export class KubeConfigImportComponent implements OnDestroy { + + done = new BehaviorSubject(false); + done$ = this.done.asObservable(); + busy = new BehaviorSubject(false); + busy$ = this.busy.asObservable(); + data = new BehaviorSubject([]); + data$ = this.data.asObservable(); + + public dataSource: ITableListDataSource = { + connect: () => this.data$, + disconnect: () => { }, + // Ensure unique per entry to step (in case user went back step and updated) + trackBy: (index, item) => item.cluster.name + this.iteration, + isTableLoading$: this.data$.pipe(map(data => !(data && data.length > 0))), + getRowState: (row: KubeConfigImportAction): Observable => { + return row ? row.state.asObservable() : observableOf({}); + } + }; + public columns: ITableColumn[] = [ + { + columnId: 'action', headerCell: () => 'Action', + cellDefinition: { + valuePath: 'action' + }, + cellFlex: '1', + }, + { + columnId: 'description', headerCell: () => 'Description', + cellDefinition: { + valuePath: 'description' + }, + cellFlex: '4', + }, + // Right-hand column to show the action progress + { + columnId: 'monitorState', + cellComponent: KubeConfigTableImportStatusComponent, + cellConfig: (row) => row.actionState.asObservable(), + cellFlex: '0 0 24px' + } + ]; + + subs: Subscription[] = []; + applyStarted: boolean; + private iteration = 0; + + private connectService: ConnectEndpointService; + + constructor( + public store: Store, + public resolver: ComponentFactoryResolver, + private injector: Injector, + private fb: FormBuilder, + private endpointsService: EndpointsService, + ) { + } + + // Process the next action in the list + private processAction(actions: KubeConfigImportAction[]) { + if (actions.length === 0) { + // We are done + this.done.next(true); + this.busy.next(false); + return; + } + + // Get the next action + const i = actions.shift(); + if (i.action === REGISTER_ACTION) { + this.doRegister(i, actions); + } else if (i.action === CONNECT_ACTION) { + this.doConnect(i, actions); + } else { + // Do the next action + this.processAction(actions); + } + } + + private doRegister(reg: KubeConfigImportAction, next: KubeConfigImportAction[]) { + const obs$ = this.registerEndpoint( + reg.cluster.name, + reg.cluster.cluster.server, + reg.cluster.cluster['insecure-skip-tls-verify'], + reg.cluster._subType + ); + const mainObs$ = this.getUpdatingState(obs$).pipe( + startWith({ busy: true, error: false, completed: false }) + ); + + this.subs.push(mainObs$.subscribe(reg.actionState)); + + const sub = reg.actionState.subscribe(progress => { + // Not sure what the status is used for? + reg.status = progress; + if (progress.error && progress.message) { + // Mark all dependency jobs as skip + next.forEach(action => { + if (action.depends === reg) { + // Mark it as skipped by setting the action to null + action.action = null; + action.state.next({ message: 'Skipping action as endpoint could not be registered', warning: true }); + } + }); + reg.state.next({ message: progress.message, error: true }); + } + if (progress.completed) { + if (!progress.error) { + // If we created okay, then guid is in the message + reg.cluster._guid = progress.message; + } + sub.unsubscribe(); + // Do the next one + this.processAction(next); + } + }); + this.subs.push(sub); + } + + private doConnect(connect: KubeConfigImportAction, next: KubeConfigImportAction[]) { + if (!connect.user) { + connect.state.next({ message: 'Can not connect - no user specified', error: true }); + return; + } + const helper = new KubeConfigAuthHelper(); + const data = helper.getAuthDataForConnect(this.resolver, this.injector, this.fb, connect.user); + if (data) { + const obs$ = this.connectEndpoint(connect, data); + + // Echo obs$ to the behaviour subject + this.subs.push(obs$.subscribe(connect.actionState)); + + this.subs.push(connect.actionState.pipe(filter(status => status.completed), first()).subscribe(status => { + if (status.error) { + connect.state.next({ message: status.message, error: true }); + } + this.processAction(next); + })); + } else { + connect.state.next({ message: 'Can not connect - could not get user auth data', error: true }); + } + } + + ngOnDestroy() { + safeUnsubscribe(...this.subs); + + if (this.connectService) { + this.connectService.destroy(); + } + } + + // Register the endpoint + private registerEndpoint(name: string, url: string, skipSslValidation: boolean, subType: string) { + return stratosEntityCatalog.endpoint.api.register( + KUBERNETES_ENDPOINT_TYPE, + subType, + name, + url, + skipSslValidation, + '', + '', + false + ).pipe( + filter(update => !!update) + ); + } + + // Connect to an endpoint + private connectEndpoint(action: KubeConfigImportAction, pData: ConnectEndpointData): Observable { + const config: ConnectEndpointConfig = { + name: action.cluster.name, + guid: action.depends.cluster._guid || action.cluster._guid, + type: KUBERNETES_ENDPOINT_TYPE, + subType: action.user._authData.subType, + ssoAllowed: false + }; + + if (this.connectService) { + this.connectService.destroy(); + } + this.connectService = new ConnectEndpointService(this.endpointsService, config); + this.connectService.setData(pData); + return this.connectService.submit().pipe( + map(updateSection => ({ + busy: false, + error: !updateSection.success, + completed: true, + message: updateSection.errorMessage + })), + startWith({ + message: '', + busy: true, + completed: false, + error: false + }) + ); + } + + // Enter the step - process the list of clusters to import + onEnter = (data: KubeConfigFileCluster[]) => { + this.applyStarted = false; + this.iteration += 1; + const imports: KubeConfigImportAction[] = []; + data.forEach(item => { + if (item._selected) { + const register = { + action: REGISTER_ACTION, + description: `Register "${item.name}" with the URL "${item.cluster.server}"`, + cluster: item, + state: new BehaviorSubject({}), + actionState: new BehaviorSubject({}), + }; + // Only include if the endpoint does not already exist + if (!item._guid) { + imports.push(register); + } + if (item._additionalUserInfo) { + return; + } + const user = item._users.find(u => u.name === item._user); + if (user) { + imports.push({ + action: CONNECT_ACTION, + description: `Connect "${item.name}" with the user "${user.name}"`, + cluster: item, + user, + state: new BehaviorSubject({}), + depends: register, + actionState: new BehaviorSubject({}), + }); + } + } + }); + this.data.next(imports); + }; + + // Finish - go back to the endpoints view + onNext: StepOnNextFunction = () => { + if (this.applyStarted) { + // this.store.dispatch(new RouterNav({ path: ['endpoints'] })); + return observableOf({ success: true, redirect: true }); + + } else { + this.applyStarted = true; + this.busy.next(true); + this.data$.pipe( + filter((data => data && data.length > 0)), + first() + ).subscribe(imports => { + // Go through the imports and dispatch the actions to perform them in sequence + this.processAction([...imports]); + }); + return observableOf({ success: true, ignoreSuccess: true }); + } + }; + + // These two should be somewhere else + private getUpdatingState(actionState$: Observable): Observable { + const completed$ = this.getHasCompletedObservable(actionState$.pipe(map(requestState => requestState.busy))); + return actionState$.pipe( + pairwise(), + withLatestFrom(completed$), + map(([[, requestState], completed]) => { + return { + busy: requestState.busy, + error: requestState.error, + completed, + message: requestState.message, + }; + }) + ); + } + + private getHasCompletedObservable(busy$: Observable) { + return busy$.pipe( + distinctUntilChanged(), + pairwise(), + map(([oldBusy, newBusy]) => oldBusy && !newBusy), + startWith(false), + ); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.html new file mode 100644 index 0000000000..e4b06461a1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.html @@ -0,0 +1 @@ + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.spec.ts new file mode 100644 index 0000000000..6d2263d935 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.spec.ts @@ -0,0 +1,29 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubeConfigTableImportStatusComponent } from './kube-config-table-import-status.component'; + +describe('KubeConfigTableImportStatusComponent', () => { + let component: KubeConfigTableImportStatusComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [KubeConfigTableImportStatusComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigTableImportStatusComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.ts new file mode 100644 index 0000000000..d9d8da3697 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component.ts @@ -0,0 +1,29 @@ +import { Component, Input } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { + IActionMonitorComponentState, +} from '../../../../../../core/src/shared/components/app-action-monitor-icon/app-action-monitor-icon.component'; +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubeConfigFileCluster } from '../../kube-config.types'; + +@Component({ + selector: 'app-kube-config-table-import-status', + templateUrl: './kube-config-table-import-status.component.html', + styleUrls: ['./kube-config-table-import-status.component.scss'] +}) +export class KubeConfigTableImportStatusComponent extends TableCellCustom { + + public state: Observable; + + constructor() { + super(); + } + + @Input() + set config(element) { + if (!this.state) { + this.state = element(this.row); + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.html new file mode 100644 index 0000000000..6611b3a683 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.html @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.spec.ts new file mode 100644 index 0000000000..bfcf2b0024 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.spec.ts @@ -0,0 +1,35 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../kubernetes.testing.module'; +import { KubeConfigImportComponent } from './kube-config-import/kube-config-import.component'; +import { KubeConfigRegistrationComponent } from './kube-config-registration.component'; +import { KubeConfigSelectionComponent } from './kube-config-selection/kube-config-selection.component'; + +describe('KubeConfigRegistrationComponent', () => { + let component: KubeConfigRegistrationComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [ + KubeConfigRegistrationComponent, + KubeConfigSelectionComponent, + KubeConfigImportComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigRegistrationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.ts new file mode 100644 index 0000000000..1708ec7897 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-registration.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-kube-config-registration', + templateUrl: './kube-config-registration.component.html', + styleUrls: ['./kube-config-registration.component.scss'] +}) +export class KubeConfigRegistrationComponent { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.html new file mode 100644 index 0000000000..77ce84fc8a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.html @@ -0,0 +1,17 @@ +
+
+ insert_drive_file +

Select a Kube Config file to import clusters

+
+ + +
+
+
+ + +
+ +
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.scss new file mode 100644 index 0000000000..df0fad3cd0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.scss @@ -0,0 +1,35 @@ +:host { + display: flex; + flex: 1; +} + +.kube-config-select { + &__panel { + align-items: center; + display: flex; + flex: 1; + justify-content: center; + } + &__upload { + text-align: center; + } + &__title { + font-size: 24px; + text-align: center; + } + &__icon { + font-size: 96px; + height: 96px; + opacity: .7; + width: 96px; + } + &__table { + flex: 1; + } + &__buttons { + padding-bottom: 12px; + button { + margin-right: 24px; + } + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.spec.ts new file mode 100644 index 0000000000..cc646004a4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.spec.ts @@ -0,0 +1,29 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubeConfigSelectionComponent } from './kube-config-selection.component'; + +describe('KubeConfigSelectionComponent', () => { + let component: KubeConfigSelectionComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [KubeConfigSelectionComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigSelectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.ts new file mode 100644 index 0000000000..9f3655ce82 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-selection.component.ts @@ -0,0 +1,211 @@ +import { Component, Input } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { BehaviorSubject, combineLatest, Observable, of as observableOf, of } from 'rxjs'; +import { first, map, switchMap } from 'rxjs/operators'; + +import { + ITableListDataSource, + RowState, +} from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { + TableHeaderSelectComponent, +} from '../../../../../core/src/shared/components/list/list-table/table-header-select/table-header-select.component'; +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { SnackBarService } from '../../../../../core/src/shared/services/snackbar.service'; +import { AppState } from '../../../../../store/src/public-api'; +import { KubeConfigHelper } from '../kube-config.helper'; +import { KubeConfigFileCluster } from '../kube-config.types'; +import { KubeConfigTableCertComponent } from './kube-config-table-cert/kube-config-table-cert.component'; +import { KubeConfigTableNameComponent } from './kube-config-table-name/kube-config-table-name.component'; +import { KubeConfigTableSelectComponent } from './kube-config-table-select/kube-config-table-select.component'; +import { + KubeConfigTableSubTypeSelectComponent, +} from './kube-config-table-sub-type-select/kube-config-table-sub-type-select.component'; +import { KubeConfigTableUserSelectComponent } from './kube-config-table-user-select/kube-config-table-user-select.component'; + +export interface KubeConfigTableListDataSource extends ITableListDataSource { + editRowName: string; +} + +@Component({ + selector: 'app-kube-config-selection', + templateUrl: './kube-config-selection.component.html', + styleUrls: ['./kube-config-selection.component.scss'], + providers: [ + KubeConfigHelper + ], +}) +export class KubeConfigSelectionComponent { + + @Input() applyStarted: boolean; + public dataSource: KubeConfigTableListDataSource = { + connect: () => this.helper.clusters$, + disconnect: () => { }, + trackBy: (index, row) => row.name, + isTableLoading$: observableOf(false), + getRowState: (row: KubeConfigFileCluster, schemaKey: string): Observable => { + return row ? row._state.asObservable() : observableOf({}); + }, + selectAllIndeterminate: false, + selectAllChecked: false, + selectAllFilteredRows: () => { + // Should always go to true from indeterminate + this.dataSource.selectAllChecked = this.dataSource.selectAllIndeterminate ? true : !this.dataSource.selectAllChecked; + this.dataSource.selectAllIndeterminate = false; // either all off or all on, cannot be indeterminate + + this.helper.clusters$.pipe( + first(), + switchMap(clusters => combineLatest(clusters.map(cluster => { + if (!cluster._invalid) { + cluster._selected = this.dataSource.selectAllChecked; + return this.helper.checkValidity(cluster).pipe(map(() => cluster)); + } + return of(cluster); + }))), + first(), + ).subscribe(clusters => { + this.checkCanGoNext(clusters); + }); + }, + editRow: null, + editRowName: null, + startEdit: (c: KubeConfigFileCluster) => { + this.dataSource.editRow = c; + }, + saveEdit: () => { + this.dataSource.editRow.name = this.dataSource.editRowName; + this.helper.update(this.dataSource.editRow); + delete this.dataSource.editRowName; + delete this.dataSource.editRow; + }, + cancelEdit: () => { + delete this.dataSource.editRowName; + delete this.dataSource.editRow; + }, + getRowUniqueId: (c: KubeConfigFileCluster) => c ? c._id : null + }; + + public columns: ITableColumn[] = [ + { + columnId: 'select', + headerCellComponent: TableHeaderSelectComponent, + cellComponent: KubeConfigTableSelectComponent, + class: 'table-column-select', + cellFlex: '0 0 48px' + }, + { + columnId: 'name', headerCell: () => 'Name', + cellComponent: KubeConfigTableNameComponent, + cellFlex: '3', + class: 'app-table__cell--table-no-v-padding' + }, + { + columnId: 'url', headerCell: () => 'URL', + cellDefinition: { + valuePath: 'cluster.server' + }, + cellFlex: '4', + }, + { + columnId: 'type', headerCell: () => 'Type', + cellFlex: '1', + cellComponent: KubeConfigTableSubTypeSelectComponent + }, + { + columnId: 'user', headerCell: () => 'User', + cellFlex: '4', + cellComponent: KubeConfigTableUserSelectComponent + }, + { + columnId: 'cert', headerCell: () => 'Skip SSL Validation', + cellFlex: '0 0 62px', + class: 'app-table__cell--table-centred', + cellComponent: KubeConfigTableCertComponent + } + ]; + + // Is the import data valid? + valid = new BehaviorSubject(false); + valid$ = this.valid.asObservable(); + + canSetIntermediate = false; + + constructor( + private store: Store, + public helper: KubeConfigHelper, + private snackbarService: SnackBarService + ) { + this.helper.clustersChanged = () => this.clustersChanged(); + } + + // Save data for the next step to know the list of clusters to import + onNext = () => this.helper.clusters$.pipe( + first(), + map(clusters => ({ + success: true, + data: clusters + })) + ); + + clustersParse(cluster: string) { + this.snackbarService.hide(); + this.helper.parse(cluster).pipe(first()).subscribe(errorString => { + if (errorString) { + this.snackbarService.show(`Failed to load Kube Config: ${errorString}`, 'Close'); + } + }); + } + + onEnter = () => { + if (!this.applyStarted) { + return; + } + // Handle back from review step (ensure newly registered endpoints are taken into account) + this.helper.updateAll().pipe(first()).subscribe(() => { }); + }; + + // Row changed event - update the next button and selection state + clustersChanged() { + this.helper.clusters$.pipe( + first() + ).subscribe(clusters => { + this.checkCanGoNext(clusters); + + // Check the select all state + let selectedCount = 0; + let totalCount = 0; + clusters.forEach(i => { + if (!i._invalid) { + totalCount++; + selectedCount += i._selected ? 1 : 0; + } + }); + + if (selectedCount === 0 || totalCount === selectedCount) { + this.dataSource.selectAllIndeterminate = false; + this.dataSource.selectAllChecked = (selectedCount !== 0); + } else { + this.dataSource.selectAllIndeterminate = true; + } + }); + + } + + // Can we proceed? + checkCanGoNext(clusters: KubeConfigFileCluster[]) { + let selected = 0; + let okay = 0; + clusters.forEach(i => { + if (i._selected) { + selected++; + if (!i._invalid) { + okay++; + } + } + }); + + // Must be at least one selected and they all must be okay to import + this.valid.next(selected > 0 && selected === okay); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.html new file mode 100644 index 0000000000..5d0a8d6faf --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.spec.ts new file mode 100644 index 0000000000..0b1bdaf8b8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubeConfigHelper } from '../../kube-config.helper'; +import { KubeConfigTableCertComponent } from './kube-config-table-cert.component'; + +describe('KubeConfigTableCertComponent', () => { + let component: KubeConfigTableCertComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [KubeConfigTableCertComponent], + providers: [ + KubeConfigHelper + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigTableCertComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.ts new file mode 100644 index 0000000000..31f71b63fc --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component.ts @@ -0,0 +1,73 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Component, Input } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { timeout } from 'rxjs/operators'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubeConfigHelper } from '../../kube-config.helper'; +import { KubeConfigFileCluster } from '../../kube-config.types'; + +type CertResponse = { + Status: number; + Required: boolean; + Error: boolean; + Message: string; +}; + +@Component({ + selector: 'app-kube-config-table-cert', + templateUrl: './kube-config-table-cert.component.html', + styleUrls: ['./kube-config-table-cert.component.scss'] +}) +export class KubeConfigTableCertComponent extends TableCellCustom { + + initialValue = new BehaviorSubject<{ + checked: boolean; + }>(null); + initialValue$ = this.initialValue.asObservable(); + + private pRow: KubeConfigFileCluster; + @Input() + set row(row: KubeConfigFileCluster) { + if (!this.pRow) { + this.pRow = row; + if (row.cluster['insecure-skip-tls-verify']) { + // User has manually specified default skip option + this.initialValue.next({ + checked: true + }); + } else { + // Manually check if a cert is required, if so tick by default + this.http.get(`/pp/v1/kube/cert?url=${row.cluster.server}`).pipe( + timeout(5000), + ).subscribe( + // Success, no cert required + (res: CertResponse) => this.update(res.Required), + // Failed, check for specific cert required error + (e: HttpErrorResponse) => this.update(false) + ); + } + } + } + get row(): KubeConfigFileCluster { + return this.pRow; + } + + constructor( + private helper: KubeConfigHelper, + private http: HttpClient + ) { + super(); + } + + private update(checked: boolean) { + this.initialValue.next({ checked }); + this.valueChanged(checked); + } + + valueChanged(value) { + this.row.cluster['insecure-skip-tls-verify'] = value; + this.helper.update(this.row); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.html new file mode 100644 index 0000000000..39cdc6abec --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.html @@ -0,0 +1,9 @@ +
+ + + + {{row.name}} + +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.scss new file mode 100644 index 0000000000..1d85cadb70 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.scss @@ -0,0 +1,12 @@ +.name { + align-items: center; + display: flex; + + .cell-edit-variable { + flex: 1; + } + + app-table-cell-edit { + margin-bottom: 3px; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.spec.ts new file mode 100644 index 0000000000..4f08f762fd --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.spec.ts @@ -0,0 +1,36 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { + IListDataSource, +} from '../../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubeConfigFileCluster } from '../../kube-config.types'; +import { KubeConfigTableNameComponent } from './kube-config-table-name.component'; + +describe('KubeConfigTableName', () => { + let component: KubeConfigTableNameComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [KubeConfigTableNameComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigTableNameComponent); + component = fixture.componentInstance; + component.dataSource = { + getRowUniqueId: (row) => '' + } as IListDataSource; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.ts new file mode 100644 index 0000000000..adaa97e293 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubeConfigFileCluster } from '../../kube-config.types'; + +@Component({ + selector: 'app-kube-config-table-name', + templateUrl: './kube-config-table-name.component.html', + styleUrls: ['./kube-config-table-name.component.scss'] +}) +export class KubeConfigTableNameComponent extends TableCellCustom { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.html new file mode 100644 index 0000000000..944326f9e6 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.spec.ts new file mode 100644 index 0000000000..3bf72f8139 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.spec.ts @@ -0,0 +1,35 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubeConfigHelper } from '../../kube-config.helper'; +import { KubeConfigFileCluster } from '../../kube-config.types'; +import { KubeConfigTableSelectComponent } from './kube-config-table-select.component'; + +describe('KubeConfigTableSelectComponent', () => { + let component: KubeConfigTableSelectComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [KubeConfigTableSelectComponent], + providers: [ + KubeConfigHelper + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigTableSelectComponent); + component = fixture.componentInstance; + component.row = {} as KubeConfigFileCluster; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.ts new file mode 100644 index 0000000000..6c7eb5abad --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubeConfigHelper } from '../../kube-config.helper'; +import { KubeConfigFileCluster } from '../../kube-config.types'; + +@Component({ + selector: 'app-kube-config-table-select', + templateUrl: './kube-config-table-select.component.html', + styleUrls: ['./kube-config-table-select.component.scss'] +}) +export class KubeConfigTableSelectComponent extends TableCellCustom { + + constructor(private helper: KubeConfigHelper) { + super(); + } + changed(v) { + this.row._selected = v.checked; + this.helper.update(this.row); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.html new file mode 100644 index 0000000000..92a8d0e90e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.html @@ -0,0 +1,3 @@ + + {{ type.name }} + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.spec.ts new file mode 100644 index 0000000000..c2ce574749 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.spec.ts @@ -0,0 +1,35 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubeConfigHelper } from '../../kube-config.helper'; +import { KubeConfigFileCluster } from '../../kube-config.types'; +import { KubeConfigTableSubTypeSelectComponent } from './kube-config-table-sub-type-select.component'; + +describe('KubeConfigTableSubTypeSelectComponent', () => { + let component: KubeConfigTableSubTypeSelectComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [KubeConfigTableSubTypeSelectComponent], + providers: [ + KubeConfigHelper + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigTableSubTypeSelectComponent); + component = fixture.componentInstance; + component.row = {} as KubeConfigFileCluster; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.ts new file mode 100644 index 0000000000..4069c9c684 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component.ts @@ -0,0 +1,33 @@ +import { Component, OnInit } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubeConfigAuthHelper } from '../../kube-config-auth.helper'; +import { KubeConfigHelper } from '../../kube-config.helper'; +import { KubeConfigFileCluster } from '../../kube-config.types'; + +@Component({ + selector: 'app-kube-config-table-sub-type-select', + templateUrl: './kube-config-table-sub-type-select.component.html', + styleUrls: ['./kube-config-table-sub-type-select.component.scss'] +}) +export class KubeConfigTableSubTypeSelectComponent extends TableCellCustom implements OnInit { + + selected: string; + + subTypes: string[]; + + constructor(private helper: KubeConfigHelper) { + super(); + + this.subTypes = new KubeConfigAuthHelper().subTypes; + } + + ngOnInit() { + this.selected = this.row._subType || ''; + } + + valueChanged(value) { + this.row._subType = value; + this.helper.update(this.row); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.html new file mode 100644 index 0000000000..1c1c629a8a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.html @@ -0,0 +1,9 @@ +
+ + Register Only + {{ user.name }} + +
+
+ No user found, register only +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.spec.ts new file mode 100644 index 0000000000..3ada12009f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.spec.ts @@ -0,0 +1,39 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubeConfigHelper } from '../../kube-config.helper'; +import { KubeConfigFileCluster } from '../../kube-config.types'; +import { KubeConfigTableUserSelectComponent } from './kube-config-table-user-select.component'; + +describe('KubeConfigTableUserSelectComponent', () => { + let component: KubeConfigTableUserSelectComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [ + KubeConfigTableUserSelectComponent, + ], + providers: [ + KubeConfigHelper + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConfigTableUserSelectComponent); + component = fixture.componentInstance; + component.row = { + _users: [] + } as KubeConfigFileCluster; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.ts new file mode 100644 index 0000000000..4582640704 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubeConfigHelper } from '../../kube-config.helper'; +import { KubeConfigFileCluster } from '../../kube-config.types'; + +@Component({ + selector: 'app-kube-config-table-user-select', + templateUrl: './kube-config-table-user-select.component.html', + styleUrls: ['./kube-config-table-user-select.component.scss'] +}) +export class KubeConfigTableUserSelectComponent extends TableCellCustom implements OnInit { + + hasUser = false; + selected: string; + + constructor(private helper: KubeConfigHelper) { + super(); + } + + ngOnInit() { + this.selected = this.row._user || ''; + this.hasUser = this.row._users.length > 0; + } + + valueChanged(value) { + this.row._user = value; + this.helper.update(this.row); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.helper.ts new file mode 100644 index 0000000000..91daa1a4c8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.helper.ts @@ -0,0 +1,201 @@ +import { Injectable } from '@angular/core'; +import * as yaml from 'js-yaml'; +import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; +import { filter, first, map, tap } from 'rxjs/operators'; + +import { EndpointsService } from '../../../../core/src/core/endpoints.service'; +import { createGuid } from '../../../../core/src/core/utils.service'; +import { RowState } from '../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { getFullEndpointApiUrl } from '../../../../store/src/endpoint-utils'; +import { EndpointModel } from '../../../../store/src/public-api'; +import { KubeConfigAuthHelper } from './kube-config-auth.helper'; +import { KubeConfigFile, KubeConfigFileCluster } from './kube-config.types'; + +/** + * Helper to parse the kubeconfig and transform it into data + * that we can display in a table for selection + * + * Main issue is we only support one credential per endpoint, so need to format the data + * to offer the user ability to select which user to import + */ +@Injectable() +export class KubeConfigHelper { + + authHelper = new KubeConfigAuthHelper(); + + clusters = new BehaviorSubject(null); + clusters$ = this.clusters.asObservable().pipe( + filter(clusters => !!clusters) + ); + + constructor( + public endpointsService: EndpointsService, + ) { + } + + public clustersChanged: () => void; + public update = (cluster: KubeConfigFileCluster) => { + this.checkValidity(cluster).subscribe(() => this.clustersChanged()); + }; + + public updateAll(): Observable { + return this.clusters$.pipe( + tap(clusters => clusters.forEach(cluster => this.update(cluster))), + ); + } + + public parse(config: string): Observable { + let doc: KubeConfigFile; + + const clusters: { [name: string]: KubeConfigFileCluster, } = {}; + + try { + doc = yaml.safeLoad(config); + } catch (e) { + return of(`${e}`); + } + + // Need contexts, users and clusters + if (!doc || !doc.contexts || !doc.users || !doc.clusters) { + return of(`Configuration must have contexts, users and clusters`); + } + + // Go through all of the contexts and find the clusters + doc.contexts.forEach(ctx => { + const cluster = doc.clusters.find(item => item.name === ctx.context.cluster); + if (cluster) { + // Found the cluster + if (!clusters[cluster.name]) { + const clstr = { + ...cluster, + _users: [] + }; + clusters[cluster.name] = clstr; + clstr._state = new BehaviorSubject({}); + } + + // Get the user + const user = doc.users.find(item => item.name === ctx.context.user); + if (user) { + // Check we don't already have this user (remove duplicates) + const users = clusters[cluster.name]._users; + if (users.findIndex(usr => usr.name === user.name) === -1) { + clusters[cluster.name]._users.push(user); + if (ctx.name === doc['current-context']) { + // Auto-select this cluster/user if it is the current context + clusters[cluster.name]._user = user.name; + clusters[cluster.name]._selected = true; + } + } + } + } + }); + + // Go through all clusters, auto-select the user where this is only 1 and check validity + const clustersArray = Object.values(clusters); + clustersArray.forEach(cluster => { + if (cluster._users.length >= 1) { + cluster._user = cluster._users[0].name; + } + cluster._id = createGuid(); + }); + + // Check validity + return combineLatest( + clustersArray.map(cluster => this.checkValidity(cluster)) + ).pipe( + map(() => { + // Notify cluster changes + this.clustersChanged(); + this.clusters.next(Object.values(clusters)); + return ''; + }) + ); + } + + + // Check the validity of a cluster for import + public checkValidity(cluster: KubeConfigFileCluster): Observable { + // Check endpoint name + return combineLatest([ + this.endpointsService.endpoints$, + this.clusters.asObservable() // Might be called before we've loaded clusters, so used the non-filtered one + ]).pipe( + first(), + map(([eps, clusters]) => this.validate(Object.values(eps), cluster, clusters)) + ); + } + + private validate(endpoints: EndpointModel[], cluster: KubeConfigFileCluster, clusters: KubeConfigFileCluster[]) { + cluster._invalid = false; + let reset = true; + + const found = endpoints.find(item => item.name === cluster.name); + if (found) { + // If the URL is the same, then we will just connect to the existing endpoint + if (getFullEndpointApiUrl(found) === cluster.cluster.server && !!cluster._user) { + cluster._guid = found.guid; + cluster._state.next({ + message: 'This endpoint will be connected and not registered (endpoint is already registered)', + info: true + }); + reset = false; + } else { + // An endpoint with the same name (but different URL) already exists + cluster._invalid = true; + cluster._state.next({ message: 'An endpoint with this name already exists', warning: true }); + } + } else { + // Check endpoint url is not registered with a different name + if (endpoints.find(item => getFullEndpointApiUrl(item) === cluster.cluster.server)) { + cluster._invalid = true; + cluster._state.next({ message: 'An endpoint with this URL already exists', warning: true }); + } + } + + // Check the connection details + if (!cluster._invalid && cluster._user) { + const user = cluster._users.find(item => item.name === cluster._user); + if (user) { + const newState = this.authHelper.parseAuth(cluster, user); + if (!!newState && !!newState.message) { + reset = false; + cluster._invalid = newState.error || newState.warning; + cluster._state.next(newState); + } + } + } + + // Register only (_additionalUserInfo.. specific to text warning) is true + // Connect only (endpoint exists) is true + // Show special warning + if (cluster._additionalUserInfo && cluster._guid) { + cluster._invalid = true; + reset = true; + cluster._state.next({ + message: 'This endpoint will not be registered or connected (endpoint is already registered, additional information required to connect)', + warning: true + }); + } + + if (clusters && !!clusters.find(candidate => candidate.name === cluster.name && candidate._id !== cluster._id)) { + cluster._invalid = true; + cluster._state.next({ message: 'An endpoint with this name already exists in the config file', warning: true }); + } + + if (!cluster.name) { + cluster._invalid = true; + cluster._state.next({ message: 'Cluster must have name', warning: true }); + } + + // Cluster is valid, so clear any warning or error message + if (!cluster._invalid && reset) { + cluster._state.next({}); + } + + // Ensure invalid rows aren't selected (user cannot unselect invalid rows) + if (cluster._invalid) { + cluster._selected = false; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.types.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.types.ts new file mode 100644 index 0000000000..bf38e74e03 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-config-registration/kube-config.types.ts @@ -0,0 +1,104 @@ +import { Observable, Subject } from 'rxjs'; + +import { + IActionMonitorComponentState, +} from '../../../../core/src/shared/components/app-action-monitor-icon/app-action-monitor-icon.component'; +import { RowState } from '../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { EndpointAuthTypeConfig } from '../../../../store/src/extension-types'; +import { ActionStatus } from './../../../../store/src/reducers/api-request-reducer/types'; + +// Types for a Kubernetes Configuration file + +export interface KubeConfigFileCluster { + name: string; + cluster: { + 'certificate-authority': string; + 'certificate-authority-data': string; + 'insecure-skip-tls-verify': boolean; + server: string; + }; + // Selected user to import + _user: string; + _users: KubeConfigFileUser[]; + // _onUpdate: (row) => {}; + // Is the cluster selected for import? + _selected: boolean; + // Is this cluster invalid? i.e. requires more information + _invalid: boolean; + // row state + _state: Subject; + // status of import + _status: string; + // guid of the existing endpoint for this cluster + _guid: string; + // subtype + _subType?: string; + // additional info is required in order to connect, hints at register only, though is specific due to warning message + _additionalUserInfo: boolean; + // unique identifier + _id: string; +} + +export interface KubeConfigFileUser { + name: string; + user: KubeConfigFileUserDetail; + _authData: KubeConfigImportAuthConfig; +} + +export interface KubeConfigFileUserDetail { + 'client-certificate'?: string; + 'client-key'?: string; + 'client-certificate-data'?: string; + 'client-key-data'?: string; + token?: string; + exec?: any; + username?: string; + password?: string; +} + +export interface KubeConfigFileContext { + name: string; + context: { + cluster: string; + user: string; + }; +} + +export interface KubeConfigFile { + apiVersion: string; + clusters: KubeConfigFileCluster[]; + contexts: KubeConfigFileContext[]; + 'current-context': string; + kind: string; + users: KubeConfigFileUser[]; +} + +export interface KubeConfigImportAction { + action: string; + description: string; + cluster: KubeConfigFileCluster; + user?: KubeConfigFileUser; + status?: ActionStatus; + state: Subject; + actionState$?: Observable; + actionState: Subject; + depends?: KubeConfigImportAction; +} + +export interface KubeImportState { + busy: boolean; + error: boolean; + completed: boolean; + message: string; +} + +export interface EndpointConfig { + type: string; + authTypes: EndpointAuthTypeConfig[]; +} + +export interface KubeConfigImportAuthConfig { + subType: string; + authType: string; + values: { [key: string]: string, }; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.html new file mode 100644 index 0000000000..905abb2f62 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.html @@ -0,0 +1,18 @@ + +

Kubernetes Terminal

+
+ + + + + +
+
+ + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.spec.ts new file mode 100644 index 0000000000..a4dd9b2411 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.spec.ts @@ -0,0 +1,43 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { createBasicStoreModule } from '@stratosui/store/testing'; + +import { ApplicationService } from '../../../../cloud-foundry/src/features/applications/application.service'; +import { ApplicationServiceMock } from '../../../../cloud-foundry/test-framework/application-service-helper'; +import { CurrentUserPermissionsService } from '../../../../core/src/core/permissions/current-user-permissions.service'; +import { CoreModule, SharedModule } from '../../../../core/src/public-api'; +import { TabNavService } from '../../../../core/src/tab-nav.service'; +import { KubeConsoleComponent } from './kube-console.component'; + +describe('KubeConsoleComponent', () => { + let component: KubeConsoleComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubeConsoleComponent], + imports: [ + CoreModule, + SharedModule, + RouterTestingModule, + createBasicStoreModule() + ], + providers: [ + { provide: ApplicationService, useClass: ApplicationServiceMock }, + TabNavService, + CurrentUserPermissionsService + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeConsoleComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.ts new file mode 100644 index 0000000000..514546b5a3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kube-terminal/kube-console.component.ts @@ -0,0 +1,95 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { NEVER, Observable, Subject } from 'rxjs'; +import websocketConnect, { normalClosureMessage } from 'rxjs-websockets'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; + +import { IHeaderBreadcrumb } from '../../../../core/src/shared/components/page-header/page-header.types'; +import { SshViewerComponent } from '../../../../core/src/shared/components/ssh-viewer/ssh-viewer.component'; +import { BaseKubeGuid } from '../kubernetes-page.types'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { KubernetesService } from '../services/kubernetes.service'; + + +@Component({ + selector: 'app-kube-console', + templateUrl: './kube-console.component.html', + styleUrls: ['./kube-console.component.scss'], + providers: [ + { + provide: BaseKubeGuid, + useFactory: (activatedRoute: ActivatedRoute) => { + return { + guid: activatedRoute.snapshot.params.endpointId + }; + }, + deps: [ + ActivatedRoute + ] + }, + KubernetesService, + KubernetesEndpointService, + ] +}) +export class KubeConsoleComponent implements OnInit { + + public messages: Observable; + + public connectionStatus = new Subject(); + + public sshInput: Subject; + + public errorMessage: string; + + public connected: boolean; + + public kubeSummaryLink: string; + + public breadcrumbs$: Observable; + + @ViewChild('sshViewer', { static: false }) sshViewer: SshViewerComponent; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + ) { } + + ngOnInit() { + this.connectionStatus.next(0); + const guid = this.kubeEndpointService.baseKube.guid; + this.kubeSummaryLink = `/kubernetes/${guid}/summary`; + + if (!guid) { + this.messages = NEVER; + this.connectionStatus.next(0); + this.errorMessage = 'No Endpoint ID available'; + } else { + const host = window.location.host; + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const streamUrl = ( + `${protocol}://${host}/pp/v1/kubeterminal/${guid}` + ); + this.sshInput = new Subject(); + const connection = websocketConnect(streamUrl); + + this.messages = connection.pipe( + tap(() => this.connectionStatus.next(1)), + switchMap(getResponse => getResponse(this.sshInput)), + catchError((e: Error) => { + if (e.message !== normalClosureMessage && !this.sshViewer.isConnected) { + this.errorMessage = 'Error launching Kubernetes Terminal'; + } + return []; + })); + + // Breadcrumbs + this.breadcrumbs$ = this.kubeEndpointService.endpoint$.pipe( + map(endpoint => ([{ + breadcrumbs: [ + { value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}` }, + ] + }]) + ) + ); + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.html new file mode 100644 index 0000000000..815e444b0c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.html @@ -0,0 +1,148 @@ + +

Dashboard Configuration

+
+ +

Stratos can proxy and embed the Kubernetes Dashboard UI when configured. This requires:

+
    +
  1. Kubernetes Dashboard Installation - An installation of the Dashboard in the Kubernetes cluster
  2. +
  3. Service Account - A Service Account that can be used to access the Dashboard
  4. +
+ +
+ +
Retrieving Dashboard configuration ...
+
+ +
+
+
+
+ +
Kubernetes Dashboard is installed and a Service Account exists - you can access the dashboard using the + 'View Dashboard' button on the Summary view for this cluster.
+
+ +
+ + + + + Kubernetes Dashboard Installation +
+ + + warning +
+
+ + +

No installation found

+

Manually install the Kubernetes Dashboard

+
+

Manually install the Kubernetes Dashboard into your cluster. Please refer to + https://github.com/kubernetes/dashboard#getting-started. +

+
+ +

Install the Kubernetes Dashboard from Stratos

+
+

Stratos will download the Dashboard yaml from the Dashboard GitHub repository and install it for you.

+
+ +
+
+
+ + +

An installation of Kubernetes Dashboard was found:

+ +
+ +
+
+
+ + + + + Service Account +
+ + + warning +
+
+ + +
+

+ warningYou are using the Azure deployed version of Kubernetes Dashboard. +

+

This version uses the Service Account 'kubernetes-dashboard' to access the Dashboard UI. You will need to + create a Cluster Role Binding as described + here to give this account + permissions. +

+

You also need to add the following label to the Service Account in order for Stratos to know the account is + configured:

+
stratos-role: kubernetes-dashboard-user
+

You can add this label with the following command:

+
kubectl label serviceaccount -n kube-system kubernetes-dashboard + stratos-role=kubernetes-dashboard-user
+
+
+

Service Account not found - you can create one manually or Stratos can do this for you.

+

Manually create a Service Account

+
+

Create a Service Account with the appropriate permissions that will be used by the Dashboard to + access your Kubernetes cluster.

+

Ensure that this token is labelled with the label:

+
stratos-role: kubernetes-dashboard-user
+
+

Create a Service Account from Stratos

+
+
+

Stratos can create a service account for accessing the Kubernetes Dashboard - please install the + Dashboard first to enable this capability.

+
+
+

Stratos will create a service account with a cluster role binding to the cluster-admin role. This user + will + have full permissions over the cluster.

+

+ warningPlease make sure you understand the risks involved +

+
+ +
+
+
+
+
+ + +

A Service Account was found:

+ +
+
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.scss new file mode 100644 index 0000000000..8e187e2358 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.scss @@ -0,0 +1,69 @@ +.kubedash { + &__header { + margin: 0; + } + &__list { + line-height: 1.5em; + } + &__card { + margin-bottom: 20px; + } + &__card-link { + color: inherit; + } + &__card-icon { + position: absolute; + right: 20px; + top: 20px; + } + &__ready { + > div { + flex: 1; + } + display: flex; + margin: 20px 0; + } + &__access { + display: flex; + > :first-child { + margin-right: 8px; + } + } + &__loading { + display: flex; + margin: 10px 0; + + > mat-spinner { + margin-right: 8px; + } + } + &__option-block { + margin-left: 20px; + } + &__option { + font-size: 14px; + font-weight: normal; + text-decoration: underline; + } + &__label { + font-family: Source Code Pro; + font-size: 12px; + } + &__metadata { + margin-left: 20px; + } + &__buttons { + display: flex; + justify-content: flex-end; + width: 100%; + } + &__warn { + align-items: center; + display: flex; + + > mat-icon { + margin-right: 8px; + } + + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.spec.ts new file mode 100644 index 0000000000..caff198d41 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.spec.ts @@ -0,0 +1,46 @@ +import { HttpClient, HttpHandler } from '@angular/common/http'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { TabNavService } from '../../../../../core/src/tab-nav.service'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubedashConfigurationComponent } from './kubedash-configuration.component'; + +describe('KubedashConfigurationComponent', () => { + let component: KubedashConfigurationComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [...KubernetesBaseTestModules], + declarations: [KubedashConfigurationComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + endpointId: 'anything' + }, + queryParams: {} + } + } + }, + TabNavService, + HttpClient, + HttpHandler, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubedashConfigurationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.ts new file mode 100644 index 0000000000..8510847ea2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component.ts @@ -0,0 +1,233 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, OnDestroy } from '@angular/core'; +import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material/snack-bar'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; + +import { ConfirmationDialogConfig } from '../../../../../core/src/shared/components/confirmation-dialog.config'; +import { ConfirmationDialogService } from '../../../../../core/src/shared/components/confirmation-dialog.service'; +import { IHeaderBreadcrumb } from '../../../../../core/src/shared/components/page-header/page-header.types'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesService } from '../../services/kubernetes.service'; +import { KubeDashboardStatus } from '../../store/kubernetes.effects'; + +type MessageUpdater = (msg: string) => void; + +@Component({ + selector: 'app-kubedash-configuration', + templateUrl: './kubedash-configuration.component.html', + styleUrls: ['./kubedash-configuration.component.scss'], + providers: [ + { + provide: BaseKubeGuid, + useFactory: (activatedRoute: ActivatedRoute) => { + return { + guid: activatedRoute.snapshot.params.endpointId + }; + }, + deps: [ + ActivatedRoute + ] + }, + KubernetesService, + KubernetesEndpointService, + ] +}) +export class KubedashConfigurationComponent implements OnDestroy { + + // Confirmation dialog + deleteServiceAccountConfirmation = new ConfirmationDialogConfig( + 'Delete Service Account?', + 'Are you sure you want to delete the Service Account and Cluster Role Binding?', + 'Delete' + ); + + createServiceAccountConfirmation = new ConfirmationDialogConfig( + 'Create Service Account?', + 'Are you sure you want to create the Service Account and Cluster Role Binding?', + 'Create' + ); + + installDashboardConfirmation = new ConfirmationDialogConfig( + 'Install Kubernetes Dashboard?', + 'Are you sure you want to install the Kubernetes Dashboard into this cluster?', + 'Install' + ); + + deleteDashboardConfirmation = new ConfirmationDialogConfig( + 'Delete Kubernetes Dashboard?', + 'Are you sure you want to delete the Kubernetes Dashboard from this cluster?' + + 'This will delete the dashboard namespace and cluster service account and role binding', + 'Delete' + ); + + public breadcrumbs$: Observable; + + public kubeDashboardStatus$: Observable; + + private snackBarRef: MatSnackBarRef; + + public serviceAccountBusy$ = new BehaviorSubject(false); + public serviceAccountMsg = ''; + + public dashboardUIBusy$ = new BehaviorSubject(false); + public dashboardUIMsg = ''; + + // Are we busy with an operation - disable buttons if we are + public isBusy$ = new BehaviorSubject(false); + + // Is the status loading + public isUpdatingStatus = false; + + private sub: Subscription; + + public isAzure$: Observable; + + public dashboardLink: string; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + private httpClient: HttpClient, + private confirmDialog: ConfirmationDialogService, + private snackBar: MatSnackBar, + ) { + this.kubeDashboardStatus$ = kubeEndpointService.kubeDashboardStatus$; + // Clear the updating status when we get back new dashboard status + this.sub = this.kubeDashboardStatus$.pipe(distinctUntilChanged()).subscribe(status => { + if (status !== null) { + this.isUpdatingStatus = false; + } + }); + + this.dashboardLink = `/kubernetes/${kubeEndpointService.kubeGuid}/dashboard`; + + this.isAzure$ = this.kubeDashboardStatus$.pipe( + filter(status => status !== null), + filter(status => !!status.version), + map(status => status.version.indexOf('azure') !== -1) + ); + + this.breadcrumbs$ = kubeEndpointService.endpoint$.pipe( + map(endpoint => ([{ + breadcrumbs: [{ value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}` }] + }])) + ); + } + + ngOnDestroy() { + if (this.snackBarRef) { + this.snackBarRef.dismiss(); + } + if (this.sub) { + this.sub.unsubscribe(); + } + } + + public createServiceAccount() { + this.confirmDialog.open(this.createServiceAccountConfirmation, () => { + this.doCreateServiceAccount(); + }); + } + + public doCreateServiceAccount() { + this.makeRequest('post', + 'serviceAccount', + 'Creating Service Account ...', + 'Service Account created', 'An error occurred creating the Service Account', + this.serviceAccountBusy$, + (msg) => this.serviceAccountMsg = msg + ); + } + + public deleteServiceAccount() { + this.confirmDialog.open(this.deleteServiceAccountConfirmation, () => { + this.doDeleteServiceAccount(); + }); + } + + public doDeleteServiceAccount() { + this.makeRequest('delete', 'serviceAccount', + 'Deleting Service Account ...', + 'Service Account deleted', + 'An error occurred deleting the Service Account', this.serviceAccountBusy$, + (msg => this.serviceAccountMsg = msg)); + } + + public installDashboard() { + this.confirmDialog.open(this.installDashboardConfirmation, () => { + this.doInstallDashboard(); + }); + } + + public doInstallDashboard() { + this.makeRequest('post', + 'installation', + 'Installing Kubernetes Dashboard ...', + 'Kubernetes Dashboard installed', 'An error occurred installing the Kubernetes Dashboard', + this.dashboardUIBusy$, + (msg) => this.dashboardUIMsg = msg + ); + } + + public deleteDashboard() { + this.confirmDialog.open(this.deleteDashboardConfirmation, () => { + this.doDeleteDashboard(); + }); + } + + public doDeleteDashboard() { + this.makeRequest('delete', + 'installation', + 'Deleting Kubernetes Dashboard ...', + 'Kubernetes Dashboard deleted', 'An error occurred deleting the Kubernetes Dashboard', + this.dashboardUIBusy$, + (msg) => this.dashboardUIMsg = msg + ); + } + + private makeRequest( + method: string, + op: string, + busyMsg: string, + okMsg: string, + errorMsg: string, + busy: BehaviorSubject, + msgUpdater: MessageUpdater) { + const guid = this.kubeEndpointService.kubeGuid; + const url = `/pp/v1/kubedash/${guid}/${op}`; + let obs; + msgUpdater(busyMsg); + busy.next(true); + this.isBusy$.next(true); + if (method === 'post') { + obs = this.httpClient.post(url, {}); + } else if (method === 'delete') { + obs = this.httpClient.delete(url, {}); + } else { + console.error('Unsupported http method'); + return; + } + + obs.subscribe(() => { + this.snackBar.open(okMsg, 'Dismiss', { duration: 3000 }); + busy.next(false); + this.refresh(); + }, (e) => { + let msg = errorMsg; + if (e && e.error && e.error.error) { + msg = e.error.error; + } + this.snackBarRef = this.snackBar.open(msg, 'Dismiss'); + busy.next(false); + this.refresh(); + }); + } + + private refresh() { + this.isUpdatingStatus = true; + this.kubeEndpointService.refreshKubernetesDashboardStatus(); + this.isBusy$.next(false); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.html new file mode 100644 index 0000000000..bee441e958 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.html @@ -0,0 +1,22 @@ + +

Dashboard

+
+ + + + +
+
+ + + + + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.scss new file mode 100644 index 0000000000..83bde95157 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.scss @@ -0,0 +1,21 @@ +.kube-dashboard { + border: 0; + display: flex; + height: 100%; + width: 100%; +} + +.kube-dashboard__hidden { + visibility: hidden; +} + +.kube-dashboard__search { + display: inline; + font-size: 16px; +} + +.kube-dashoard__error { + align-items: center; + display: flex; + flex-direction: column; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.spec.ts new file mode 100644 index 0000000000..25fc305d97 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.spec.ts @@ -0,0 +1,43 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { TabNavService } from '../../../../core/src/tab-nav.service'; +import { KubernetesBaseTestModules } from '../kubernetes.testing.module'; +import { KubernetesDashboardTabComponent } from './kubernetes-dashboard.component'; + +describe('KubernetesDashboardTabComponent', () => { + let component: KubernetesDashboardTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesDashboardTabComponent], + imports: [...KubernetesBaseTestModules], + providers: [ + TabNavService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + endpointId: 'anything' + }, + queryParams: {} + } + } + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesDashboardTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.ts new file mode 100644 index 0000000000..92047d7909 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-dashboard/kubernetes-dashboard.component.ts @@ -0,0 +1,215 @@ +import { Component, ElementRef, OnInit, Renderer2, ViewChild } from '@angular/core'; +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { + EndpointMissingMessageParts, +} from '../../../../core/src/shared/components/endpoints-missing/endpoints-missing.component'; +import { IHeaderBreadcrumb } from '../../../../core/src/shared/components/page-header/page-header.types'; +import { BaseKubeGuid } from '../kubernetes-page.types'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { KubernetesService } from '../services/kubernetes.service'; + +@Component({ + selector: 'app-kubernetes-dashboard', + templateUrl: './kubernetes-dashboard.component.html', + styleUrls: ['./kubernetes-dashboard.component.scss'], + providers: [ + { + provide: BaseKubeGuid, + useFactory: (activatedRoute: ActivatedRoute) => { + return { + guid: activatedRoute.snapshot.params.endpointId + }; + }, + deps: [ + ActivatedRoute + ] + }, + KubernetesService, + KubernetesEndpointService, + ] +}) +export class KubernetesDashboardTabComponent implements OnInit { + + private pKubeDash: ElementRef; + @ViewChild('kubeDash', { read: ElementRef, static: false }) set kubeDash(kubeDash: ElementRef) { + if (!this.pKubeDash) { + this.pKubeDash = kubeDash; + // Need to look at this process again. In tests this is never hit, leading to null references to kubeDash + this.setupEventListener(); + } + } + get kubeDash(): ElementRef { + return this.pKubeDash; + } + + source: SafeResourceUrl; + href = ''; + isLoading$ = new BehaviorSubject(true); + hasError$ = new BehaviorSubject(false); + expanded = true; + + private loadCheckTries = 0; + private haveSetupEventLister = false; + private hasIframeLoaded = false; + public breadcrumbs$: Observable; + + public errorMsg$ = new BehaviorSubject({} as EndpointMissingMessageParts); + + constructor(public kubeEndpointService: KubernetesEndpointService, private sanitizer: DomSanitizer, public renderer: Renderer2) { + this.hasError$.next(false); + } + + ngOnInit() { + const guid = this.kubeEndpointService.baseKube.guid; + let href = window.location.href; + const index = href.indexOf('dashboard'); + href = href.substr(index + 9); + this.href = href; + this.source = this.sanitizer.bypassSecurityTrustResourceUrl(`/pp/v1/kubedash/${guid}/login`); + this.breadcrumbs$ = this.kubeEndpointService.endpoint$.pipe( + map(endpoint => ([{ + breadcrumbs: [ + { value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}` }, + ] + }]) + ) + ); + } + + public configUrl(): string { + const guid = this.kubeEndpointService.baseKube.guid; + return `/kubernetes/${guid}/dashboard-config`; + } + + iframeLoaded() { + if (!this.pKubeDash) { + return; + } + this.loadCheckTries = 20; + this.checkPageLoad(); + this.hasIframeLoaded = true; + this.setupEventListener(); + } + + checkPageLoad() { + let hasLoaded = false; + const errMsg = this.getStratosError(); + if (!!errMsg) { + hasLoaded = true; + this.errorMsg$.next({ + firstLine: errMsg, + secondLine: { text: '' } + }); + this.hasError$.next(true); + } + + const kdToolbar = this.getKubeDashToolbar(); + if (!!kdToolbar) { + hasLoaded = true; + } + if (this.getKubeDashLogin()) { + hasLoaded = true; + } + + if (!hasLoaded) { + this.loadCheckTries--; + if (this.loadCheckTries > 0) { + setTimeout(() => this.checkPageLoad(), 350); + } else { + hasLoaded = true; + } + } + + if (hasLoaded) { + this.isLoading$.next(false); + this.toggle(true); + } + + } + + setupEventListener() { + if (this.haveSetupEventLister || !this.pKubeDash || !this.hasIframeLoaded) { + return; + } + + this.haveSetupEventLister = true; + const iframeWindow = this.pKubeDash.nativeElement.contentWindow; + iframeWindow.addEventListener('hashchange', () => { + if (this.href) { + let h2 = decodeURI(this.href); + h2 = decodeURI(h2); + + h2 = h2.replace('%3F', '?'); + h2 = h2.replace('%3D', '='); + h2 = '#!' + h2; + iframeWindow.location.hash = h2; + this.href = ''; + } + }); + } + + // toggle visibility of the kube dashboard header bar + toggle(val: boolean) { + if (val !== undefined) { + this.expanded = val; + } else { + this.expanded = !this.expanded; + } + + const height = this.expanded ? '48px' : '0px'; + const kdToolbar = this.getKubeDashToolbar(); + if (!!kdToolbar) { + this.renderer.setStyle(kdToolbar, 'height', height); + this.renderer.setStyle(kdToolbar, 'minHeight', height); + } + } + + // Can we detect the dashboard's toolbar (implies dashboard UI has loaded) + private getKubeDashToolbar() { + if (this.pKubeDash && + this.pKubeDash.nativeElement && + this.pKubeDash.nativeElement.contentDocument && + this.pKubeDash.nativeElement.contentDocument.getElementsByTagName) { + const kdChrome = this.pKubeDash.nativeElement.contentDocument.getElementsByTagName('kd-chrome')[0]; + if (kdChrome) { + const kdToolbar = kdChrome.getElementsByTagName('mat-toolbar')[0]; + if (kdToolbar) { + return kdToolbar; + } + const mdToolbar = kdChrome.getElementsByTagName('md-toolbar')[0]; + return mdToolbar; + } + } + return null; + } + + // Can we detect the dashboard login page? + private getKubeDashLogin(): boolean { + if (this.pKubeDash && + this.pKubeDash.nativeElement && + this.pKubeDash.nativeElement.contentDocument && + this.pKubeDash.nativeElement.contentDocument.getElementsByTagName) { + const kdLogin = this.pKubeDash.nativeElement.contentDocument.getElementsByTagName('kd-login'); + return kdLogin.length === 1; + } + return false; + } + + // Can we detect a Stratos error message page? + private getStratosError(): string { + if (this.pKubeDash && + this.pKubeDash.nativeElement && + this.pKubeDash.nativeElement.contentDocument && + this.pKubeDash.nativeElement.contentDocument.getElementsByTagName) { + const stratosError = this.pKubeDash.nativeElement.contentDocument.getElementsByTagName('stratos-error'); + if (stratosError.length === 1) { + return stratosError[0].innerText; + } + } + return null; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-catalog.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-catalog.ts new file mode 100644 index 0000000000..f90866eebe --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-catalog.ts @@ -0,0 +1,46 @@ +import { + StratosCatalogEndpointEntity, + StratosCatalogEntity, +} from '../../../store/src/entity-catalog/entity-catalog-entity/entity-catalog-entity'; +import { IFavoriteMetadata } from '../../../store/src/types/user-favorites.types'; +import { + AnalysisReportsActionBuilders, + KubeDashboardActionBuilders, + KubeDeploymentActionBuilders, + KubeNamespaceActionBuilders, + KubeNodeActionBuilders, + KubePodActionBuilders, + KubeServiceActionBuilders, + KubeStatefulSetsActionBuilders, +} from './store/action-builders/kube.action-builders'; +import { + AnalysisReport, + KubernetesDeployment, + KubernetesNamespace, + KubernetesNode, + KubernetesPod, + KubernetesStatefulSet, + KubeService, +} from './store/kube.types'; + +/** + * A strongly typed collection of Kube Catalog Entities. + * This can be used to access functionality exposed by each specific type, such as get, update, delete, etc + */ +export class KubeEntityCatalog { + public endpoint: StratosCatalogEndpointEntity; + public statefulSet: StratosCatalogEntity; + public pod: StratosCatalogEntity; + public deployment: StratosCatalogEntity; + public node: StratosCatalogEntity; + public namespace: StratosCatalogEntity; + public service: StratosCatalogEntity; + public dashboard: StratosCatalogEntity; + public analysisReport: StratosCatalogEntity; +} + +/** + * A strongly typed collection of Kube Catalog Entities. + * This can be used to access functionality exposed by each specific type, such as get, update, delete, etc + */ +export const kubeEntityCatalog: KubeEntityCatalog = new KubeEntityCatalog(); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-factory.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-factory.ts new file mode 100644 index 0000000000..3b82c7d712 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-factory.ts @@ -0,0 +1,127 @@ +import { Schema, schema } from 'normalizr'; + +import { getAPIResourceGuid } from '../../../cloud-foundry/src/store/selectors/api.selectors'; +import { EntitySchema } from '../../../store/src/helpers/entity-schema'; +import { metricEntityType } from '../../../store/src/helpers/stratos-entity-factory'; +import { + getGuidFromKubeDashboardObj, + getGuidFromKubeDeploymentObj, + getGuidFromKubeNamespaceObj, + getGuidFromKubeNodeObj, + getGuidFromKubePodObj, + getGuidFromKubeServiceObj, + getGuidFromKubeStatefulSetObj, +} from './store/kube.getIds'; +import { KubernetesApp } from './store/kube.types'; + +export const kubernetesEntityType = 'kubernetesInfo'; +export const kubernetesNodesEntityType = 'kubernetesNode'; +export const kubernetesPodsEntityType = 'kubernetesPod'; +export const kubernetesNamespacesEntityType = 'kubernetesNamespace'; +export const kubernetesServicesEntityType = 'kubernetesService'; +export const kubernetesStatefulSetsEntityType = 'kubernetesStatefulSet'; +export const kubernetesDeploymentsEntityType = 'kubernetesDeployment'; +export const kubernetesDashboardEntityType = 'kubernetesDashboard'; +export const analysisReportEntityType = 'analysisReport'; + +export const getKubeAppId = (object: KubernetesApp) => object.name; + +export const KUBERNETES_ENDPOINT_TYPE = 'k8s'; + +const entityCache: { + [key: string]: EntitySchema; +} = {}; + +export class KubernetesEntitySchema extends EntitySchema { + /** + * @param entityKey As per schema.Entity ctor + * @param [definition] As per schema.Entity ctor + * @param [options] As per schema.Entity ctor + * @param [relationKey] Allows multiple children of the same type within a single parent entity. For instance user with developer + * spaces, manager spaces, auditor space, etc + */ + constructor( + entityKey: string, + definition?: Schema, + options?: schema.EntityOptions, + relationKey?: string + ) { + super(entityKey, KUBERNETES_ENDPOINT_TYPE, definition, options, relationKey); + } +} + + +entityCache[kubernetesEntityType] = new KubernetesEntitySchema( + kubernetesEntityType, + {}, + { idAttribute: getAPIResourceGuid } +); + +entityCache[kubernetesStatefulSetsEntityType] = new KubernetesEntitySchema( + kubernetesStatefulSetsEntityType, + {}, + { + idAttribute: getGuidFromKubeStatefulSetObj + } +); + +entityCache[kubernetesPodsEntityType] = new KubernetesEntitySchema( + kubernetesPodsEntityType, + {}, + { + idAttribute: getGuidFromKubePodObj + } +); + +entityCache[kubernetesDeploymentsEntityType] = new KubernetesEntitySchema( + kubernetesDeploymentsEntityType, + {}, + { + idAttribute: getGuidFromKubeDeploymentObj + } +); + +entityCache[kubernetesNodesEntityType] = new KubernetesEntitySchema( + kubernetesNodesEntityType, + {}, + { idAttribute: getGuidFromKubeNodeObj } +); + +entityCache[kubernetesNamespacesEntityType] = new KubernetesEntitySchema( + kubernetesNamespacesEntityType, + {}, + { idAttribute: getGuidFromKubeNamespaceObj } +); + +entityCache[kubernetesServicesEntityType] = new KubernetesEntitySchema( + kubernetesServicesEntityType, + {}, + { idAttribute: getGuidFromKubeServiceObj } +); + +entityCache[kubernetesDashboardEntityType] = new KubernetesEntitySchema( + kubernetesDashboardEntityType, + {}, + { idAttribute: getGuidFromKubeDashboardObj } +); + +// Analysis Reports - should not be bound to an endpoint +entityCache[analysisReportEntityType] = new KubernetesEntitySchema( + analysisReportEntityType, + {}, + { idAttribute: 'id' } +); + +entityCache[metricEntityType] = new KubernetesEntitySchema(metricEntityType); + +export function addKubernetesEntitySchema(key: string, newSchema: EntitySchema) { + entityCache[key] = newSchema; +} + +export function kubernetesEntityFactory(key: string): EntitySchema { + const entity = entityCache[key]; + if (!entity) { + throw new Error(`Unknown entity schema type: ${key}`); + } + return entity; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-generator.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-generator.ts new file mode 100644 index 0000000000..7b7d77a960 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-entity-generator.ts @@ -0,0 +1,317 @@ +import { Validators } from '@angular/forms'; + +import { BaseEndpointAuth } from '../../../core/src/core/endpoint-auth'; +import { + StratosBaseCatalogEntity, + StratosCatalogEndpointEntity, + StratosCatalogEntity, +} from '../../../store/src/entity-catalog/entity-catalog-entity/entity-catalog-entity'; +import { + IStratosEntityDefinition, + StratosEndpointExtensionDefinition, +} from '../../../store/src/entity-catalog/entity-catalog.types'; +import { EndpointAuthTypeConfig, EndpointType } from '../../../store/src/extension-types'; +import { metricEntityType } from '../../../store/src/helpers/stratos-entity-factory'; +import { IFavoriteMetadata } from '../../../store/src/types/user-favorites.types'; +import { KubernetesAWSAuthFormComponent } from './auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component'; +import { + KubernetesCertsAuthFormComponent, +} from './auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component'; +import { + KubernetesConfigAuthFormComponent, +} from './auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component'; +import { KubernetesGKEAuthFormComponent } from './auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component'; +import { KubeConfigRegistrationComponent } from './kube-config-registration/kube-config-registration.component'; +import { kubeEntityCatalog } from './kubernetes-entity-catalog'; +import { + analysisReportEntityType, + KUBERNETES_ENDPOINT_TYPE, + kubernetesDashboardEntityType, + kubernetesDeploymentsEntityType, + kubernetesEntityFactory, + kubernetesNamespacesEntityType, + kubernetesNodesEntityType, + kubernetesPodsEntityType, + kubernetesServicesEntityType, + kubernetesStatefulSetsEntityType, +} from './kubernetes-entity-factory'; +import { + AnalysisReportsActionBuilders, + analysisReportsActionBuilders, + KubeDashboardActionBuilders, + kubeDashboardActionBuilders, + KubeDeploymentActionBuilders, + kubeDeploymentActionBuilders, + KubeNamespaceActionBuilders, + kubeNamespaceActionBuilders, + KubeNodeActionBuilders, + kubeNodeActionBuilders, + KubePodActionBuilders, + kubePodActionBuilders, + KubeServiceActionBuilders, + kubeServiceActionBuilders, + KubeStatefulSetsActionBuilders, + kubeStatefulSetsActionBuilders, +} from './store/action-builders/kube.action-builders'; +import { + KubernetesDeployment, + KubernetesNamespace, + KubernetesNode, + KubernetesPod, + KubernetesStatefulSet, + KubeService, +} from './store/kube.types'; +import { generateWorkloadsEntities } from './workloads/store/workloads-entity-generator'; + +const enum KubeEndpointAuthTypes { + CERT_AUTH = 'kube-cert-auth', + CONFIG = 'kubeconfig', + CONFIG_AZ = 'kubeconfig-az', + AWS_IAM = 'aws-iam', + GKE = 'gke-auth', +} + +const kubeAuthTypeMap: { [type: string]: EndpointAuthTypeConfig, } = { + [KubeEndpointAuthTypes.CERT_AUTH]: { + value: KubeEndpointAuthTypes.CERT_AUTH, + name: 'Kubernetes Cert Auth', + form: { + cert: ['', Validators.required], + certKey: ['', Validators.required], + }, + types: new Array(), + component: KubernetesCertsAuthFormComponent + }, + [KubeEndpointAuthTypes.CONFIG]: { + value: KubeEndpointAuthTypes.CONFIG, + name: 'Kube Config', + form: { + kubeconfig: ['', Validators.required], + }, + types: new Array(), + component: KubernetesConfigAuthFormComponent + }, + [KubeEndpointAuthTypes.CONFIG_AZ]: { + value: KubeEndpointAuthTypes.CONFIG_AZ, + name: 'Azure AKS', + form: { + kubeconfig: ['', Validators.required], + }, + types: new Array(), + component: KubernetesConfigAuthFormComponent + }, + [KubeEndpointAuthTypes.AWS_IAM]: { + value: KubeEndpointAuthTypes.AWS_IAM, + name: 'AWS IAM (EKS)', + form: { + cluster: ['', Validators.required], + access_key: ['', Validators.required], + secret_key: ['', Validators.required], + }, + types: new Array(), + component: KubernetesAWSAuthFormComponent + }, + [KubeEndpointAuthTypes.GKE]: { + value: KubeEndpointAuthTypes.GKE, + name: 'GKE', + form: { + gkeconfig: ['', Validators.required], + }, + types: new Array(), + component: KubernetesGKEAuthFormComponent, + help: '/core/assets/custom/help/en/connecting_gke.md' + } +}; + +export function generateKubernetesEntities(): StratosBaseCatalogEntity[] { + const endpointDefinition: StratosEndpointExtensionDefinition = { + type: KUBERNETES_ENDPOINT_TYPE, + label: 'Kubernetes', + labelPlural: 'Kubernetes', + icon: 'kubernetes', + iconFont: 'stratos-icons', + logoUrl: '/core/assets/custom/kubernetes.svg', + urlValidation: undefined, + authTypes: [ + kubeAuthTypeMap[KubeEndpointAuthTypes.CERT_AUTH], + kubeAuthTypeMap[KubeEndpointAuthTypes.CONFIG], + BaseEndpointAuth.UsernamePassword + ], + renderPriority: 4, + subTypes: [ + { + type: 'config', + label: 'Import Kubeconfig', + authTypes: [kubeAuthTypeMap[KubeEndpointAuthTypes.CONFIG]], + logoUrl: '/core/assets/custom/kube_import.png', + renderPriority: 3, + registrationComponent: KubeConfigRegistrationComponent, + }, + { + type: 'caasp', + label: 'SUSE CaaS Platform', + labelShort: 'CaaSP', + authTypes: [kubeAuthTypeMap[KubeEndpointAuthTypes.CONFIG]], + logoUrl: '/core/assets/custom/caasp.png', + renderPriority: 5, + }, { + type: 'aks', + label: 'Azure AKS', + labelShort: 'AKS', + authTypes: [kubeAuthTypeMap[KubeEndpointAuthTypes.CONFIG_AZ]], + logoUrl: '/core/assets/custom/aks.svg', + renderPriority: 6 + }, { + type: 'eks', + label: 'Amazon EKS', + labelShort: 'EKS', + authTypes: [kubeAuthTypeMap[KubeEndpointAuthTypes.AWS_IAM]], + logoUrl: '/core/assets/custom/eks.svg', + renderPriority: 6 + }, { + type: 'gke', + label: 'Google Kubernetes Engine', + labelShort: 'GKE', + authTypes: [kubeAuthTypeMap[KubeEndpointAuthTypes.GKE]], + logoUrl: '/core/assets/custom/gke.svg', + renderPriority: 6 + }, { + type: 'k3s', + label: 'K3S', + labelShort: 'K3S', + authTypes: [BaseEndpointAuth.UsernamePassword], + logoUrl: '/core/assets/custom/k3s.svg', + renderPriority: 6 + }] + }; + return [ + generateEndpointEntity(endpointDefinition), + generateStatefulSetsEntity(endpointDefinition), + generatePodsEntity(endpointDefinition), + generateDeploymentsEntity(endpointDefinition), + generateNodesEntity(endpointDefinition), + generateNamespacesEntity(endpointDefinition), + generateServicesEntity(endpointDefinition), + generateDashboardEntity(endpointDefinition), + generateAnalysisReportsEntity(endpointDefinition), + generateMetricEntity(endpointDefinition), + ...generateWorkloadsEntities(endpointDefinition) + ]; +} + +function generateEndpointEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + kubeEntityCatalog.endpoint = new StratosCatalogEndpointEntity( + endpointDefinition, + metadata => `/kubernetes/${metadata.guid}` + ); + return kubeEntityCatalog.endpoint; +} + +function generateStatefulSetsEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition: IStratosEntityDefinition = { + type: kubernetesStatefulSetsEntityType, + schema: kubernetesEntityFactory(kubernetesStatefulSetsEntityType), + endpoint: endpointDefinition + }; + kubeEntityCatalog.statefulSet = new StratosCatalogEntity( + definition, { + actionBuilders: kubeStatefulSetsActionBuilders + }); + return kubeEntityCatalog.statefulSet; +} + +function generatePodsEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition: IStratosEntityDefinition = { + type: kubernetesPodsEntityType, + schema: kubernetesEntityFactory(kubernetesPodsEntityType), + endpoint: endpointDefinition + }; + kubeEntityCatalog.pod = new StratosCatalogEntity(definition, { + actionBuilders: kubePodActionBuilders + }); + return kubeEntityCatalog.pod; +} + +function generateDeploymentsEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition: IStratosEntityDefinition = { + type: kubernetesDeploymentsEntityType, + schema: kubernetesEntityFactory(kubernetesDeploymentsEntityType), + endpoint: endpointDefinition + }; + kubeEntityCatalog.deployment = new StratosCatalogEntity( + definition, { + actionBuilders: kubeDeploymentActionBuilders + }); + return kubeEntityCatalog.deployment; +} + +function generateNodesEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition: IStratosEntityDefinition = { + type: kubernetesNodesEntityType, + schema: kubernetesEntityFactory(kubernetesNodesEntityType), + endpoint: endpointDefinition + }; + kubeEntityCatalog.node = new StratosCatalogEntity(definition, { + actionBuilders: kubeNodeActionBuilders + }); + return kubeEntityCatalog.node; +} + +function generateNamespacesEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition: IStratosEntityDefinition = { + type: kubernetesNamespacesEntityType, + schema: kubernetesEntityFactory(kubernetesNamespacesEntityType), + endpoint: endpointDefinition + }; + kubeEntityCatalog.namespace = new StratosCatalogEntity(definition, { + actionBuilders: kubeNamespaceActionBuilders + }); + return kubeEntityCatalog.namespace; +} + +function generateServicesEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition: IStratosEntityDefinition = { + type: kubernetesServicesEntityType, + schema: kubernetesEntityFactory(kubernetesServicesEntityType), + endpoint: endpointDefinition + }; + kubeEntityCatalog.service = new StratosCatalogEntity(definition, { + actionBuilders: kubeServiceActionBuilders + }); + return kubeEntityCatalog.service; +} + +function generateDashboardEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition: IStratosEntityDefinition = { + type: kubernetesDashboardEntityType, + schema: kubernetesEntityFactory(kubernetesDashboardEntityType), + endpoint: endpointDefinition + }; + kubeEntityCatalog.dashboard = new StratosCatalogEntity(definition, { + actionBuilders: kubeDashboardActionBuilders + }); + return kubeEntityCatalog.dashboard; +} + +function generateAnalysisReportsEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition = { + type: analysisReportEntityType, + schema: kubernetesEntityFactory(analysisReportEntityType), + endpoint: endpointDefinition + }; + kubeEntityCatalog.analysisReport = new StratosCatalogEntity(definition, { + actionBuilders: analysisReportsActionBuilders + }); + return kubeEntityCatalog.analysisReport; +} + +function generateMetricEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition: IStratosEntityDefinition = { + type: metricEntityType, + schema: kubernetesEntityFactory(metricEntityType), + label: 'Kubernetes Metric', + labelPlural: 'Kubernetes Metrics', + endpoint: endpointDefinition, + }; + return new StratosCatalogEntity(definition); +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-metrics.helpers.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-metrics.helpers.ts new file mode 100644 index 0000000000..45580cbbdf --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-metrics.helpers.ts @@ -0,0 +1,58 @@ +import moment from 'moment'; + + +export function formatCPUTime(value: string | number, debug = false): string { + + const cpuTimeFormat = { + day: 86400, + hour: 3600, + minute: 60, + second: 1 + }; + const cpuTimeFormatOrder = ['day', 'hour', 'minute', 'second']; + + let num = (typeof value === 'number') ? value : parseFloat(replaceAll(value, ',', '')); + if (isNaN(num)) { + return '-'; + } + + // Duration is in seconds + const result = []; + cpuTimeFormatOrder.forEach(key => { + const v = Math.floor(num / cpuTimeFormat[key]); + num -= v * cpuTimeFormat[key]; + if (v > 0 || result.length > 0) { + result.push(v + key.substr(0, 1)); + } + }); + + if (result.length === 0) { + result.push('0s'); + } + + return result.join(' '); +} + +function replaceAll(str, find, replace) { + return str.replace(new RegExp(find, 'g'), replace); +} + +export function formatAxisCPUTime(value: string) { + const duration = moment.duration(parseFloat(value) * 1000); + if (duration.asDays() >= 1) { + return `${duration.asDays().toPrecision(2)} d`; + } + if (duration.asHours() >= 1) { + return `${duration.asHours().toPrecision(2)} hrs`; + } + if (duration.asMinutes() >= 1) { + return `${duration.asMinutes().toPrecision(2)} min`; + } + if (duration.asSeconds() >= 1) { + return `${duration.asSeconds().toPrecision(2)} sec`; + } + if (duration.asMilliseconds() >= 1) { + return `${duration.asSeconds().toPrecision(2)} msec`; + } + return value; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.html new file mode 100644 index 0000000000..650e5a18aa --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.html @@ -0,0 +1,13 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.spec.ts new file mode 100644 index 0000000000..2e150486ae --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.spec.ts @@ -0,0 +1,46 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MDAppModule } from '../../../../../core/src/public-api'; +import { TabNavService } from '../../../../../core/src/tab-nav.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesNamespaceService } from '../../services/kubernetes-namespace.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { + AnalysisReportSelectorComponent, +} from './../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { AnalysisReportViewerComponent } from './../../analysis-report-viewer/analysis-report-viewer.component'; +import { KubernetesNamespaceAnalysisReportComponent } from './kubernetes-namespace-analysis-report.component'; + +describe('KubernetesNamespaceAnalysisReportComponent', () => { + let component: KubernetesNamespaceAnalysisReportComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNamespaceAnalysisReportComponent, AnalysisReportSelectorComponent, AnalysisReportViewerComponent], + imports: [ + KubernetesBaseTestModules, + MDAppModule + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + KubernetesNamespaceService, + TabNavService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNamespaceAnalysisReportComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.ts new file mode 100644 index 0000000000..cdaa70db35 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.ts @@ -0,0 +1,50 @@ +import { Component } from '@angular/core'; +import { Subject } from 'rxjs'; + +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesNamespaceService } from '../../services/kubernetes-namespace.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { AnalysisReport } from '../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-namespace-analysis-report-tab', + templateUrl: './kubernetes-namespace-analysis-report.component.html', + styleUrls: ['./kubernetes-namespace-analysis-report.component.scss'], + providers: [ + KubernetesAnalysisService + ] +}) +export class KubernetesNamespaceAnalysisReportComponent { + + public report$ = new Subject(); + + path: string; + + currentReport = null; + + endpointID: string; + + noReportsAvailable = false; + + constructor( + public analyzerService: KubernetesAnalysisService, + public endpointService: KubernetesEndpointService, + public kubeNamespaceService: KubernetesNamespaceService, + ) { + this.endpointID = this.endpointService.kubeGuid; + this.path = `${this.kubeNamespaceService.namespaceName}`; + this.report$.next(null); + } + + public analysisChanged(report) { + if (report.id !== this.currentReport) { + this.currentReport = report.id; + this.analyzerService.getByID(this.endpointID, report.id).subscribe(r => this.report$.next(r)); + } + } + + public onReportCount(count: number) { + this.noReportsAvailable = count === 0; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component.html new file mode 100644 index 0000000000..dd2b6cb351 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component.ts new file mode 100644 index 0000000000..a9127d468f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; + +import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { + KubernetesNamespacePodsListConfigService, +} from '../../list-types/kubernetes-namespace-pods/kubernetes-namespace-pods-list-config.service'; + +@Component({ + selector: 'app-kubernetes-namespace-pods', + templateUrl: './kubernetes-namespace-pods.component.html', + styleUrls: ['./kubernetes-namespace-pods.component.scss'], + providers: [{ + provide: ListConfig, + useClass: KubernetesNamespacePodsListConfigService, + }] +}) +export class KubernetesNamespacePodsComponent { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.html new file mode 100644 index 0000000000..dd2b6cb351 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.spec.ts new file mode 100644 index 0000000000..68f5dd3509 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.spec.ts @@ -0,0 +1,37 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../../core/src/tab-nav.service'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesNamespaceService } from '../../services/kubernetes-namespace.service'; +import { KubernetesNamespaceServicesComponent } from './kubernetes-namespace-services.component'; + +describe('KubernetesNamespaceServicesComponent', () => { + let component: KubernetesNamespaceServicesComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNamespaceServicesComponent], + imports: [...KubernetesBaseTestModules], + providers: [ + TabNavService, + BaseKubeGuid, + KubernetesNamespaceService, + KubernetesEndpointService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNamespaceServicesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.ts new file mode 100644 index 0000000000..02a76e577e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; + +import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { + KubernetesNamespaceServicesListConfig, +} from '../../list-types/kubernetes-namespace-services/kubernetes-namespace-services-list-config.service'; + +@Component({ + selector: 'app-kubernetes-namespace-services', + templateUrl: './kubernetes-namespace-services.component.html', + styleUrls: ['./kubernetes-namespace-services.component.scss'], + providers: [{ + provide: ListConfig, + useClass: KubernetesNamespaceServicesListConfig, + }] +}) +export class KubernetesNamespaceServicesComponent { + + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace.component.html new file mode 100644 index 0000000000..8a042faa2a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace.component.html @@ -0,0 +1,6 @@ + +

{{ kubeNamespaceService.namespaceName }}

+
+
+
+ diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace.component.ts new file mode 100644 index 0000000000..0a7eab7a1e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-namespace/kubernetes-namespace.component.ts @@ -0,0 +1,61 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { IHeaderBreadcrumb } from '../../../../core/src/shared/components/page-header/page-header.types'; +import { BaseKubeGuid } from '../kubernetes-page.types'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { KubernetesNamespaceService } from '../services/kubernetes-namespace.service'; +import { KubernetesAnalysisService } from '../services/kubernetes.analysis.service'; +import { KubernetesService } from '../services/kubernetes.service'; + +@Component({ + selector: 'app-kubernetes-namespace', + templateUrl: './kubernetes-namespace.component.html', + styleUrls: ['./kubernetes-namespace.component.scss'], + providers: [ + { + provide: BaseKubeGuid, + useFactory: (activatedRoute: ActivatedRoute) => { + return { + guid: activatedRoute.snapshot.params.endpointId + }; + }, + deps: [ + ActivatedRoute + ] + }, + KubernetesService, + KubernetesEndpointService, + KubernetesNamespaceService, + KubernetesAnalysisService, + ] +}) +export class KubernetesNamespaceComponent { + + tabLinks = []; + + public breadcrumbs$: Observable; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public kubeNamespaceService: KubernetesNamespaceService, + public analysisService: KubernetesAnalysisService, + ) { + this.breadcrumbs$ = kubeEndpointService.endpoint$.pipe( + map(endpoint => ([{ + breadcrumbs: [ + { value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}/namespaces` }, + ] + }]) + ) + ); + + this.tabLinks = [ + { link: 'pods', label: 'Pods', icon: 'pod', iconFont: 'stratos-icons' }, + { link: 'services', label: 'Services', icon: 'service', iconFont: 'stratos-icons' }, + { link: 'analysis', label: 'Analysis', icon: 'assignment', hidden$: this.analysisService.hideAnalysis$ }, + ]; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.html new file mode 100644 index 0000000000..a81d3b07a6 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.html @@ -0,0 +1,19 @@ +
+ + + Summary of {{title}} Usage for Last {{period}} + + + + + + + + +
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.scss new file mode 100644 index 0000000000..6d0f144251 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.scss @@ -0,0 +1,9 @@ +.kubernetes-node-metric-stats-card__metadata { + > * { + display: block; + + &:not(:last-child) { + margin-bottom: 6px; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.spec.ts new file mode 100644 index 0000000000..f9ab58d0a9 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.spec.ts @@ -0,0 +1,37 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesNodeMetricStatsCardComponent } from './kubernetes-node-metric-stats-card.component'; +import { KubernetesNodeSimpleMetricComponent } from '../kubernetes-node-simple-metric/kubernetes-node-simple-metric.component'; +import { KubernetesNodeService } from '../../../services/kubernetes-node.service'; +import { BaseKubeGuid } from '../../../kubernetes-page.types'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; + +describe('KubernetesNodeMetricStatsCardComponent', () => { + let component: KubernetesNodeMetricStatsCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeMetricStatsCardComponent, KubernetesNodeSimpleMetricComponent], + imports: KubernetesBaseTestModules, + providers: [KubernetesNodeService, KubernetesEndpointService, BaseKubeGuid] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeMetricStatsCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + // Ensure we destroy the component and clean up the polling subscription + fixture.destroy(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.ts new file mode 100644 index 0000000000..bfc3ada39c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component.ts @@ -0,0 +1,48 @@ +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Observable, Subscription } from 'rxjs'; + +import { KubeNodeMetric, KubernetesNodeService } from '../../../services/kubernetes-node.service'; +import { MetricStatistic } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-node-metric-stats-card', + templateUrl: './kubernetes-node-metric-stats-card.component.html', + styleUrls: ['./kubernetes-node-metric-stats-card.component.scss'] +}) +export class KubernetesNodeMetricStatsCardComponent implements OnInit, OnDestroy { + + @Input() + title = 'Memory'; + + @Input() + metric: KubeNodeMetric; + + @Input() + period = 'Hour'; + + @Input() + unit: string; + + max$: Observable; + mean$: Observable; + subscriptions: Subscription[] = []; + constructor( + public kubeNodeService: KubernetesNodeService + ) { } + + ngOnInit() { + const maxMetric = this.kubeNodeService.setupMetricObservable(this.metric, MetricStatistic.MAXIMUM); + this.subscriptions.push(maxMetric.pollerSub); + this.max$ = maxMetric.entity$; + + const meanMetric = this.kubeNodeService.setupMetricObservable(this.metric, MetricStatistic.AVERAGE); + this.subscriptions.push(meanMetric.pollerSub); + this.mean$ = meanMetric.entity$; + } + + + ngOnDestroy() { + this.subscriptions.forEach(s => s.unsubscribe()); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.html new file mode 100644 index 0000000000..fc9dce0d55 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.html @@ -0,0 +1,10 @@ +
+ + + + + + + +
+ \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.scss new file mode 100644 index 0000000000..6f307dd37a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.scss @@ -0,0 +1,8 @@ +.kubernetes-node-metrics-chart{ + padding-top: 40px; + width: 100%; + &__content{ + width: 100%; + height: 300px; + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.spec.ts new file mode 100644 index 0000000000..ab800afd4d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesNodeMetricsChartComponent } from './kubernetes-node-metrics-chart.component'; +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; + +describe('KubernetesNodeMetricsChartComponent', () => { + let component: KubernetesNodeMetricsChartComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeMetricsChartComponent], + imports: KubernetesBaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeMetricsChartComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.ts new file mode 100644 index 0000000000..e9c226f588 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component.ts @@ -0,0 +1,58 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { MetricsConfig } from '../../../../../../core/src/shared/components/metrics-chart/metrics-chart.component'; +import { MetricsLineChartConfig } from '../../../../../../core/src/shared/components/metrics-chart/metrics-chart.types'; +import { MetricsChartHelpers } from '../../../../../../core/src/shared/components/metrics-chart/metrics.component.helpers'; +import { IMetricMatrixResult } from '../../../../../../store/src/types/base-metric.types'; +import { IMetricApplication } from '../../../../../../store/src/types/metric.types'; +import { FetchKubernetesMetricsAction } from '../../../store/kubernetes.actions'; + +@Component({ + selector: 'app-kubernetes-node-metrics-chart', + templateUrl: './kubernetes-node-metrics-chart.component.html', + styleUrls: ['./kubernetes-node-metrics-chart.component.scss'] +}) +export class KubernetesNodeMetricsChartComponent implements OnInit { + + @Input() + private nodeName: string; + @Input() + private endpointGuid: string; + @Input() + private yAxisLabel: string; + @Input() + private metricName: string; + @Input() + private seriesTranslation: string; + @Input() + public title: string; + + public instanceChartConfig: MetricsLineChartConfig; + public instanceMetricConfig: MetricsConfig>; + constructor() { } + + ngOnInit() { + this.instanceChartConfig = MetricsChartHelpers.buildChartConfig(this.yAxisLabel); + const query = `${this.metricName}{instance="${this.nodeName}"}[1h]&time=${(new Date()).getTime() / 1000}`; + this.instanceMetricConfig = { + getSeriesName: result => result.metric.name ? result.metric.name : result.metric.id, + mapSeriesItemName: MetricsChartHelpers.getDateSeriesName, + sort: MetricsChartHelpers.sortBySeriesName, + mapSeriesItemValue: this.getmapSeriesItemValue(), + metricsAction: new FetchKubernetesMetricsAction( + this.nodeName, + this.endpointGuid, + query, + ), + }; + } + + private getmapSeriesItemValue() { + switch (this.seriesTranslation) { + case 'mb': + return (bytes) => bytes / 1000000; + default: + return undefined; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.html new file mode 100644 index 0000000000..07776ce427 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.html @@ -0,0 +1,16 @@ +
+ + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.scss new file mode 100644 index 0000000000..71be1de298 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.scss @@ -0,0 +1,6 @@ +.kubernetes-node-metrics { + &__charts { + display: block; + margin-top: 20px; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.spec.ts new file mode 100644 index 0000000000..8cf61f7366 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.spec.ts @@ -0,0 +1,41 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../../services/kubernetes-node.service'; +import { + KubernetesNodeMetricStatsCardComponent, +} from './kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component'; +import { KubernetesNodeMetricsComponent } from './kubernetes-node-metrics.component'; +import { + KubernetesNodeSimpleMetricComponent, +} from './kubernetes-node-simple-metric/kubernetes-node-simple-metric.component'; + +describe('KubernetesNodeMetricsComponent', () => { + let component: KubernetesNodeMetricsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + KubernetesNodeMetricsComponent, + KubernetesNodeMetricStatsCardComponent, + KubernetesNodeSimpleMetricComponent + ], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid, KubernetesEndpointService, KubernetesNodeService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeMetricsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.ts new file mode 100644 index 0000000000..7e2cd38a56 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component.ts @@ -0,0 +1,91 @@ +import { Component, OnInit } from '@angular/core'; + +import { MetricsConfig } from '../../../../../core/src/shared/components/metrics-chart/metrics-chart.component'; +import { MetricsLineChartConfig } from '../../../../../core/src/shared/components/metrics-chart/metrics-chart.types'; +import { + ChartDataTypes, + getMetricsChartConfigBuilder, +} from '../../../../../core/src/shared/components/metrics-chart/metrics.component.helpers'; +import { ChartSeries, IMetricMatrixResult } from '../../../../../store/src/types/base-metric.types'; +import { IMetricApplication } from '../../../../../store/src/types/metric.types'; +import { formatAxisCPUTime, formatCPUTime } from '../../kubernetes-metrics.helpers'; +import { KubeNodeMetric, KubernetesNodeService } from '../../services/kubernetes-node.service'; +import { FetchKubernetesChartMetricsAction } from '../../store/kubernetes.actions'; + +@Component({ + selector: 'app-kubernetes-node-metrics', + templateUrl: './kubernetes-node-metrics.component.html', + styleUrls: ['./kubernetes-node-metrics.component.scss'] +}) +export class KubernetesNodeMetricsComponent implements OnInit { + memoryMetric: KubeNodeMetric; + cpuMetric: KubeNodeMetric; + memoryUnit: string; + cpuUnit: string; + + public instanceMetricConfigs: [ + MetricsConfig>, + MetricsLineChartConfig + ][]; + + constructor( + public kubeNodeService: KubernetesNodeService + ) { + this.memoryMetric = KubeNodeMetric.MEMORY; + this.cpuMetric = KubeNodeMetric.CPU; + } + + ngOnInit() { + const chartConfigBuilder = getMetricsChartConfigBuilder( + result => { + const metric = result.metric; + if (!!metric.pod && !!metric.namespace) { + const containerName = `${metric.namespace}:${metric.pod}:${metric.container}`; + if (!!metric.cpu) { + return `${containerName}:${metric.cpu}`; + } + return containerName; + } + + if (metric.name) { + return metric.name; + } + + return result.metric.id; + + }, + ); + + this.instanceMetricConfigs = [ + chartConfigBuilder( + new FetchKubernetesChartMetricsAction( + this.kubeNodeService.nodeName, + this.kubeNodeService.kubeGuid, + `${KubeNodeMetric.MEMORY}{instance="${this.kubeNodeService.nodeName}"}` + ), + 'Memory Usage (MB)', + ChartDataTypes.BYTES, + (series: ChartSeries[]) => { + return series.filter(s => s.name.indexOf('/') !== 0 && !!s.metadata.container && s.metadata.container !== 'POD'); + }, + null, + (value: string) => value + ' MB' + ), + chartConfigBuilder( + new FetchKubernetesChartMetricsAction( + this.kubeNodeService.nodeName, + this.kubeNodeService.kubeGuid, + `${KubeNodeMetric.CPU}{instance="${this.kubeNodeService.nodeName}"}` + ), + 'CPU Usage (secs)', + ChartDataTypes.CPU_TIME, + (series: ChartSeries[]) => { + return series.filter(s => s.name.indexOf('/') !== 0 && !!s.metadata.container && s.metadata.container !== 'POD'); + }, + (t) => formatAxisCPUTime(t), + (t) => formatCPUTime(t) + ) + ]; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.html new file mode 100644 index 0000000000..b01a16cc12 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.html @@ -0,0 +1,8 @@ +
+
+ {{ key }} +
+
+ {{ formatValue() }} +
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.scss new file mode 100644 index 0000000000..d8df959cd7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.scss @@ -0,0 +1,5 @@ +.kube-node-metric { + display: flex; + flex-direction: row; + justify-content: space-between; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.spec.ts new file mode 100644 index 0000000000..86c56f3d38 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesNodeSimpleMetricComponent } from './kubernetes-node-simple-metric.component'; + +describe('KubernetesNodeSimpleMetricComponent', () => { + let component: KubernetesNodeSimpleMetricComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ KubernetesNodeSimpleMetricComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeSimpleMetricComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.ts new file mode 100644 index 0000000000..38cf007c35 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component.ts @@ -0,0 +1,29 @@ +import { Component, Input } from '@angular/core'; +import { formatCPUTime } from '../../../kubernetes-metrics.helpers'; + +@Component({ + selector: 'app-kubernetes-node-simple-metric', + templateUrl: './kubernetes-node-simple-metric.component.html', + styleUrls: ['./kubernetes-node-simple-metric.component.scss'] +}) +export class KubernetesNodeSimpleMetricComponent { + + @Input() + key: string; + + @Input() + value: number; + + @Input() + unit: string; + + public formatValue() { + switch (this.unit) { + case 'secs': + return formatCPUTime(this.value); + default: + const unit = this.unit || ''; + return `${this.value} ${unit}`; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.html new file mode 100644 index 0000000000..dd2b6cb351 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.spec.ts new file mode 100644 index 0000000000..15b1a83cb8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesNodePodsComponent } from './kubernetes-node-pods.component'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesNodeService } from '../../services/kubernetes-node.service'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; + +describe('KubernetesNodePodsComponent', () => { + let component: KubernetesNodePodsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodePodsComponent], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid, KubernetesEndpointService, KubernetesNodeService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodePodsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.ts new file mode 100644 index 0000000000..5cd5f67945 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; + +import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { + KubernetesNodePodsListConfigService, +} from '../../list-types/kubernetes-node-pods/kubernetes-node-pods-list-config.service'; + +@Component({ + selector: 'app-kubernetes-node-pods', + templateUrl: './kubernetes-node-pods.component.html', + styleUrls: ['./kubernetes-node-pods.component.scss'], + providers: [{ + provide: ListConfig, + useClass: KubernetesNodePodsListConfigService, + }] +}) +export class KubernetesNodePodsComponent { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.html new file mode 100644 index 0000000000..1fe49080bf --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.html @@ -0,0 +1,6 @@ + +

{{ kubeNodeService.nodeName }}

+
+
+
+ diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.spec.ts new file mode 100644 index 0000000000..b0530a0935 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.spec.ts @@ -0,0 +1,44 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { TabNavService } from '../../../../core/src/tab-nav.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../kubernetes.testing.module'; +import { KubernetesNodeComponent } from './kubernetes-node.component'; + +describe('KubernetesNodeComponent', () => { + let component: KubernetesNodeComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeComponent], + imports: KubernetesBaseTestModules, + providers: [ + TabNavService, + KubeBaseGuidMock, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + endpointId: 'anything' + }, + queryParams: {} + } + } + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.ts new file mode 100644 index 0000000000..17b057f286 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-node/kubernetes-node.component.ts @@ -0,0 +1,68 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { first, map, tap } from 'rxjs/operators'; + +import { EndpointsService } from '../../../../core/src/core/endpoints.service'; +import { IHeaderBreadcrumb } from '../../../../core/src/shared/components/page-header/page-header.types'; +import { BaseKubeGuid } from '../kubernetes-page.types'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../services/kubernetes-node.service'; +import { KubernetesService } from '../services/kubernetes.service'; + +@Component({ + selector: 'app-kubernetes-node', + templateUrl: './kubernetes-node.component.html', + styleUrls: ['./kubernetes-node.component.scss'], + providers: [ + { + provide: BaseKubeGuid, + useFactory: (activatedRoute: ActivatedRoute) => { + return { + guid: activatedRoute.snapshot.params.endpointId + }; + }, + deps: [ + ActivatedRoute + ] + }, + KubernetesService, + KubernetesEndpointService, + KubernetesNodeService + ] +}) +export class KubernetesNodeComponent { + + tabLinks = [ + { link: 'summary', label: 'Summary', icon: 'kubernetes', iconFont: 'stratos-icons' }, + { link: 'metrics', label: 'Metrics', icon: 'equalizer' }, + { link: 'pods', label: 'Pods', icon: 'pod', iconFont: 'stratos-icons' }, + ]; + + public breadcrumbs$: Observable; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public kubeNodeService: KubernetesNodeService, + public endpointsService: EndpointsService + ) { + this.endpointsService.hasMetrics(this.kubeEndpointService.kubeGuid).pipe( + first(), + tap(haveMetrics => { + if (!haveMetrics) { + // Remove metrics tab + this.tabLinks = this.tabLinks.filter(tab => tab.link !== 'metrics'); + } + }) + ).subscribe(); + + this.breadcrumbs$ = kubeEndpointService.endpoint$.pipe( + map(endpoint => ([{ + breadcrumbs: [ + { value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}` }, + ] + }]) + ) + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-page.types.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-page.types.ts new file mode 100644 index 0000000000..88d24b610f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-page.types.ts @@ -0,0 +1,7 @@ +import { Injectable } from '@angular/core'; + +@Injectable() +export class BaseKubeGuid { + guid: string; +} + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html new file mode 100644 index 0000000000..c3ebbdd9e1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html @@ -0,0 +1,36 @@ + + +
+
+ {{ resource.apiVersion }} + {{ resource.kind | titlecase }} + +
+
+ {{ resource.creationTimestamp | date:'medium' }} + {{ resource.age }} +
+ + +
+
{{ label.name }}
+
{{ label.value }}
+
+
+ + +
+
{{ label.name }}
+
{{ label.value }}
+
+
+ + + + + + +
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.scss new file mode 100644 index 0000000000..a427bd0214 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.scss @@ -0,0 +1,35 @@ +.resource-preview { + + &__labels { + display: table; + } + + &__label { + display: table-row; + } + + &__label-name { + display: table-cell; + opacity: .8; + padding: 2px 20px 2px 2px; + } + + &__label-value { + display: table-cell; + } + + &__side-by-side { + display: flex; + + app-metadata-item { + margin-right: 40px; + } + + } + + &__metrics { + align-items: center; + display: flex; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts new file mode 100644 index 0000000000..c7d6ae77a4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts @@ -0,0 +1,49 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { SidePanelService } from '../../../../core/src/shared/services/side-panel.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { + ResourceAlertViewComponent, +} from './../analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component'; +import { KubernetesResourceViewerComponent } from './kubernetes-resource-viewer.component'; + +describe('KubernetesResourceViewerComponent', () => { + let component: KubernetesResourceViewerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesResourceViewerComponent, KubernetesResourceViewerComponent, ResourceAlertViewComponent], + imports: KubernetesBaseTestModules, + providers: [ + KubernetesEndpointService, + KubeBaseGuidMock, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + endpointId: 'anything' + }, + queryParams: {} + } + } + }, + SidePanelService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesResourceViewerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts new file mode 100644 index 0000000000..3f868ec9ef --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts @@ -0,0 +1,155 @@ +import { Component } from '@angular/core'; +import moment from 'moment'; +import { Observable, of } from 'rxjs'; +import { filter, first, map, publishReplay, refCount, switchMap } from 'rxjs/operators'; + +import { EndpointsService } from '../../../../core/src/core/endpoints.service'; +import { PreviewableComponent } from '../../../../core/src/shared/previewable-component'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { BasicKubeAPIResource, KubeAPIResource, KubeStatus } from '../store/kube.types'; + +export interface KubernetesResourceViewerConfig { + title: string; + analysis?: any; + resource$: Observable; + resourceKind: string; +} + +interface KubernetesResourceViewerResource { + raw: any; + jsonView: KubeAPIResource; + age: string; + creationTimestamp: string; + labels: { name: string, value: string, }[]; + annotations: { name: string, value: string, }[]; + kind: string; + apiVersion: string; +} + +@Component({ + selector: 'app-kubernetes-resource-viewer', + templateUrl: './kubernetes-resource-viewer.component.html', + styleUrls: ['./kubernetes-resource-viewer.component.scss'] +}) +export class KubernetesResourceViewerComponent implements PreviewableComponent { + + constructor( + private endpointsService: EndpointsService, + private kubeEndpointService: KubernetesEndpointService + ) { + } + + public title: string; + public resource$: Observable; + + public hasPodMetrics$: Observable; + public podRouterLink$: Observable; + + private analysis; + public alerts; + + setProps(props: KubernetesResourceViewerConfig) { + this.title = props.title; + this.analysis = props.analysis; + this.resource$ = props.resource$.pipe( + filter(item => !!item), + map((item: (KubeAPIResource | KubeStatus)) => { + const resource: KubernetesResourceViewerResource = {} as KubernetesResourceViewerResource; + const newItem = {} as any; + + resource.raw = item; + Object.keys(item || []).forEach(k => { + if (k !== 'endpointId' && k !== 'releaseTitle' && k !== 'expandedStatus' && k !== '_metadata') { + newItem[k] = item[k]; + } + }); + + resource.jsonView = newItem; + + /* tslint:disable-next-line:no-string-literal */ + const fallback = item['_metadata'] || {}; + + const ts = item.metadata ? item.metadata.creationTimestamp : fallback.creationTimestamp; + resource.age = moment(ts).fromNow(true); + resource.creationTimestamp = ts; + + if (item.metadata && item.metadata.labels) { + resource.labels = []; + Object.keys(item.metadata.labels || []).forEach(labelName => { + resource.labels.push({ + name: labelName, + value: item.metadata.labels[labelName] + }); + }); + } + + if (item.metadata && item.metadata.annotations) { + resource.annotations = []; + Object.keys(item.metadata.annotations || []).forEach(labelName => { + resource.annotations.push({ + name: labelName, + value: item.metadata.annotations[labelName] + }); + }); + } + + /* tslint:disable-next-line:no-string-literal */ + resource.kind = item['kind'] || fallback.kind || props.resourceKind; + /* tslint:disable-next-line:no-string-literal */ + resource.apiVersion = item['apiVersion'] || fallback.apiVersion || this.getVersionFromSelfLink(item.metadata['selfLink']); + + // Apply analysis if there is one - if this is a k8s resource (i.e. not a container) + if (item.metadata) { + this.applyAnalysis(resource); + } + return resource; + }), + publishReplay(1), + refCount() + ); + + this.hasPodMetrics$ = props.resourceKind === 'pod' ? + this.resource$.pipe( + switchMap(resource => this.endpointsService.hasMetrics(this.getEndpointId(resource.raw))), + first(), + ) : + of(false); + + this.podRouterLink$ = this.hasPodMetrics$.pipe( + filter(hasPodMetrics => hasPodMetrics), + switchMap(() => this.resource$), + map(pod => { + return [ + `/kubernetes`, + this.getEndpointId(pod.raw), + `pods`, + pod.raw.metadata.namespace, + pod.raw.metadata.name + ]; + }) + ); + } + + private getVersionFromSelfLink(url: string): string { + if (!url) { + return; + } + const parts = url.split('/'); + return `${parts[1]}/${parts[2]}`; + } + + private getEndpointId(res): string { + return this.kubeEndpointService.kubeGuid || res.endpointId || res.metadata.kubeId; + } + + private applyAnalysis(resource) { + let id = (resource.kind || 'pod').toLowerCase(); + id = `${id}/${resource.raw.metadata.namespace}/${resource.raw.metadata.name}`; + if (this.analysis && this.analysis.alerts[id]) { + this.alerts = this.analysis.alerts[id]; + } else { + this.alerts = null; + } + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.html new file mode 100644 index 0000000000..ab204d0455 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.html @@ -0,0 +1,8 @@ + +

{{ (kubeEndpointService.endpoint$ | async)?.entity.name }}

+
+
+
+ + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.spec.ts new file mode 100644 index 0000000000..27e21b1be5 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.spec.ts @@ -0,0 +1,43 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { TabNavService } from '../../../../core/src/tab-nav.service'; +import { KubernetesBaseTestModules } from '../kubernetes.testing.module'; +import { KubernetesTabBaseComponent } from './kubernetes-tab-base.component'; + +describe('KubernetesTabBaseComponent', () => { + let component: KubernetesTabBaseComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesTabBaseComponent], + imports: KubernetesBaseTestModules, + providers: [ + TabNavService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + endpointId: 'anything' + }, + queryParams: {} + } + } + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesTabBaseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts new file mode 100644 index 0000000000..c250a2de07 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts @@ -0,0 +1,72 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { first, map, startWith } from 'rxjs/operators'; + +import { FavoritesConfigMapper } from '../../../../store/src/favorite-config-mapper'; +import { UserFavoriteEndpoint } from '../../../../store/src/types/user-favorites.types'; +import { BaseKubeGuid } from '../kubernetes-page.types'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../services/kubernetes.analysis.service'; +import { KubernetesService } from '../services/kubernetes.service'; + +@Component({ + selector: 'app-kubernetes-tab-base', + templateUrl: './kubernetes-tab-base.component.html', + styleUrls: ['./kubernetes-tab-base.component.scss'], + providers: [ + { + provide: BaseKubeGuid, + useFactory: (activatedRoute: ActivatedRoute) => { + return { + guid: activatedRoute.snapshot.params.endpointId + }; + }, + deps: [ + ActivatedRoute + ] + }, + KubernetesService, + KubernetesEndpointService, + KubernetesAnalysisService, + ] +}) +export class KubernetesTabBaseComponent implements OnInit { + + tabLinks = []; + + public isFetching$: Observable; + public favorite$: Observable; + public endpointIds$: Observable; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public favoritesConfigMapper: FavoritesConfigMapper, + public analysisService: KubernetesAnalysisService, + ) { + this.tabLinks = [ + { link: 'summary', label: 'Summary', icon: 'kubernetes', iconFont: 'stratos-icons' }, + { link: 'analysis', label: 'Analysis', icon: 'assignment', hidden$: this.analysisService.hideAnalysis$ }, + { link: '-', label: 'Cluster' }, + { link: 'nodes', label: 'Nodes', icon: 'node', iconFont: 'stratos-icons' }, + { link: 'namespaces', label: 'Namespaces', icon: 'namespace', iconFont: 'stratos-icons' }, + { link: '-', label: 'Resources' }, + { link: 'pods', label: 'Pods', icon: 'pod', iconFont: 'stratos-icons' }, + ]; + } + + ngOnInit() { + this.isFetching$ = this.kubeEndpointService.endpoint$.pipe( + map(endpoint => !endpoint), + startWith(true) + ); + this.favorite$ = this.kubeEndpointService.endpoint$.pipe( + first(), + map(endpoint => this.favoritesConfigMapper.getFavoriteEndpointFromEntity(endpoint.entity)) + ); + this.endpointIds$ = this.kubeEndpointService.endpoint$.pipe( + map(endpoint => [endpoint.entity.guid]) + ); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.module.ts new file mode 100644 index 0000000000..2ee5760928 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.module.ts @@ -0,0 +1,238 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { NgxChartsModule } from '@swimlane/ngx-charts'; + +import { CoreModule } from '../../../core/src/core/core.module'; +import { SharedModule } from '../../../core/src/shared/shared.module'; +import { + AnalysisReportRunnerComponent, +} from './analysis-report-viewer/analysis-report-runner/analysis-report-runner.component'; +import { + AnalysisReportSelectorComponent, +} from './analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { AnalysisReportViewerComponent } from './analysis-report-viewer/analysis-report-viewer.component'; +import { + KubeScoreReportViewerComponent, +} from './analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component'; +import { PopeyeReportViewerComponent } from './analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component'; +import { + ResourceAlertPreviewComponent, +} from './analysis-report-viewer/resource-alert-preview/resource-alert-preview.component'; +import { + ResourceAlertViewComponent, +} from './analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component'; +import { KubeConsoleComponent } from './kube-terminal/kube-console.component'; +import { + KubedashConfigurationComponent, +} from './kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component'; +import { KubernetesDashboardTabComponent } from './kubernetes-dashboard/kubernetes-dashboard.component'; +import { + KubernetesNamespaceAnalysisReportComponent, +} from './kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component'; +import { + KubernetesNamespacePodsComponent, +} from './kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component'; +import { + KubernetesNamespaceServicesComponent, +} from './kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component'; +import { KubernetesNamespaceComponent } from './kubernetes-namespace/kubernetes-namespace.component'; +import { + KubernetesNodeMetricStatsCardComponent, +} from './kubernetes-node/kubernetes-node-metrics/kubernetes-node-metric-stats-card/kubernetes-node-metric-stats-card.component'; +import { + KubernetesNodeMetricsChartComponent, +} from './kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics-chart/kubernetes-node-metrics-chart.component'; +import { KubernetesNodeMetricsComponent } from './kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component'; +import { + KubernetesNodeSimpleMetricComponent, +} from './kubernetes-node/kubernetes-node-metrics/kubernetes-node-simple-metric/kubernetes-node-simple-metric.component'; +import { KubernetesNodePodsComponent } from './kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component'; +import { KubernetesNodeComponent } from './kubernetes-node/kubernetes-node.component'; +import { BaseKubeGuid } from './kubernetes-page.types'; +import { KubernetesResourceViewerComponent } from './kubernetes-resource-viewer/kubernetes-resource-viewer.component'; +import { KubernetesTabBaseComponent } from './kubernetes-tab-base/kubernetes-tab-base.component'; +import { KubernetesRoutingModule } from './kubernetes.routing'; +import { KubernetesComponent } from './kubernetes/kubernetes.component'; +import { AnalysisStatusCellComponent } from './list-types/analysis-status-cell/analysis-status-cell.component'; +import { KubernetesLabelsCellComponent } from './list-types/kubernetes-labels-cell/kubernetes-labels-cell.component'; +import { + KubeNamespacePodCountComponent, +} from './list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component'; +import { + KubernetesNamespaceLinkComponent, +} from './list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component'; +import { ConditionCellComponent } from './list-types/kubernetes-nodes/condition-cell/condition-cell.component'; +import { + KubernetesNodeCapacityComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component'; +import { KubernetesNodeIpsComponent } from './list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component'; +import { + KubernetesNodeLabelsComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component'; +import { + KubernetesNodeLinkComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component'; +import { + KubernetesNodePressureComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component'; +import { + KubernetesNodeConditionCardComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component'; +import { + KubernetesNodeConditionComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component'; +import { + KubernetesNodeInfoCardComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component'; +import { + KubernetesNodeSummaryCardComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component'; +import { + KubernetesNodeSummaryComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component'; +import { + KubernetesNodeTagsCardComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component'; +import { NodePodCountComponent } from './list-types/kubernetes-nodes/node-pod-count/node-pod-count.component'; +import { + KubernetesPodContainersComponent, +} from './list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component'; +import { + KubernetesPodStatusComponent, +} from './list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component'; +import { KubernetesPodTagsComponent } from './list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component'; +import { KubernetesServicePortsComponent } from './list-types/kubernetes-service-ports/kubernetes-service-ports.component'; +import { + KubeServiceCardComponent, +} from './list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component'; +import { PodMetricsComponent } from './pod-metrics/pod-metrics.component'; +import { KubernetesEndpointService } from './services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from './services/kubernetes-node.service'; +import { KubernetesService } from './services/kubernetes.service'; +import { + AnalysisInfoCardComponent, +} from './tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component'; +import { + KubernetesAnalysisInfoComponent, +} from './tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component'; +import { + KubernetesAnalysisReportComponent, +} from './tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component'; +import { KubernetesAnalysisTabComponent } from './tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component'; +import { KubernetesNamespacesTabComponent } from './tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component'; +import { KubernetesNodesTabComponent } from './tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component'; +import { KubernetesPodsTabComponent } from './tabs/kubernetes-pods-tab/kubernetes-pods-tab.component'; +import { KubernetesSummaryTabComponent } from './tabs/kubernetes-summary-tab/kubernetes-summary.component'; + +/* tslint:disable:max-line-length */ +/* tslint:enable */ + +@NgModule({ + imports: [ + CoreModule, + CommonModule, + SharedModule, + NgxChartsModule, + KubernetesRoutingModule, + ], + declarations: [ + KubernetesComponent, + KubernetesNodesTabComponent, + KubernetesTabBaseComponent, + KubernetesNodeCapacityComponent, + KubernetesPodsTabComponent, + KubernetesPodTagsComponent, + KubernetesNamespacesTabComponent, + KubernetesDashboardTabComponent, + KubernetesSummaryTabComponent, + KubernetesAnalysisTabComponent, + PodMetricsComponent, + KubernetesNodeLinkComponent, + KubernetesNodeIpsComponent, + KubernetesNodeLabelsComponent, + KubernetesNodePressureComponent, + KubernetesLabelsCellComponent, + KubernetesNodeComponent, + KubernetesNodeSummaryComponent, + KubernetesNodePodsComponent, + KubernetesNodeSummaryCardComponent, + KubernetesNodeConditionCardComponent, + KubernetesNodeTagsCardComponent, + KubernetesNodePodsComponent, + KubernetesNodeInfoCardComponent, + KubernetesNodeMetricsComponent, + KubernetesNodeConditionComponent, + KubernetesNodeMetricsChartComponent, + KubernetesNodeMetricStatsCardComponent, + KubernetesNodeSimpleMetricComponent, + ConditionCellComponent, + KubernetesNamespaceLinkComponent, + KubernetesNamespaceComponent, + KubernetesNamespacePodsComponent, + KubernetesNamespaceServicesComponent, + KubeNamespacePodCountComponent, + NodePodCountComponent, + KubernetesServicePortsComponent, + KubernetesPodStatusComponent, + KubeConsoleComponent, + KubeServiceCardComponent, + KubernetesResourceViewerComponent, + KubeServiceCardComponent, + KubedashConfigurationComponent, + KubernetesPodContainersComponent, + KubernetesAnalysisReportComponent, + KubernetesAnalysisInfoComponent, + AnalysisInfoCardComponent, + AnalysisReportViewerComponent, + PopeyeReportViewerComponent, + AnalysisReportSelectorComponent, + AnalysisReportRunnerComponent, + ResourceAlertPreviewComponent, + ResourceAlertViewComponent, + KubeScoreReportViewerComponent, + AnalysisStatusCellComponent, + KubernetesNamespaceAnalysisReportComponent, + ], + providers: [ + KubernetesService, + BaseKubeGuid, + KubernetesEndpointService, + KubernetesNodeService + ], + entryComponents: [ + KubernetesNodeCapacityComponent, + KubernetesPodTagsComponent, + KubernetesNodeLinkComponent, + KubernetesNodeIpsComponent, + KubernetesNodeLabelsComponent, + KubernetesNodePressureComponent, + KubernetesLabelsCellComponent, + ConditionCellComponent, + KubernetesNamespaceLinkComponent, + KubeNamespacePodCountComponent, + NodePodCountComponent, + KubernetesServicePortsComponent, + KubernetesPodStatusComponent, + KubeServiceCardComponent, + KubernetesResourceViewerComponent, + KubernetesPodContainersComponent, + PopeyeReportViewerComponent, + KubeScoreReportViewerComponent, + AnalysisReportSelectorComponent, + ResourceAlertPreviewComponent, + AnalysisStatusCellComponent, + ], + exports: [ + KubernetesResourceViewerComponent, + AnalysisReportViewerComponent, + PopeyeReportViewerComponent, + KubeScoreReportViewerComponent, + AnalysisReportSelectorComponent, + AnalysisReportRunnerComponent, + ResourceAlertPreviewComponent, + ResourceAlertViewComponent, + AnalysisStatusCellComponent, + ] +}) +export class KubernetesModule { } + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.routing.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.routing.ts new file mode 100644 index 0000000000..35c15386f8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.routing.ts @@ -0,0 +1,167 @@ +import { KubernetesNamespaceAnalysisReportComponent } from './kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component'; +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { KubernetesDashboardTabComponent } from './kubernetes-dashboard/kubernetes-dashboard.component'; +import { + KubernetesNamespacePodsComponent, +} from './kubernetes-namespace/kubernetes-namespace-pods/kubernetes-namespace-pods.component'; +import { + KubernetesNamespaceServicesComponent, +} from './kubernetes-namespace/kubernetes-namespace-services/kubernetes-namespace-services.component'; +import { KubernetesNamespaceComponent } from './kubernetes-namespace/kubernetes-namespace.component'; +import { KubernetesNodeMetricsComponent } from './kubernetes-node/kubernetes-node-metrics/kubernetes-node-metrics.component'; +import { KubernetesNodePodsComponent } from './kubernetes-node/kubernetes-node-pods/kubernetes-node-pods.component'; +import { KubernetesNodeComponent } from './kubernetes-node/kubernetes-node.component'; +import { KubernetesTabBaseComponent } from './kubernetes-tab-base/kubernetes-tab-base.component'; +import { KubernetesComponent } from './kubernetes/kubernetes.component'; +import { + KubernetesNodeSummaryComponent, +} from './list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component'; +import { PodMetricsComponent } from './pod-metrics/pod-metrics.component'; +import { KubernetesNamespacesTabComponent } from './tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component'; +import { KubernetesNodesTabComponent } from './tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component'; +import { KubernetesPodsTabComponent } from './tabs/kubernetes-pods-tab/kubernetes-pods-tab.component'; +import { KubernetesSummaryTabComponent } from './tabs/kubernetes-summary-tab/kubernetes-summary.component'; +import { KubedashConfigurationComponent } from './kubernetes-dashboard/kubedash-configuration/kubedash-configuration.component'; +import { KubeConsoleComponent } from './kube-terminal/kube-console.component'; +import { KubernetesAnalysisTabComponent } from './tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component'; +import { KubernetesAnalysisReportComponent } from './tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component'; +import { + KubernetesAnalysisInfoComponent +} from './tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component'; + +const kubernetes: Routes = [{ + path: '', + component: KubernetesComponent +}, +{ + path: ':endpointId/nodes/:nodeName/pods/:namespace/:podName', + component: PodMetricsComponent, +}, +{ + path: ':endpointId/pods/:namespace/:podName', + component: PodMetricsComponent, +}, +{ + path: ':endpointId/namespaces/:namespaceName/pods/:podName', + component: PodMetricsComponent +}, +{ + path: ':endpointId/nodes/:nodeName', + component: KubernetesNodeComponent, + children: [ + { + path: '', + redirectTo: 'summary', + pathMatch: 'full' + }, + { + path: 'summary', + component: KubernetesNodeSummaryComponent + }, + { + path: 'pods', + component: KubernetesNodePodsComponent + }, + { + path: 'metrics', + component: KubernetesNodeMetricsComponent + } + ] +}, +{ + path: ':endpointId/namespaces/:namespaceName', + component: KubernetesNamespaceComponent, + children: [ + { + path: '', + redirectTo: 'pods', + pathMatch: 'full' + }, + { + path: 'pods', + component: KubernetesNamespacePodsComponent + }, + { + path: 'services', + component: KubernetesNamespaceServicesComponent + }, + { + path: 'analysis', + component: KubernetesNamespaceAnalysisReportComponent + } + ] +}, +{ + path: ':endpointId', + component: KubernetesTabBaseComponent, + children: [ + { + path: '', + redirectTo: 'summary', + pathMatch: 'full' + }, + { + path: 'summary', + component: KubernetesSummaryTabComponent + }, + { + path: 'nodes', + component: KubernetesNodesTabComponent + }, + { + path: 'namespaces', + component: KubernetesNamespacesTabComponent + }, + { + path: 'pods', + component: KubernetesPodsTabComponent + }, + { + path: 'analysis', + component: KubernetesAnalysisTabComponent + }, + { + path: 'analysis/report/:id', + component: KubernetesAnalysisReportComponent + }, + { + path: 'analysis/info', + component: KubernetesAnalysisInfoComponent + }, + ] +}, +{ + path: ':endpointId/dashboard', + component: KubernetesDashboardTabComponent, + data: { + uiNoMargin: true + }, + children: [ + { + path: '**', + component: KubernetesDashboardTabComponent, + data: { + uiNoMargin: true + } + } + ] +}, +{ + path: ':endpointId/dashboard-config', + component: KubedashConfigurationComponent, +}, +{ + path: ':endpointId/terminal', + component: KubeConsoleComponent, + data: { + uiNoMargin: true + } +} +]; + +@NgModule({ + imports: [RouterModule.forChild(kubernetes)] +}) +export class KubernetesRoutingModule { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.setup.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.setup.module.ts new file mode 100644 index 0000000000..c7afda8eee --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.setup.module.ts @@ -0,0 +1,101 @@ +import { CommonModule } from '@angular/common'; +import { NgModule, Optional, SkipSelf } from '@angular/core'; + +import { CoreModule } from '../../../core/src/core/core.module'; +import { EndpointsService } from '../../../core/src/core/endpoints.service'; +import { SharedModule } from '../../../core/src/shared/shared.module'; +import { EntityCatalogModule } from '../../../store/src/entity-catalog.module'; +import { EndpointHealthCheck } from '../../../store/src/entity-catalog/entity-catalog.types'; +import { KubernetesAWSAuthFormComponent } from './auth-forms/kubernetes-aws-auth-form/kubernetes-aws-auth-form.component'; +import { + KubernetesCertsAuthFormComponent, +} from './auth-forms/kubernetes-certs-auth-form/kubernetes-certs-auth-form.component'; +import { + KubernetesConfigAuthFormComponent, +} from './auth-forms/kubernetes-config-auth-form/kubernetes-config-auth-form.component'; +import { KubernetesGKEAuthFormComponent } from './auth-forms/kubernetes-gke-auth-form/kubernetes-gke-auth-form.component'; +import { KubeConfigImportComponent } from './kube-config-registration/kube-config-import/kube-config-import.component'; +import { + KubeConfigTableImportStatusComponent, +} from './kube-config-registration/kube-config-import/kube-config-table-import-status/kube-config-table-import-status.component'; +import { KubeConfigRegistrationComponent } from './kube-config-registration/kube-config-registration.component'; +import { + KubeConfigSelectionComponent, +} from './kube-config-registration/kube-config-selection/kube-config-selection.component'; +import { + KubeConfigTableCertComponent, +} from './kube-config-registration/kube-config-selection/kube-config-table-cert/kube-config-table-cert.component'; +import { + KubeConfigTableNameComponent, +} from './kube-config-registration/kube-config-selection/kube-config-table-name/kube-config-table-name.component'; +import { + KubeConfigTableSelectComponent, +} from './kube-config-registration/kube-config-selection/kube-config-table-select/kube-config-table-select.component'; +import { + KubeConfigTableSubTypeSelectComponent, +} from './kube-config-registration/kube-config-selection/kube-config-table-sub-type-select/kube-config-table-sub-type-select.component'; +import { + KubeConfigTableUserSelectComponent, +} from './kube-config-registration/kube-config-selection/kube-config-table-user-select/kube-config-table-user-select.component'; +import { kubeEntityCatalog } from './kubernetes-entity-catalog'; +import { KUBERNETES_ENDPOINT_TYPE } from './kubernetes-entity-factory'; +import { generateKubernetesEntities } from './kubernetes-entity-generator'; +import { BaseKubeGuid } from './kubernetes-page.types'; +import { KubernetesStoreModule } from './kubernetes.store.module'; +import { KubernetesEndpointService } from './services/kubernetes-endpoint.service'; + +@NgModule({ + imports: [ + EntityCatalogModule.forFeature(generateKubernetesEntities), + CoreModule, + CommonModule, + SharedModule, + KubernetesStoreModule + ], + declarations: [ + KubernetesCertsAuthFormComponent, + KubernetesAWSAuthFormComponent, + KubernetesConfigAuthFormComponent, + KubernetesGKEAuthFormComponent, + KubeConfigRegistrationComponent, + KubeConfigSelectionComponent, + KubeConfigImportComponent, + KubeConfigTableSelectComponent, + KubeConfigTableUserSelectComponent, + KubeConfigTableImportStatusComponent, + KubeConfigTableSubTypeSelectComponent, + KubeConfigTableNameComponent, + KubeConfigTableCertComponent + ], + providers: [ + BaseKubeGuid, + KubernetesEndpointService, + ], + entryComponents: [ + KubernetesCertsAuthFormComponent, + KubernetesAWSAuthFormComponent, + KubernetesConfigAuthFormComponent, + KubernetesGKEAuthFormComponent, + KubeConfigRegistrationComponent, + KubeConfigTableSelectComponent, + KubeConfigTableUserSelectComponent, + KubeConfigTableImportStatusComponent, + KubeConfigTableSubTypeSelectComponent, + KubeConfigTableNameComponent, + KubeConfigTableCertComponent + ] +}) +export class KubernetesSetupModule { + constructor( + endpointService: EndpointsService, + @Optional() @SkipSelf() parentModule: KubernetesSetupModule + ) { + if (parentModule) { + // Module has already been imported + } else { + endpointService.registerHealthCheck( + new EndpointHealthCheck(KUBERNETES_ENDPOINT_TYPE, (endpoint) => kubeEntityCatalog.node.api.healthCheck(endpoint.guid)) + ); + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.store.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.store.module.ts new file mode 100644 index 0000000000..4b37169198 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.store.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; + +import { AnalysisEffects } from './store/analysis.effects'; +import { KubernetesEffects } from './store/kubernetes.effects'; + +@NgModule({ + imports: [ + EffectsModule.forFeature([ + AnalysisEffects, + KubernetesEffects, + ]) + ] +}) +export class KubernetesStoreModule { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.testing.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.testing.module.ts new file mode 100644 index 0000000000..bffc35ba08 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes.testing.module.ts @@ -0,0 +1,59 @@ +import { HttpClientModule } from '@angular/common/http'; +import { NgModule } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule, SharedModule } from '../../../core/src/public-api'; +import { TabNavService } from '../../../core/src/tab-nav.service'; +import { AppTestModule } from '../../../core/test-framework/core-test.helper'; +import { + CATALOGUE_ENTITIES, + entityCatalog, + EntityCatalogFeatureModule, + TestEntityCatalog, +} from '../../../store/src/public-api'; +import { generateStratosEntities } from '../../../store/src/stratos-entity-generator'; +import { createBasicStoreModule } from '../../../store/testing/public-api'; +import { HelmReleaseActivatedRouteMock, HelmReleaseGuidMock } from '../helm/helm-testing.module'; +import { generateKubernetesEntities } from './kubernetes-entity-generator'; +import { BaseKubeGuid } from './kubernetes-page.types'; +import { HelmReleaseHelperService } from './workloads/release/tabs/helm-release-helper.service'; + +@NgModule({ + imports: [{ + ngModule: EntityCatalogFeatureModule, + providers: [ + { + provide: CATALOGUE_ENTITIES, useFactory: () => { + const testEntityCatalog = entityCatalog as TestEntityCatalog; + testEntityCatalog.clear(); + return [ + ...generateStratosEntities(), + ...generateKubernetesEntities(), + ]; + } + } + ] + }] +}) +export class KubernetesTestingModule { } + +export const KubernetesBaseTestModules = [ + AppTestModule, + KubernetesTestingModule, + RouterTestingModule, + CoreModule, + createBasicStoreModule(), + NoopAnimationsModule, + HttpClientModule, + SharedModule, +]; + +export const HelmReleaseProviders = [ + HelmReleaseHelperService, + HelmReleaseActivatedRouteMock, + HelmReleaseGuidMock, + TabNavService +]; + +export const KubeBaseGuidMock = { provide: BaseKubeGuid, useValue: { guid: 'anything' } }; diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.html b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.html new file mode 100644 index 0000000000..9dd3facb7c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.html @@ -0,0 +1,5 @@ + +

Kubernetes

+
+ + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.spec.ts new file mode 100644 index 0000000000..89e6bc2ced --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.spec.ts @@ -0,0 +1,29 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../core/src/tab-nav.service'; +import { KubernetesBaseTestModules } from '../kubernetes.testing.module'; +import { KubernetesComponent } from './kubernetes.component'; + +describe('KubernetesComponent', () => { + let component: KubernetesComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesComponent], + imports: KubernetesBaseTestModules, + providers: [TabNavService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.ts new file mode 100644 index 0000000000..b94175816b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/kubernetes/kubernetes.component.ts @@ -0,0 +1,52 @@ +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { filter, first, map } from 'rxjs/operators'; + +import { EndpointListHelper } from '../../../../core/src/shared/components/list/list-types/endpoint/endpoint-list.helpers'; +import { ListConfig } from '../../../../core/src/shared/components/list/list.component.types'; +import { RouterNav } from '../../../../store/src/actions/router.actions'; +import { AppState } from '../../../../store/src/public-api'; +import { + KubernetesEndpointsListConfigService, +} from '../list-types/kubernetes-endpoints/kubernetes-endpoints-list-config.service'; +import { KubernetesService } from '../services/kubernetes.service'; + +@Component({ + selector: 'app-kubernetes', + templateUrl: './kubernetes.component.html', + styleUrls: ['./kubernetes.component.scss'], + providers: [ + { + provide: ListConfig, + useClass: KubernetesEndpointsListConfigService, + }, + EndpointListHelper, + KubernetesService + ] +}) +export class KubernetesComponent { + + connectedEndpoints$: Observable; + constructor( + private store: Store, + kubeService: KubernetesService + ) { + this.connectedEndpoints$ = kubeService.kubeEndpoints$.pipe( + map(kubeEndpoints => { + const connectedEndpoints = kubeEndpoints.filter( + c => c.connectionStatus === 'connected' + ); + const hasOne = connectedEndpoints.length === 1; + if (hasOne) { + this.store.dispatch(new RouterNav({ + path: ['kubernetes', connectedEndpoints[0].guid] + })); + } + return connectedEndpoints.length; + }), + filter(connectedEndpointsCount => connectedEndpointsCount > 1), + first() + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-reports-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-reports-list-config.service.ts new file mode 100644 index 0000000000..1288c57637 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-reports-list-config.service.ts @@ -0,0 +1,123 @@ +import { Injectable, NgZone } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { ITableColumn } from 'frontend/packages/core/src/shared/components/list/list-table/table.types'; +import { + IListAction, + IListConfig, + IListMultiFilterConfig, + ListViewTypes, +} from 'frontend/packages/core/src/shared/components/list/list.component.types'; +import moment from 'moment'; +import { of } from 'rxjs'; + +import { ListView } from '../../../../store/src/actions/list.actions'; +import { AppState } from '../../../../store/src/public-api'; +import { defaultHelmKubeListPageSize } from '../../kubernetes/list-types/kube-helm-list-types'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../services/kubernetes.analysis.service'; +import { AnalysisReport } from '../store/kube.types'; +import { AnalysisReportsDataSource } from './analysis-reports-list-source'; +import { AnalysisStatusCellComponent } from './analysis-status-cell/analysis-status-cell.component'; + +@Injectable() +export class AnalysisReportsListConfig implements IListConfig { + AppsDataSource: AnalysisReportsDataSource; + isLocal = true; + multiFilterConfigs: IListMultiFilterConfig[]; + + guid: string; + + columns: Array> = [ + { + columnId: 'name', headerCell: () => 'Name', + cellDefinition: { + getValue: (row: AnalysisReport) => row.name, + getLink: row => row.status === 'completed' ? `/kubernetes/${this.guid}/analysis/report/${row.id}` : null + }, + sort: { + type: 'sort', + orderKey: 'name', + field: 'name' + }, + cellFlex: '2', + }, + { + columnId: 'type', + headerCell: () => 'Type', + cellDefinition: { + getValue: (row: AnalysisReport) => row.type.charAt(0).toUpperCase() + row.type.substring(1) + }, + sort: { + type: 'sort', + orderKey: 'type', + field: 'type' + }, + cellFlex: '1' + }, + { + columnId: 'age', + headerCell: () => 'Age', + cellDefinition: { + getValue: (row: AnalysisReport) => { + return moment(row.created).fromNow(true); + } + }, + sort: { + type: 'sort', + orderKey: 'age', + field: 'created' + }, + cellFlex: '1' + }, + { + columnId: 'status', + headerCell: () => 'Status', + cellComponent: AnalysisStatusCellComponent, + sort: { + type: 'sort', + orderKey: 'status', + field: 'status' + }, + cellFlex: '1' + } + ]; + + pageSizeOptions = defaultHelmKubeListPageSize; + viewType = ListViewTypes.TABLE_ONLY; + defaultView = 'table' as ListView; + + enableTextFilter = true; + text = { + filter: 'Filter by Name', + noEntries: 'There are no Analysis Reports' + }; + + constructor( + store: Store, + kubeEndpointService: KubernetesEndpointService, + private analysisService: KubernetesAnalysisService, + ngZone: NgZone, + ) { + this.guid = kubeEndpointService.baseKube.guid; + this.AppsDataSource = new AnalysisReportsDataSource(store, this, kubeEndpointService, ngZone); + } + + private listActionDelete: IListAction = { + action: (item) => this.analysisService.delete(item.endpoint, item), + label: 'Delete', + icon: 'delete', + description: ``, + createEnabled: row$ => of(true) + }; + + private singleActions = [ + this.listActionDelete, + ]; + + getGlobalActions = () => []; + getMultiActions = () => []; + getSingleActions = () => this.singleActions; + getColumns = () => this.columns; + getDataSource = () => this.AppsDataSource; + getMultiFiltersConfigs = () => []; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-reports-list-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-reports-list-source.ts new file mode 100644 index 0000000000..0b1cccec2f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-reports-list-source.ts @@ -0,0 +1,63 @@ +import { NgZone } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { safeUnsubscribe } from 'frontend/packages/core/src/core/utils.service'; +import { ListDataSource } from 'frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from 'frontend/packages/core/src/shared/components/list/list.component.types'; +import { interval, Subscription } from 'rxjs'; +import { first, map } from 'rxjs/operators'; + +import { AppState } from '../../../../store/src/public-api'; +import { isFetchingPage } from '../../../../store/src/reducers/pagination-reducer/pagination-reducer.helper'; +import { kubeEntityCatalog } from '../kubernetes-entity-catalog'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { GetAnalysisReports } from '../store/analysis.actions'; +import { AnalysisReport } from '../store/kube.types'; + +export class AnalysisReportsDataSource extends ListDataSource { + + + private analysisAction: GetAnalysisReports; + private pollInterval: Subscription; + + constructor( + store: Store, + listConfig: IListConfig, + endpointService: KubernetesEndpointService, + ngZone: NgZone, + ) { + const action = kubeEntityCatalog.analysisReport.actions.getMultiple(endpointService.baseKube.guid); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (entity: AnalysisReport) => action.entity[0].getId(entity), + paginationKey: action.paginationKey, + isLocal: true, + transformEntities: [{ type: 'filter', field: 'name' }], + listConfig, + }); + this.analysisAction = action; + + this.startPoll(store, ngZone); + } + + destroy() { + safeUnsubscribe(this.pollInterval); + } + + private startPoll(store: Store, ngZone: NgZone) { + ngZone.runOutsideAngular(() => this.pollInterval = interval(5000).subscribe(() => this.poll(store, ngZone))); + } + private poll(store: Store, ngZone: NgZone) { + kubeEntityCatalog.analysisReport.store.getPaginationMonitor(this.analysisAction.kubeGuid).pagination$.pipe( + first(), + map(isFetchingPage) + ).subscribe(isFetchingPageRes => { + if (!isFetchingPageRes) { + ngZone.run(() => { + store.dispatch(this.analysisAction); + }); + } + }); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.html new file mode 100644 index 0000000000..6ff4563a31 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.html @@ -0,0 +1,8 @@ +
+ Running +
+
Completed
+
+
Error
+ warning +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.scss new file mode 100644 index 0000000000..27dcfcf08b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.scss @@ -0,0 +1,22 @@ +.status { + &__running { + align-items: center; + display: flex; + + &> mat-progress-spinner { + margin-right: 8px; + } + } + &__error { + align-items: center; + display: flex; + + mat-icon { + cursor: help; + font-size: 20px; + height: 20px; + margin-left: 8px; + width: 20px; + } + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.spec.ts new file mode 100644 index 0000000000..d7c7f58503 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.spec.ts @@ -0,0 +1,32 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MDAppModule } from '../../../../../core/src/public-api'; +import { + AnalysisReportSelectorComponent, +} from './../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { AnalysisStatusCellComponent } from './analysis-status-cell.component'; + +describe('AnalysisStatusCellComponent', () => { + let component: AnalysisStatusCellComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [AnalysisStatusCellComponent, AnalysisReportSelectorComponent], + imports: [ + MDAppModule, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AnalysisStatusCellComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.ts new file mode 100644 index 0000000000..acc74ee6d6 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.ts @@ -0,0 +1,16 @@ +import { Component } from '@angular/core'; +import { TableCellCustom } from 'frontend/packages/core/src/shared/components/list/list.types'; + +@Component({ + selector: 'app-analysis-status-cell', + templateUrl: './analysis-status-cell.component.html', + styleUrls: ['./analysis-status-cell.component.scss'] +}) +export class AnalysisStatusCellComponent extends TableCellCustom { + + constructor() { + super(); + this.row = {}; + } + + } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kube-helm-list-types.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kube-helm-list-types.ts new file mode 100644 index 0000000000..f4e0553692 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kube-helm-list-types.ts @@ -0,0 +1 @@ +export const defaultHelmKubeListPageSize = [9, 45, 90]; diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kube-list.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kube-list.helper.ts new file mode 100644 index 0000000000..b2ac0ae8bb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kube-list.helper.ts @@ -0,0 +1,54 @@ +import moment from 'moment'; + +import { DataFunction } from '../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { ITableColumn } from '../../../../core/src/shared/components/list/list-table/table.types'; +import { BasicKubeAPIResource, ConditionType, KubernetesNode } from '../store/kube.types'; + +export function getConditionSort(condition: ConditionType): DataFunction { + return (entities, paginationState) => { + const orderDirection = paginationState.params['order-direction'] || 'asc'; + return entities.sort((a, b) => { + + const aConditionValue = a.status.conditions.find(c => c.type === condition); + const bConditionValue = b.status.conditions.find(c => c.type === condition); + if (aConditionValue > bConditionValue) { + return orderDirection === 'desc' ? 1 : -1; + } + if (bConditionValue < aConditionValue) { + return orderDirection === 'desc' ? -1 : 1; + } + return 0; + }); + }; +} +export function getContainerLengthSort(entities, paginationState) { + const orderDirection = paginationState.params['order-direction'] || 'asc'; + return entities.sort((a, b) => { + + const aConditionValue = a.spec.containers.length; + const bConditionValue = b.spec.containers.length; + if (orderDirection === 'desc') { + return aConditionValue - bConditionValue; + } else { + return bConditionValue - aConditionValue; + } + }); +} + +export function createKubeAgeColumn(): ITableColumn { + return { + columnId: 'age', + headerCell: () => 'Age', + cellDefinition: { + getValue: (row: T) => { + return moment(row.metadata.creationTimestamp).fromNow(true); + } + }, + sort: { + type: 'sort', + orderKey: 'age', + field: 'metadata.creationTimestamp' + }, + cellFlex: '1' + }; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-data-source.ts new file mode 100644 index 0000000000..c91acd3774 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-data-source.ts @@ -0,0 +1,39 @@ +import { Store } from '@ngrx/store'; + +import { + BaseEndpointsDataSource, + syncPaginationSection, +} from '../../../../../core/src/shared/components/list/list-types/endpoint/base-endpoints-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { GetAllEndpoints } from '../../../../../store/src/actions/endpoint.actions'; +import { EntityMonitorFactory } from '../../../../../store/src/monitors/entity-monitor.factory.service'; +import { InternalEventMonitorFactory } from '../../../../../store/src/monitors/internal-event-monitor.factory'; +import { PaginationMonitorFactory } from '../../../../../store/src/monitors/pagination-monitor.factory'; +import { AppState, EndpointModel } from '../../../../../store/src/public-api'; + +export class KubernetesEndpointsDataSource extends BaseEndpointsDataSource { + + constructor( + store: Store, + listConfig: IListConfig, + paginationMonitorFactory: PaginationMonitorFactory, + entityMonitorFactory: EntityMonitorFactory, + internalEventMonitorFactory: InternalEventMonitorFactory, + ) { + const action = new GetAllEndpoints(); + const paginationKey = 'kube-endpoints'; + // We do this here to ensure we sync up with main endpoint table data. + syncPaginationSection(store, action, paginationKey); + action.paginationKey = paginationKey; + super( + store, + listConfig, + action, + 'k8s', + paginationMonitorFactory, + entityMonitorFactory, + internalEventMonitorFactory, + ); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-list-config.service.ts new file mode 100644 index 0000000000..f4efc09eca --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-endpoints/kubernetes-endpoints-list-config.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { + BaseEndpointsDataSource, +} from '../../../../../core/src/shared/components/list/list-types/endpoint/base-endpoints-data-source'; +import { + EndpointCardComponent, +} from '../../../../../core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component'; +import { + EndpointsListConfigService, +} from '../../../../../core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service'; +import { IListConfig, ListViewTypes } from '../../../../../core/src/shared/components/list/list.component.types'; +import { EntityMonitorFactory } from '../../../../../store/src/monitors/entity-monitor.factory.service'; +import { InternalEventMonitorFactory } from '../../../../../store/src/monitors/internal-event-monitor.factory'; +import { PaginationMonitorFactory } from '../../../../../store/src/monitors/pagination-monitor.factory'; +import { AppState, EndpointModel } from '../../../../../store/src/public-api'; +import { KubernetesEndpointsDataSource } from './kubernetes-endpoints-data-source'; + +@Injectable() +export class KubernetesEndpointsListConfigService implements IListConfig { + columns: ITableColumn[]; + isLocal = true; + dataSource: BaseEndpointsDataSource; + viewType = ListViewTypes.CARD_ONLY; + cardComponent = EndpointCardComponent; + text = { + title: '', + filter: 'Filter Endpoints', + noEntries: 'There are no endpoints' + }; + enableTextFilter = true; + + + constructor( + private store: Store, + paginationMonitorFactory: PaginationMonitorFactory, + entityMonitorFactory: EntityMonitorFactory, + internalEventMonitorFactory: InternalEventMonitorFactory, + endpointsListConfigService: EndpointsListConfigService, + ) { + this.columns = endpointsListConfigService.columns.filter(column => { + return column.columnId !== 'type'; + }); + this.dataSource = new KubernetesEndpointsDataSource( + this.store, + this, + paginationMonitorFactory, + entityMonitorFactory, + internalEventMonitorFactory, + ); + } + public getColumns = () => this.columns; + public getGlobalActions = () => []; + public getMultiActions = () => []; + public getSingleActions = () => []; + public getMultiFiltersConfigs = () => []; + public getDataSource = () => this.dataSource; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.html new file mode 100644 index 0000000000..ba47496e44 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.spec.ts new file mode 100644 index 0000000000..208f7e2be3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.spec.ts @@ -0,0 +1,45 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseTestModules } from '../../../../../core/test-framework/core-test.helper'; +import { KubernetesStatus } from '../../store/kube.types'; +import { KubernetesLabelsCellComponent } from './kubernetes-labels-cell.component'; + +describe('KubernetesLabelsCellComponent', () => { + let component: KubernetesLabelsCellComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesLabelsCellComponent], + imports: BaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesLabelsCellComponent); + component = fixture.componentInstance; + component.row = { + metadata: { + labels: {}, + namespace: 'test', + name: 'test', + uid: 'test' + }, + status: { + phase: KubernetesStatus.ACTIVE + }, + spec: { + containers: [], + nodeName: 'test', + schedulerName: 'test', + initContainers: [] + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.ts new file mode 100644 index 0000000000..20a8b2e5b0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit } from '@angular/core'; + +import { AppChip } from '../../../../../core/src/shared/components/chips/chips.component'; +import { TableCellCustom } from '../../../../../core/src/shared/components/list/list.types'; +import { KubeAPIResource } from '../../store/kube.types'; + + +@Component({ + selector: 'app-kubernetes-labels-cell', + templateUrl: './kubernetes-labels-cell.component.html', + styleUrls: ['./kubernetes-labels-cell.component.scss'] +}) +export class KubernetesLabelsCellComponent extends TableCellCustom implements OnInit { + + chipsConfig: AppChip[]; + + constructor() { + super(); + } + + ngOnInit() { + this.chipsConfig = Object.entries(this.row.metadata.labels).map(([key, value]) => ({ + value: `${key}:${value}` + })); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-pods/kubernetes-namespace-pods-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-pods/kubernetes-namespace-pods-data-source.ts new file mode 100644 index 0000000000..8ed8c1128c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-pods/kubernetes-namespace-pods-data-source.ts @@ -0,0 +1,33 @@ +import { Store } from '@ngrx/store'; + +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../../../store/src/public-api'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { kubernetesEntityFactory, kubernetesPodsEntityType } from '../../kubernetes-entity-factory'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesNamespaceService } from '../../services/kubernetes-namespace.service'; +import { KubernetesPod } from '../../store/kube.types'; + +export class KubernetesNamespacePodsDataSource extends ListDataSource { + + constructor( + store: Store, + kubeGuid: BaseKubeGuid, + listConfig: IListConfig, + kubeNamespaceService: KubernetesNamespaceService, + ) { + const action = kubeEntityCatalog.pod.actions.getInNamespace(kubeGuid.guid, kubeNamespaceService.namespaceName); + super({ + store, + action, + schema: kubernetesEntityFactory(kubernetesPodsEntityType), + getRowUniqueId: (object: KubernetesPod) => object.metadata.name, + paginationKey: action.paginationKey, + isLocal: true, + listConfig, + transformEntities: [{ type: 'filter', field: 'metadata.name' }] + }); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-pods/kubernetes-namespace-pods-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-pods/kubernetes-namespace-pods-list-config.service.ts new file mode 100644 index 0000000000..c5e48a4935 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-pods/kubernetes-namespace-pods-list-config.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { AppState } from '../../../../../store/src/public-api'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesNamespaceService } from '../../services/kubernetes-namespace.service'; +import { BaseKubernetesPodsListConfigService } from '../kubernetes-pods/kubernetes-pods-list-config.service'; +import { KubernetesNamespacePodsDataSource } from './kubernetes-namespace-pods-data-source'; + +@Injectable() +export class KubernetesNamespacePodsListConfigService extends BaseKubernetesPodsListConfigService { + + showNamespaceLink = false; + + constructor( + store: Store, + kubeId: BaseKubeGuid, + public kubeNamespaceService: KubernetesNamespaceService, + ) { + super(kubeId.guid, [ + BaseKubernetesPodsListConfigService.namespaceColumnId, + ]); + this.podsDataSource = new KubernetesNamespacePodsDataSource(store, kubeId, this, kubeNamespaceService); + } + + private podsDataSource: KubernetesNamespacePodsDataSource; + + getDataSource = () => this.podsDataSource; + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-services/kubernetes-namespace-services-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-services/kubernetes-namespace-services-data-source.ts new file mode 100644 index 0000000000..bf533daf17 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-services/kubernetes-namespace-services-data-source.ts @@ -0,0 +1,26 @@ +import { Store } from '@ngrx/store'; + +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../../../store/src/public-api'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubeService } from '../../store/kube.types'; +import { BaseKubernetesServicesDataSource } from '../kubernetes-services/kubernetes-services-data-source'; + + +export class KubernetesNamespaceServicesDataSource extends BaseKubernetesServicesDataSource { + + constructor( + store: Store, + kubeGuid: BaseKubeGuid, + listConfig: IListConfig, + namespace: string, + ) { + super( + store, + kubeEntityCatalog.service.actions.getInNamespace(namespace, kubeGuid.guid), + listConfig + ); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-services/kubernetes-namespace-services-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-services/kubernetes-namespace-services-list-config.service.ts new file mode 100644 index 0000000000..ebf6103361 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespace-services/kubernetes-namespace-services-list-config.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { AppState } from '../../../../../store/src/public-api'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesNamespaceService } from '../../services/kubernetes-namespace.service'; +import { BaseKubernetesServicesListConfig } from '../kubernetes-services/kubernetes-service-list-config.service'; +import { KubernetesNamespaceServicesDataSource } from './kubernetes-namespace-services-data-source'; + +@Injectable() +export class KubernetesNamespaceServicesListConfig extends BaseKubernetesServicesListConfig { + dataSource: KubernetesNamespaceServicesDataSource; + + constructor( + store: Store, + kubeId: BaseKubeGuid, + kubeNamespaceService: KubernetesNamespaceService + ) { + super(); + this.dataSource = new KubernetesNamespaceServicesDataSource(store, kubeId, this, kubeNamespaceService.namespaceName); + } + getDataSource = () => this.dataSource; + + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.html new file mode 100644 index 0000000000..a36794a85b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.html @@ -0,0 +1 @@ +{{ podCount$ | async }} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.spec.ts new file mode 100644 index 0000000000..d5f28ba20e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.spec.ts @@ -0,0 +1,29 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubeNamespacePodCountComponent } from './kube-namespace-pod-count.component'; + +describe('KubeNamespacePodCountComponent', () => { + let component: KubeNamespacePodCountComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubeNamespacePodCountComponent], + imports: KubernetesBaseTestModules, + providers: [KubeBaseGuidMock, KubernetesEndpointService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeNamespacePodCountComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.ts new file mode 100644 index 0000000000..d1e7679573 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kube-namespace-pod-count/kube-namespace-pod-count.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesNamespace } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kube-namespace-pod-count', + templateUrl: './kube-namespace-pod-count.component.html', + styleUrls: ['./kube-namespace-pod-count.component.scss'] +}) +export class KubeNamespacePodCountComponent extends TableCellCustom implements OnInit { + podCount$: Observable; + + constructor( + private kubeEndpointService: KubernetesEndpointService + ) { + super(); + } + + ngOnInit() { + + this.podCount$ = this.kubeEndpointService.pods$.pipe( + map(pods => pods.filter(p => p.metadata.namespace === this.row.metadata.name)), + map(p => p.length) + ); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.html new file mode 100644 index 0000000000..e8bdc39357 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.html @@ -0,0 +1 @@ +{{row.metadata.name}} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.spec.ts new file mode 100644 index 0000000000..83363482ad --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.spec.ts @@ -0,0 +1,45 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesStatus } from '../../../store/kube.types'; +import { KubernetesNamespaceLinkComponent } from './kubernetes-namespace-link.component'; + +describe('KubernetesNamespaceLinkComponent', () => { + let component: KubernetesNamespaceLinkComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNamespaceLinkComponent], + imports: KubernetesBaseTestModules, + providers: [KubernetesEndpointService, BaseKubeGuid] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNamespaceLinkComponent); + component = fixture.componentInstance; + component.row = { + spec: { + finalizers: [] + }, + status: { + phase: KubernetesStatus.RUNNING + }, + metadata: { + namespace: 'test', + name: 'test', + uid: 'test', + labels: {} + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.ts new file mode 100644 index 0000000000..58fde4613a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespace-link/kubernetes-namespace-link.component.ts @@ -0,0 +1,23 @@ +import { Component, OnInit } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesNamespace } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-namespace-link', + templateUrl: './kubernetes-namespace-link.component.html', + styleUrls: ['./kubernetes-namespace-link.component.scss'] +}) +export class KubernetesNamespaceLinkComponent extends TableCellCustom implements OnInit { + routerLink: string; + dashboardLink: string; + constructor(public kubeEndpointService: KubernetesEndpointService) { + super(); + } + + ngOnInit() { + this.routerLink = `${this.row.metadata.name}`; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespaces-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespaces-data-source.ts new file mode 100644 index 0000000000..c95efb1f23 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespaces-data-source.ts @@ -0,0 +1,33 @@ +import { Store } from '@ngrx/store'; + +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { getPaginationKey } from '../../../../../store/src/actions/pagination.actions'; +import { AppState } from '../../../../../store/src/public-api'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { kubernetesNamespacesEntityType } from '../../kubernetes-entity-factory'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesNamespace } from '../../store/kube.types'; + + +export class KubernetesNamespacesDataSource extends ListDataSource { + + constructor( + store: Store, + kubeGuid: BaseKubeGuid, + listConfig: IListConfig + ) { + const action = kubeEntityCatalog.namespace.actions.getMultiple(kubeGuid.guid); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (row) => action.entity[0].getId(row), + paginationKey: getPaginationKey(kubernetesNamespacesEntityType, kubeGuid.guid), + isLocal: true, + listConfig, + transformEntities: [{ type: 'filter', field: 'metadata.name' }] + }); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespaces-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespaces-list-config.service.ts new file mode 100644 index 0000000000..587fcf631d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-namespaces/kubernetes-namespaces-list-config.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { filter, first, map, tap } from 'rxjs/operators'; + +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { IListConfig, ListViewTypes } from '../../../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../../../store/src/public-api'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesNamespace } from '../../store/kube.types'; +import { defaultHelmKubeListPageSize } from '../kube-helm-list-types'; +import { createKubeAgeColumn } from '../kube-list.helper'; +import { KubeNamespacePodCountComponent } from './kube-namespace-pod-count/kube-namespace-pod-count.component'; +import { KubernetesNamespaceLinkComponent } from './kubernetes-namespace-link/kubernetes-namespace-link.component'; +import { KubernetesNamespacesDataSource } from './kubernetes-namespaces-data-source'; + + +@Injectable() +export class KubernetesNamespacesListConfigService implements IListConfig { + podsDataSource: KubernetesNamespacesDataSource; + + columns: Array> = [ + { + columnId: 'name', headerCell: () => 'Name', + cellComponent: KubernetesNamespaceLinkComponent, + sort: { + type: 'sort', + orderKey: 'name', + field: 'metadata.name' + }, + cellFlex: '5', + }, + // FIXME: Hide link until the link is fixed + // { + // columnId: 'view', headerCell: () => 'Dashboard', + // cellDefinition: { + // getValue: () => 'View', + // getLink: (row: KubernetesNamespace) => { + // return `/kubernetes/${this.kubeId.guid}/dashboard/overview?namespace=${row.metadata.name}`; + // }, + // }, + // cellFlex: '3', + // }, + { + columnId: 'pods', headerCell: () => 'Pods', + cellComponent: KubeNamespacePodCountComponent, + cellFlex: '5', + }, + { + columnId: 'status', headerCell: () => 'Status', + cellDefinition: { + getValue: (row) => `${row.status.phase}` + }, + sort: { + type: 'sort', + orderKey: 'status', + field: 'status.phase' + }, + cellFlex: '5', + }, + createKubeAgeColumn() + ]; + + pageSizeOptions = defaultHelmKubeListPageSize; + viewType = ListViewTypes.TABLE_ONLY; + enableTextFilter = true; + text = { + filter: 'Filter by Name', + noEntries: 'There are no namespaces' + }; + private initialised$: Observable; + + getGlobalActions = () => null; + getMultiActions = () => []; + getSingleActions = () => []; + getColumns = () => this.columns; + getDataSource = () => this.podsDataSource; + getMultiFiltersConfigs = () => []; + getInitialised = () => this.initialised$; + + constructor( + store: Store, + private kubeId: BaseKubeGuid, + kubeService: KubernetesEndpointService + ) { + this.podsDataSource = new KubernetesNamespacesDataSource(store, this.kubeId, this); + + const hasDashboard = kubeService.kubeDashboardConfigured$.pipe( + first(), + tap((enabled) => { + if (!enabled) { + this.columns = this.columns.filter(column => column.columnId !== 'view'); + } + }) + ); + this.initialised$ = hasDashboard.pipe( + map(() => true) + ); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-node-pods/kubernetes-node-pods-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-node-pods/kubernetes-node-pods-data-source.ts new file mode 100644 index 0000000000..6ff5893190 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-node-pods/kubernetes-node-pods-data-source.ts @@ -0,0 +1,32 @@ +import { Store } from '@ngrx/store'; + +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../../../store/src/public-api'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesNodeService } from '../../services/kubernetes-node.service'; +import { KubernetesPod } from '../../store/kube.types'; + +export class KubernetesNodePodsDataSource extends ListDataSource { + + constructor( + store: Store, + kubeGuid: BaseKubeGuid, + listConfig: IListConfig, + kubeNodeService: KubernetesNodeService, + ) { + const action = kubeEntityCatalog.pod.actions.getOnNode(kubeGuid.guid, kubeNodeService.nodeName); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (row) => action.entity[0].getId(row), + paginationKey: action.paginationKey, + isLocal: true, + listConfig, + transformEntities: [{ type: 'filter', field: 'metadata.name' }] + }); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-node-pods/kubernetes-node-pods-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-node-pods/kubernetes-node-pods-list-config.service.ts new file mode 100644 index 0000000000..4f93ff213b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-node-pods/kubernetes-node-pods-list-config.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { AppState } from '../../../../../store/src/public-api'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesNodeService } from '../../services/kubernetes-node.service'; +import { BaseKubernetesPodsListConfigService } from '../kubernetes-pods/kubernetes-pods-list-config.service'; +import { KubernetesNodePodsDataSource } from './kubernetes-node-pods-data-source'; + +@Injectable() +export class KubernetesNodePodsListConfigService extends BaseKubernetesPodsListConfigService { + + private podsDataSource: KubernetesNodePodsDataSource; + + getDataSource = () => this.podsDataSource; + + constructor( + store: Store, + kubeId: BaseKubeGuid, + public kubeNodeService: KubernetesNodeService, + ) { + super(kubeId.guid, [ + BaseKubernetesPodsListConfigService.nodeColumnId + ]); + this.podsDataSource = new KubernetesNodePodsDataSource(store, kubeId, this, kubeNodeService); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.html new file mode 100644 index 0000000000..08b5564786 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.spec.ts new file mode 100644 index 0000000000..dedb8f791a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.spec.ts @@ -0,0 +1,46 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseTestModules } from '../../../../../../core/test-framework/core-test.helper'; +import { ConditionCellComponent } from './condition-cell.component'; + +describe('ConditionCellComponent', () => { + let component: ConditionCellComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ConditionCellComponent], + imports: BaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConditionCellComponent); + component = fixture.componentInstance; + component.row = { + metadata: { + namespace: 'test', + name: 'test', + uid: 'test' + }, + status: { + conditions: [], + addresses: [], + images: [] + }, + spec: { + containers: [], + nodeName: 'test', + schedulerName: 'test', + initContainers: [], + readinessGates: [] + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.ts new file mode 100644 index 0000000000..03b0b2e878 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/condition-cell/condition-cell.component.ts @@ -0,0 +1,37 @@ +import { Component, OnInit } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubernetesNode } from '../../../store/kube.types'; + +@Component({ + selector: 'app-condition-cell', + templateUrl: './condition-cell.component.html', + styleUrls: ['./condition-cell.component.scss'] +}) +export class ConditionCellComponent extends TableCellCustom implements OnInit { + public isTrue: boolean = null; + + public subtle = false; + + public inverse = false; + + constructor() { + super(); + } + + ngOnInit() { + const conditions = this.row.status.conditions.filter(c => c.type === this.config.conditionType); + if (conditions && conditions.length) { + const condition = conditions[0]; + switch (condition.status) { + case 'True': + this.isTrue = true; + break; + case 'False': + this.isTrue = false; + break; + } + } + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.html new file mode 100644 index 0000000000..77e3a8a6c8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.html @@ -0,0 +1,19 @@ +
+
+
+
+
{{ getMemory(row.status.capacity.memory) | bytesToHumanSize }}
+
Memory
+
+
+
+ +
+
+
+
{{ row.status.capacity.cpu }}
+
CPU
+
+
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.scss new file mode 100644 index 0000000000..adb0a416a4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.scss @@ -0,0 +1,22 @@ +.kube-node-capacity { + display: flex; + + .legend-items-container { + width: 100%; + + .legend-items { + white-space: nowrap; + overflow: auto; + } + + .item-value { + font-size: 18px; + } + + .item-label { + font-size: 14px; + opacity: 0.7; + } + } + +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.spec.ts new file mode 100644 index 0000000000..bc04b8d360 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.spec.ts @@ -0,0 +1,50 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseTestModules } from '../../../../../../core/test-framework/core-test.helper'; +import { KubernetesNodeCapacityComponent } from './kubernetes-node-capacity.component'; + +describe('KubernetesNodeCapacityComponent', () => { + let component: KubernetesNodeCapacityComponent; + let fixture: ComponentFixture>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeCapacityComponent], + imports: BaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeCapacityComponent); + component = fixture.componentInstance; + component.row = { + metadata: { + namespace: 'test', + name: 'test', + uid: 'test' + }, + status: { + conditions: [], + addresses: [], + images: [], + capacity: { + pods: 100, + memory: '100Ki', + cpu: 100 + } + }, + spec: { + containers: [], + nodeName: 'test', + schedulerName: 'test', + initContainers: [] + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.ts new file mode 100644 index 0000000000..17491cde85 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-capacity/kubernetes-node-capacity.component.ts @@ -0,0 +1,26 @@ +import { Component, ViewEncapsulation } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; + + +@Component({ + selector: 'app-kubernetes-node-capacity', + templateUrl: './kubernetes-node-capacity.component.html', + styleUrls: ['./kubernetes-node-capacity.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class KubernetesNodeCapacityComponent extends TableCellCustom { + + constructor() { + super(); + } + + public getMemory(memoryCapacity: string) { + if (memoryCapacity.endsWith('Ki')) { + const value = parseInt(memoryCapacity, 10); + return (value * 1024); + + } + return memoryCapacity; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.html new file mode 100644 index 0000000000..789d7317dc --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.html @@ -0,0 +1 @@ +network \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.scss new file mode 100644 index 0000000000..03d64068f2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.scss @@ -0,0 +1,5 @@ +mat-icon { + font-size: 20px; + height: 20px; + width: 20px; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.spec.ts new file mode 100644 index 0000000000..942059a5b5 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.spec.ts @@ -0,0 +1,47 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseTestModules } from '../../../../../../core/test-framework/core-test.helper'; +import { KubernetesNodeIpsComponent } from './kubernetes-node-ips.component'; + +describe('KubernetesNodeIpsComponent', () => { + let component: KubernetesNodeIpsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeIpsComponent], + imports: BaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeIpsComponent); + component = fixture.componentInstance; + component.row = { + metadata: { + labels: {}, + namespace: 'test', + name: 'test', + uid: 'test' + }, + status: { + conditions: [], + addresses: [], + images: [] + }, + spec: { + containers: [], + nodeName: 'test', + schedulerName: 'test', + initContainers: [], + readinessGates: [] + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.ts new file mode 100644 index 0000000000..feb188954f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-ips/kubernetes-node-ips.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubernetesAddressExternal, KubernetesAddressInternal, KubernetesNode } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-node-ips', + templateUrl: './kubernetes-node-ips.component.html', + styleUrls: ['./kubernetes-node-ips.component.scss'] +}) +export class KubernetesNodeIpsComponent extends TableCellCustom implements OnInit { + + tooltip: string; + + constructor() { + super(); + } + + ngOnInit() { + this.tooltip = this.row.status.addresses + .filter(address => address.type === KubernetesAddressInternal || address.type === KubernetesAddressExternal) + .map(address => address.address) + .join(', '); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.html new file mode 100644 index 0000000000..e50abec150 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.html @@ -0,0 +1 @@ +info \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.scss new file mode 100644 index 0000000000..03d64068f2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.scss @@ -0,0 +1,5 @@ +mat-icon { + font-size: 20px; + height: 20px; + width: 20px; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.spec.ts new file mode 100644 index 0000000000..050be6121c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.spec.ts @@ -0,0 +1,47 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseTestModules } from '../../../../../../core/test-framework/core-test.helper'; +import { KubernetesNodeLabelsComponent } from './kubernetes-node-labels.component'; + +describe('KubernetesNodeLabelsComponent', () => { + let component: KubernetesNodeLabelsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeLabelsComponent], + imports: BaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeLabelsComponent); + component = fixture.componentInstance; + component.row = { + metadata: { + labels: {}, + namespace: 'test', + name: 'test', + uid: 'test' + }, + status: { + conditions: [], + addresses: [], + images: [] + }, + spec: { + containers: [], + nodeName: 'test', + schedulerName: 'test', + initContainers: [], + readinessGates: [] + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.ts new file mode 100644 index 0000000000..f15c018bec --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-labels/kubernetes-node-labels.component.ts @@ -0,0 +1,25 @@ +import { Component, OnInit } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubernetesNode } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-node-labels', + templateUrl: './kubernetes-node-labels.component.html', + styleUrls: ['./kubernetes-node-labels.component.scss'] +}) +export class KubernetesNodeLabelsComponent extends TableCellCustom implements OnInit { + + labels: string; + + constructor() { + super(); + } + + ngOnInit() { + this.labels = Object.entries(this.row.metadata.labels) + .map(([key, value]) => `${key}:${value}`) + .join(', '); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.html new file mode 100644 index 0000000000..a32c03f58b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.scss new file mode 100644 index 0000000000..f95dfc2f4d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.scss @@ -0,0 +1,11 @@ +.node-link { + align-items: center; + display: flex; + flex-direction: row; + mat-icon { + padding-left: 10px; + } + &__help { + cursor: help; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.spec.ts new file mode 100644 index 0000000000..1672ebc38b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.spec.ts @@ -0,0 +1,49 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesNodeLinkComponent } from './kubernetes-node-link.component'; + +describe('KubernetesNodeLinkComponent', () => { + let component: KubernetesNodeLinkComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeLinkComponent], + imports: KubernetesBaseTestModules, + providers: [KubernetesEndpointService, BaseKubeGuid] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeLinkComponent); + component = fixture.componentInstance; + component.row = { + metadata: { + namespace: 'test', + name: 'test', + uid: 'test' + }, + status: { + conditions: [], + addresses: [], + images: [] + }, + spec: { + containers: [], + nodeName: 'test', + schedulerName: 'test', + initContainers: [], + readinessGates: [] + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.theme.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.theme.scss new file mode 100644 index 0000000000..387f85d461 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.theme.scss @@ -0,0 +1,18 @@ + +@mixin kube-node-link-theme($theme, $app-theme) { + + $status-colors: map-get($app-theme, status); + $error: map-get($status-colors, danger); + $warning: map-get($status-colors, warning); + + .node-link { + .mat-icon { + &.error { + color: $error; + } + &.warning { + color: $warning; + } + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.ts new file mode 100644 index 0000000000..ccf197c570 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-link/kubernetes-node-link.component.ts @@ -0,0 +1,48 @@ +import { Component, OnInit } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesNode } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-node-link', + templateUrl: './kubernetes-node-link.component.html', + styleUrls: ['./kubernetes-node-link.component.scss'] +}) +export class KubernetesNodeLinkComponent extends TableCellCustom implements OnInit { + + public nodeLink: string; + + public icon: { + icon: string, + class: string, + message: string, + }; + + constructor( + private kubeEndpointService: KubernetesEndpointService + ) { + super(); + } + + ngOnInit() { + this.nodeLink = `/kubernetes/${this.kubeEndpointService.kubeGuid}/nodes/${this.row.metadata.name}`; + const caaspNodeData = this.kubeEndpointService.getCaaspNodeData(this.row); + if (caaspNodeData) { + if (caaspNodeData.securityUpdates) { + this.icon = { + icon: 'error', + class: 'error', + message: 'Node has security updates' + }; + } else if (caaspNodeData.disruptiveUpdates) { + this.icon = { + icon: 'warning', + class: 'warning', + message: 'Node has disruptive updates' + }; + } + } + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.html new file mode 100644 index 0000000000..aeafa64566 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.html @@ -0,0 +1,9 @@ + +
+ info + {{ error }} +
+
+ + None + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.scss new file mode 100644 index 0000000000..38738ff61b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.scss @@ -0,0 +1,12 @@ +.pressure { + align-items: center; + display: flex; + + &__icon { + color: red; + font-size: 20px; + height: 20px; + margin-right: 4px; + width: 20px; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.spec.ts new file mode 100644 index 0000000000..cb7eb3fa12 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.spec.ts @@ -0,0 +1,47 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseTestModules } from '../../../../../../core/test-framework/core-test.helper'; +import { KubernetesNodePressureComponent } from './kubernetes-node-pressure.component'; + +describe('KubernetesNodePressureComponent', () => { + let component: KubernetesNodePressureComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodePressureComponent], + imports: BaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodePressureComponent); + component = fixture.componentInstance; + component.row = { + metadata: { + labels: {}, + namespace: 'test', + name: 'test', + uid: 'test' + }, + status: { + conditions: [], + addresses: [], + images: [] + }, + spec: { + containers: [], + nodeName: 'test', + schedulerName: 'test', + initContainers: [], + readinessGates: [] + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.theme.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.theme.scss new file mode 100644 index 0000000000..5508403ef1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.theme.scss @@ -0,0 +1,13 @@ + + +// Not currently used but should be - NJ +@import '~@angular/material/theming'; +@mixin app-kubernetes-node-pressure($theme, $app-theme) { + $status: map-get($app-theme, status); + .pressure { + // color: map-get($status, danger); + &__icon { + color: map-get($status, danger); + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.ts new file mode 100644 index 0000000000..8f532f808f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-pressure/kubernetes-node-pressure.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { ConditionType, ConditionTypeLabels, KubernetesNode } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-node-pressure', + templateUrl: './kubernetes-node-pressure.component.html', + styleUrls: ['./kubernetes-node-pressure.component.scss'] +}) +export class KubernetesNodePressureComponent extends TableCellCustom implements OnInit { + + errors: string[] = []; + + constructor() { + super(); + } + + ngOnInit() { + const conditions = this.row.status.conditions; + this.errors = conditions + .filter(c => c.type !== ConditionType.Ready) + .filter(c => c.status === 'True') + .map(condition => ConditionTypeLabels[condition.type] || condition.type); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.html new file mode 100644 index 0000000000..c485a910da --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.html @@ -0,0 +1,23 @@ +
+ + + Condition + + +
+ + + + + + + + + + + +
+
+
+ +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.scss new file mode 100644 index 0000000000..4e825883e2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.scss @@ -0,0 +1,3 @@ +.kubernetes-node-condition-card { + height: 100% +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.spec.ts new file mode 100644 index 0000000000..d8dfafea22 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../../../../services/kubernetes-node.service'; +import { KubernetesNodeConditionCardComponent } from './kubernetes-node-condition-card.component'; +import { KubernetesNodeConditionComponent } from './kubernetes-node-condition/kubernetes-node-condition.component'; + +describe('KubernetesNodeConditionCardComponent', () => { + let component: KubernetesNodeConditionCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeConditionCardComponent, KubernetesNodeConditionComponent], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid, KubernetesEndpointService, KubernetesNodeService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeConditionCardComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.ts new file mode 100644 index 0000000000..b778e37518 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition-card.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { CaaspNodeData, KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../../../../services/kubernetes-node.service'; + +@Component({ + selector: 'app-kubernetes-node-condition-card', + templateUrl: './kubernetes-node-condition-card.component.html', + styleUrls: ['./kubernetes-node-condition-card.component.scss'] +}) +export class KubernetesNodeConditionCardComponent { + public caaspNode$: Observable; + public caaspNodeDisruptive$: Observable; + public caaspNodSecurity$: Observable; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public kubeNodeService: KubernetesNodeService + ) { + + this.caaspNode$ = this.kubeNodeService.nodeEntity$.pipe( + map(node => kubeEndpointService.getCaaspNodeData(node)), + ); + + this.caaspNodeDisruptive$ = this.caaspNode$.pipe( + map(node => node.disruptiveUpdates) + ) + + this.caaspNodSecurity$ = this.caaspNode$.pipe( + map(node => node.securityUpdates) + ) + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.html new file mode 100644 index 0000000000..ae2fa2e27d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.html @@ -0,0 +1,10 @@ +
+
+ {{ icons[condition][0] }} +
+ {{ titles[condition] }} +
+
+ +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.scss new file mode 100644 index 0000000000..f9bec88545 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.scss @@ -0,0 +1,21 @@ +.kube-node-condition { + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; + margin-right: 20px; + padding-top: 20px; + + &__title { + align-items: center; + display: flex; + justify-content: center; + + &__span { + margin-left: 10px; + } + } + &__indicator { + min-width: 50px; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.ts new file mode 100644 index 0000000000..010f8acf94 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component.ts @@ -0,0 +1,80 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +import { KubernetesNodeService } from '../../../../../services/kubernetes-node.service'; +import { ConditionType, ConditionTypeLabels, KubernetesCondition } from '../../../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-node-condition', + templateUrl: './kubernetes-node-condition.component.html', + styleUrls: ['./kubernetes-node-condition.component.scss'] +}) +export class KubernetesNodeConditionComponent implements OnInit { + + + @Input() + condition: ConditionType; + condition$: Observable; + hasCondition$: Observable; + + @Input() + overrideCondition$: Observable; + + @Input() + type = 'yes-no' + + @Input() + inverse = false; + + @Input() + subtle = false; + + @Input() + paddingTop = '20px'; + + public titles = ConditionTypeLabels; + + public icons = { + Ready: ['done_outline', 'material-icons'], + OutOfDisk: ['storage', 'material-icons'], + MemoryPressure: ['memory', 'material-icons'], + DiskPressure: ['storage', 'material-icons'], + PIDPressure: ['vertical_align_center', 'material-icons'], + NetworkUnavailable: ['settings_ethernet', 'material-icons'], + CaaspUpdates: ['vertical_align_top', 'material-icons'], + CaaspDisruptive: ['warning', 'material-icons'], + CaaspSecurity: ['security', 'material-icons'] + }; + + constructor( + public kubeNodeService: KubernetesNodeService + ) { } + + ngOnInit() { + this.condition$ = this.overrideCondition$ ? this.overrideCondition$ : this.kubeNodeService.node$.pipe( + filter(p => !!p && !!p.entity), + map(p => p.entity.status.conditions), + map(conditions => conditions.filter(o => o.type === this.condition)), + filter(conditions => !!conditions.length), + map(conditions => this.shouldBeGreen(conditions[0])) + ); + this.hasCondition$ = this.condition$.pipe( + map(() => true) + ) + } + + shouldBeGreen(condition: KubernetesCondition) { + if (condition.status === 'True') { + if (condition.type === ConditionType.Ready) { + return true; + } + return false; + } else if (condition.status === 'False') { + if (condition.type === ConditionType.Ready) { + return false; + } + return true; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.html new file mode 100644 index 0000000000..1876674daa --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.html @@ -0,0 +1,19 @@ +
+ + + Node Information + + +
+ {{ (kubeNodeService.nodeEntity$ | async)?.status.nodeInfo.operatingSystem }} + {{ (kubeNodeService.nodeEntity$ | async)?.status.nodeInfo.architecture }} + {{ (kubeNodeService.nodeEntity$ | async)?.status.nodeInfo.osImage }} + {{ (kubeNodeService.nodeEntity$ | async)?.status.nodeInfo.kernelVersion }} + {{ (kubeNodeService.nodeEntity$ | async)?.status.nodeInfo.kubeletVersion }} + {{ (kubeNodeService.nodeEntity$ | async)?.status.nodeInfo.kubeProxyVersion }} +
+
+
+ +
+ \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.scss new file mode 100644 index 0000000000..eddb307ce7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.scss @@ -0,0 +1,3 @@ +.app-kubernetes-node-info-card{ + height: 100%; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.spec.ts new file mode 100644 index 0000000000..4b45cd03f2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../../../../services/kubernetes-node.service'; +import { KubernetesNodeInfoCardComponent } from './kubernetes-node-info-card.component'; + +describe('KubernetesNodeInfoCardComponent', () => { + let component: KubernetesNodeInfoCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeInfoCardComponent], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid, KubernetesNodeService, KubernetesEndpointService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeInfoCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.ts new file mode 100644 index 0000000000..c4e39b6576 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-info-card/kubernetes-node-info-card.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { KubernetesNodeService } from '../../../../services/kubernetes-node.service'; + +@Component({ + selector: 'app-kubernetes-node-info-card', + templateUrl: './kubernetes-node-info-card.component.html', + styleUrls: ['./kubernetes-node-info-card.component.scss'] +}) +export class KubernetesNodeInfoCardComponent { + constructor( public kubeNodeService: KubernetesNodeService ) {} +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.html new file mode 100644 index 0000000000..c0d7270b8f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.html @@ -0,0 +1,30 @@ +
+ + + General Information + + +
+ {{ (kubeNodeService.nodeEntity$ | async)?.metadata.name }} + + + {{ (kubeNodeService.nodeEntity$ | async)?.metadata.creationTimestamp | date:'medium' }} + + + {{ caaspNode.version }} + + + + + + + + +
+
+
+ +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.scss new file mode 100644 index 0000000000..f3257a9b10 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.scss @@ -0,0 +1,3 @@ +.kubernetes-node-summary-card { + height: 100%; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.spec.ts new file mode 100644 index 0000000000..b0b76ccb65 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../../../../services/kubernetes-node.service'; +import { KubernetesNodeSummaryCardComponent } from './kubernetes-node-summary-card.component'; + +describe('KubernetesNodeSummaryCardComponent', () => { + let component: KubernetesNodeSummaryCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeSummaryCardComponent], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid, KubernetesEndpointService, KubernetesNodeService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeSummaryCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.ts new file mode 100644 index 0000000000..51342da974 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary-card/kubernetes-node-summary-card.component.ts @@ -0,0 +1,43 @@ +import { Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { CaaspNodeData, KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../../../../services/kubernetes-node.service'; + +@Component({ + selector: 'app-kubernetes-node-summary-card', + templateUrl: './kubernetes-node-summary-card.component.html', + styleUrls: ['./kubernetes-node-summary-card.component.scss'] +}) +export class KubernetesNodeSummaryCardComponent { + public caaspVersion$: Observable; + public caaspNode$: Observable; + public caaspNodeUpdates$: Observable; + public caaspNodeDisruptive$: Observable; + public caaspNodeSecurity$: Observable; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public kubeNodeService: KubernetesNodeService + ) { + this.caaspNode$ = this.kubeNodeService.nodeEntity$.pipe( + map(node => { + const nodeData = kubeEndpointService.getCaaspNodeData(node); + return !!nodeData.version ? nodeData : null; + }), + ); + + this.caaspNodeUpdates$ = this.caaspNode$.pipe( + map(node => node.updates) + ) + + this.caaspNodeDisruptive$ = this.caaspNode$.pipe( + map(node => node.disruptiveUpdates) + ) + + this.caaspNodeSecurity$ = this.caaspNode$.pipe( + map(node => node.securityUpdates) + ) + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.html new file mode 100644 index 0000000000..271d010719 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.html @@ -0,0 +1,29 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.spec.ts new file mode 100644 index 0000000000..629d1c742b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.spec.ts @@ -0,0 +1,46 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../../../services/kubernetes-node.service'; +import { + KubernetesNodeConditionCardComponent, +} from './kubernetes-node-condition-card/kubernetes-node-condition-card.component'; +import { + KubernetesNodeConditionComponent, +} from './kubernetes-node-condition-card/kubernetes-node-condition/kubernetes-node-condition.component'; +import { KubernetesNodeInfoCardComponent } from './kubernetes-node-info-card/kubernetes-node-info-card.component'; +import { KubernetesNodeSummaryCardComponent } from './kubernetes-node-summary-card/kubernetes-node-summary-card.component'; +import { KubernetesNodeSummaryComponent } from './kubernetes-node-summary.component'; +import { KubernetesNodeTagsCardComponent } from './kubernetes-node-tags-card/kubernetes-node-tags-card.component'; + +describe('KubernetesNodeSummaryComponent', () => { + let component: KubernetesNodeSummaryComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeSummaryComponent, + KubernetesNodeConditionComponent, + KubernetesNodeConditionCardComponent, + KubernetesNodeSummaryCardComponent, + KubernetesNodeInfoCardComponent, + KubernetesNodeTagsCardComponent, + ], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid, KubernetesEndpointService, KubernetesNodeService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeSummaryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.ts new file mode 100644 index 0000000000..3405e304ce --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-summary.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { KubernetesNodeService } from '../../../services/kubernetes-node.service'; + +@Component({ + selector: 'app-kubernetes-node-summary', + templateUrl: './kubernetes-node-summary.component.html', + styleUrls: ['./kubernetes-node-summary.component.scss'] +}) +export class KubernetesNodeSummaryComponent { + constructor( + public kubeNodeService: KubernetesNodeService + ) { } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.html new file mode 100644 index 0000000000..f327c96f5d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.html @@ -0,0 +1,13 @@ +
+ + + {{ title }} + + +
+ +
+
+
+ +
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.spec.ts new file mode 100644 index 0000000000..95a2a45250 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.spec.ts @@ -0,0 +1,32 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesNodeService } from '../../../../services/kubernetes-node.service'; +import { KubernetesNodeTagsCardComponent } from './kubernetes-node-tags-card.component'; + +describe('KubernetesNodeTagsCardComponent', () => { + let component: KubernetesNodeTagsCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodeTagsCardComponent], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid, KubernetesEndpointService, KubernetesNodeService], + + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodeTagsCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.ts new file mode 100644 index 0000000000..347734d3eb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-node-summary/kubernetes-node-tags-card/kubernetes-node-tags-card.component.ts @@ -0,0 +1,41 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { AppChip } from '../../../../../../../core/src/shared/components/chips/chips.component'; +import { KubernetesNodeService } from '../../../../services/kubernetes-node.service'; + +@Component({ + selector: 'app-kubernetes-node-tags-card', + templateUrl: './kubernetes-node-tags-card.component.html', + styleUrls: ['./kubernetes-node-tags-card.component.scss'] +}) +export class KubernetesNodeTagsCardComponent implements OnInit { + + + @Input() + mode: string; + + @Input() + title: string; + + chipTags$: Observable; + + constructor( + public kubeNodeService: KubernetesNodeService + ) { } + + ngOnInit() { + this.chipTags$ = this.kubeNodeService.nodeEntity$.pipe( + map(node => this.getTags(node.metadata[this.mode])), + ); + } + + + private getTags(tags: {}) { + const labelEntries = Object.entries(tags); + return labelEntries.map(t => ({ + value: `${t[0]}:${t[1]}` + })); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-nodes-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-nodes-data-source.ts new file mode 100644 index 0000000000..2550ea4e65 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-nodes-data-source.ts @@ -0,0 +1,36 @@ +import { Store } from '@ngrx/store'; + +import { + DataFunction, + DataFunctionDefinition, + ListDataSource, +} from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { getPaginationKey } from '../../../../../store/src/actions/pagination.actions'; +import { AppState } from '../../../../../store/src/public-api'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { kubernetesNodesEntityType } from '../../kubernetes-entity-factory'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesNode } from '../../store/kube.types'; + +export class KubernetesNodesDataSource extends ListDataSource { + + constructor( + store: Store, + kubeGuid: BaseKubeGuid, + listConfig: IListConfig, + transformEntities: (DataFunction | DataFunctionDefinition)[] + ) { + const action = kubeEntityCatalog.node.actions.getMultiple(kubeGuid.guid); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (row) => action.entity[0].getId(row), + paginationKey: getPaginationKey(kubernetesNodesEntityType, kubeGuid.guid), + isLocal: true, + listConfig, + transformEntities + }); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-nodes-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-nodes-list-config.service.ts new file mode 100644 index 0000000000..8fa1986be2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/kubernetes-nodes-list-config.service.ts @@ -0,0 +1,169 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { DataFunction } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { + IListConfig, + IListFilter, + ListViewTypes, +} from '../../../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../../../store/src/public-api'; +import { PaginationEntityState } from '../../../../../store/src/types/pagination.types'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { ConditionType, KubernetesAddressExternal, KubernetesAddressInternal, KubernetesNode } from '../../store/kube.types'; +import { defaultHelmKubeListPageSize } from '../kube-helm-list-types'; +import { createKubeAgeColumn, getConditionSort } from '../kube-list.helper'; +import { ConditionCellComponent } from './condition-cell/condition-cell.component'; +import { KubernetesNodeCapacityComponent } from './kubernetes-node-capacity/kubernetes-node-capacity.component'; +import { KubernetesNodeIpsComponent } from './kubernetes-node-ips/kubernetes-node-ips.component'; +import { KubernetesNodeLabelsComponent } from './kubernetes-node-labels/kubernetes-node-labels.component'; +import { KubernetesNodeLinkComponent } from './kubernetes-node-link/kubernetes-node-link.component'; +import { KubernetesNodePressureComponent } from './kubernetes-node-pressure/kubernetes-node-pressure.component'; +import { KubernetesNodesDataSource } from './kubernetes-nodes-data-source'; +import { NodePodCountComponent } from './node-pod-count/node-pod-count.component'; + +export enum KubernetesNodesListFilterKeys { + NAME = 'name', + IP_ADDRESS = 'ip-address', + LABELS = 'labels' +} + +@Injectable() +export class KubernetesNodesListConfigService implements IListConfig { + dataSource: KubernetesNodesDataSource; + + columns: Array> = [ + { + columnId: 'name', headerCell: () => 'Name', + cellComponent: KubernetesNodeLinkComponent, + sort: { + type: 'sort', + orderKey: 'name', + field: 'metadata.name' + }, + cellFlex: '5', + }, + { + columnId: 'ips', headerCell: () => 'IPs', + cellComponent: KubernetesNodeIpsComponent, + cellFlex: '1', + }, + { + columnId: 'labels', headerCell: () => 'Labels', + cellComponent: KubernetesNodeLabelsComponent, + cellFlex: '1', + }, + { + columnId: 'ready', headerCell: () => 'Ready', + cellConfig: { + conditionType: ConditionType.Ready + }, + cellComponent: ConditionCellComponent, + sort: getConditionSort(ConditionType.Ready), + cellFlex: '2', + }, + { + columnId: 'condition', headerCell: () => 'Condition', + cellComponent: KubernetesNodePressureComponent, + cellFlex: '2', + }, + { + columnId: 'numPods', headerCell: () => 'Pods', + cellComponent: NodePodCountComponent, + cellFlex: '2', + }, + { + columnId: 'capacity', headerCell: () => 'Capacity', + cellComponent: KubernetesNodeCapacityComponent, + cellFlex: '3', + }, + // Display labels as the usual chip list + // { + // columnId: 'labels', headerCell: () => 'Labels', + // cellComponent: KubernetesLabelsCellComponent, + // cellFlex: '6', + // }, + createKubeAgeColumn() + ]; + filters: IListFilter[] = [ + { + default: true, + key: KubernetesNodesListFilterKeys.NAME, + label: 'Name', + placeholder: 'Filter by Name' + }, + { + key: KubernetesNodesListFilterKeys.LABELS, + label: 'Labels', + placeholder: 'Filter by Labels' + }, + { + key: KubernetesNodesListFilterKeys.IP_ADDRESS, + label: 'IP Address', + placeholder: 'Filter by IP Address' + } + ]; + + pageSizeOptions = defaultHelmKubeListPageSize; + viewType = ListViewTypes.TABLE_ONLY; + + enableTextFilter = true; + text = { + filter: '', + noEntries: 'There are no nodes' + }; + + getGlobalActions = () => null; + getMultiActions = () => []; + getSingleActions = () => []; + getColumns = () => this.columns; + getDataSource = () => this.dataSource; + getMultiFiltersConfigs = () => []; + getFilters = (): IListFilter[] => this.filters; + + constructor( + store: Store, + kubeId: BaseKubeGuid, + ) { + const transformEntities: DataFunction[] = [ + (entities: KubernetesNode[], paginationState: PaginationEntityState) => { + if (!paginationState.clientPagination.filter.string) { + return entities; + } + + const filterString = paginationState.clientPagination.filter.string.toUpperCase(); + + const filterKey = paginationState.clientPagination.filter.filterKey; + + switch (filterKey) { + case KubernetesNodesListFilterKeys.IP_ADDRESS: + return entities.filter(node => { + const ipAddress = + node.status.addresses.find(address => address.type === KubernetesAddressInternal) || + node.status.addresses.find(address => address.type === KubernetesAddressExternal); + return ipAddress ? ipAddress.address.toUpperCase().includes(filterString) : false; + }); + + case KubernetesNodesListFilterKeys.LABELS: + return entities.filter(node => { + return Object.entries(node.metadata.labels).some(([label, value]) => { + label = label.toUpperCase(); + value = value.toUpperCase(); + return label.includes(filterString) || value.includes(filterString); + }); + }); + + case KubernetesNodesListFilterKeys.NAME: + return entities.filter(node => { + return node.metadata.name.toUpperCase().includes(filterString); + }); + default: + return entities; + } + } + ]; + + this.dataSource = new KubernetesNodesDataSource(store, kubeId, this, transformEntities); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.html new file mode 100644 index 0000000000..b39b6b7527 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.html @@ -0,0 +1,3 @@ +
+ {{ podCount$ | async }} +
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.scss new file mode 100644 index 0000000000..d82f191cdb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.scss @@ -0,0 +1,3 @@ +.node-pod-count { + font-size: 18px; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.spec.ts new file mode 100644 index 0000000000..238a27d291 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.spec.ts @@ -0,0 +1,29 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { NodePodCountComponent } from './node-pod-count.component'; + +describe('NodePodCountComponent', () => { + let component: NodePodCountComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [NodePodCountComponent], + imports: KubernetesBaseTestModules, + providers: [KubeBaseGuidMock, KubernetesEndpointService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NodePodCountComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.ts new file mode 100644 index 0000000000..b59c679be9 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-nodes/node-pod-count/node-pod-count.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesNode } from '../../../store/kube.types'; + +@Component({ + selector: 'app-node-pod-count', + templateUrl: './node-pod-count.component.html', + styleUrls: ['./node-pod-count.component.scss'] +}) +export class NodePodCountComponent extends TableCellCustom implements OnInit { + podCount$: Observable; + + constructor( + private kubeEndpointService: KubernetesEndpointService + ) { + super(); + } + + ngOnInit() { + + this.podCount$ = this.kubeEndpointService.pods$.pipe( + map(pods => pods.filter(p => p.spec.nodeName === this.row.metadata.name)), + map(p => `${p.length} / ${this.row.status.capacity.pods}`) + ); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.html new file mode 100644 index 0000000000..6d265bc99c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.html @@ -0,0 +1,45 @@ +
+
+
+ + {{ iconDetail.icon }} +
+
+
+ + + + {{container.container.name}} + +
+
+
+ + Image: + + + {{container.container.image}} + +
+
+ + State: + + + {{container.status}} + +
+
+ + Probes (L:R): + + + {{container.container.livenessProbe ? 'on' : 'off'}}:{{container.container.readinessProbe ? 'on' : 'off'}} + +
+
+
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.scss new file mode 100644 index 0000000000..d6f9565d71 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.scss @@ -0,0 +1,61 @@ +$detail-padding: 28px; + +.pod-containers { + display: flex; + flex-direction: column; + padding-left: 54px; + &__container { + display: flex; + flex-direction: row; + height: 100px; + &__icon { + align-items: center; + display: flex; + height: 60px; + width: 50px; + mat-icon { + font-size: 35px; + height: 35px; + width: 35px; + } + .container-icon { + padding-left: 4px; + } + } + + &__details { + display: flex; + flex-direction: column; + min-width: 75%; + + &--header { + display: flex; + flex-direction: row; + font-size: 15px; + font-weight: 500; + margin-top: 10px; + app-boolean-indicator { + width: $detail-padding; + } + } + &--content { + font-size: 13px; + margin-left: $detail-padding; + padding-bottom: 10px; + & > div { + display: flex; + flex-direction: row; + } + span:first-of-type { + min-width: 90px; + } + } + } + + &:not(:last-of-type) { + .pod-containers__container__details { + border-bottom: 2px solid rgba(0, 0, 0, .1); + } + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.spec.ts new file mode 100644 index 0000000000..ff465a15c8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.spec.ts @@ -0,0 +1,36 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesPod } from '../../../store/kube.types'; +import { KubernetesPodContainersComponent } from './kubernetes-pod-containers.component'; + +describe('KubernetesPodContainersComponent', () => { + let component: KubernetesPodContainersComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesPodContainersComponent], + imports: KubernetesBaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesPodContainersComponent); + component = fixture.componentInstance; + component.row = { + metadata: { + uid: '' + }, + status: { + + } + } as KubernetesPod; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.ts new file mode 100644 index 0000000000..dcbfd41b47 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-containers/kubernetes-pod-containers.component.ts @@ -0,0 +1,120 @@ +import { TitleCasePipe } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import moment from 'moment'; +import { Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +import { + BooleanIndicatorType, +} from '../../../../../../core/src/shared/components/boolean-indicator/boolean-indicator.component'; +import { + TableCellBooleanIndicatorComponentConfig, +} from '../../../../../../core/src/shared/components/list/list-table/table-cell-boolean-indicator/table-cell-boolean-indicator.component'; +import { CardCell } from '../../../../../../core/src/shared/components/list/list.types'; +import { kubeEntityCatalog } from '../../../kubernetes-entity-catalog'; +import { Container, ContainerState, ContainerStatus, InitContainer, KubernetesPod } from '../../../store/kube.types'; + +export interface ContainerForTable { + isInit: boolean; + container: Container | InitContainer; + containerStatus: ContainerStatus; + status: string; +} + +@Component({ + selector: 'app-kubernetes-pod-containers', + templateUrl: './kubernetes-pod-containers.component.html', + styleUrls: ['./kubernetes-pod-containers.component.scss'], + providers: [ + TitleCasePipe + ] +}) +export class KubernetesPodContainersComponent extends CardCell { + + public containers$: Observable; + public icon = { + false: { + icon: 'container', + font: 'stratos-icons', + tooltip: 'Container' + }, + true: { + icon: 'border_clear', + font: '', + tooltip: 'Init Container' + } + }; + + public readyBoolConfig: TableCellBooleanIndicatorComponentConfig = { + isEnabled: (row: ContainerForTable) => row.containerStatus.ready, + type: BooleanIndicatorType.yesNo, + subtle: false, + showText: false + }; + + @Input() + set row(row: KubernetesPod) { + if (!row || !!this.containers$) { + return; + } + const id = kubeEntityCatalog.pod.getSchema().getId(row); + this.containers$ = kubeEntityCatalog.pod.store.getEntityMonitor(id).entity$.pipe( + filter(pod => !!pod), + map(pod => this.map(pod)), + ); + } + + constructor( + private titleCase: TitleCasePipe, + ) { + super(); + } + + private getState(containerStatus: ContainerStatus) { + if (!containerStatus.state) { + return 'Unknown'; + } + const entries = Object.entries(containerStatus.state); + if (!entries.length) { + return 'Unknown'; + } + const sorted = entries.sort((a, b) => { + const aStarted = moment(a[1].startedAt); + const bStarted = moment(b[1].startedAt); + + return aStarted.isBefore(bStarted) ? -1 : + aStarted.isAfter(bStarted) ? 1 : 0; + + }); + return this.containerStatusToString(sorted[0][0], sorted[0][1]); + } + + private map(row: KubernetesPod): ContainerForTable[] { + const containerStatus = row.status.containerStatuses || []; + const initContainerStatuses = row.status.initContainerStatuses || []; + const containerStatusWithContainers: ContainerForTable[] = [ + ...containerStatus.map(c => this.createContainerForTable(c, row.spec.containers)), + ...initContainerStatuses.map(c => this.createContainerForTable(c, row.spec.initContainers, true)) + ]; + return containerStatusWithContainers.sort((a, b) => a.container.name.localeCompare(b.container.name)); + } + + private containerStatusToString(state: string, status: ContainerState): string { + const exitCode = status.exitCode ? `:${status.exitCode}` : ''; + const signal = status.signal ? `:${status.signal}` : ''; + const reason = status.reason ? ` (${status.reason}${exitCode || signal})` : ''; + return `${this.titleCase.transform(state)}${reason}`; + } + + private createContainerForTable(containerStatus: ContainerStatus, containers: (Container | InitContainer)[], isInit = false): + ContainerForTable { + const containerForTable: ContainerForTable = { + isInit, + containerStatus, + container: containers.find(c => c.name === containerStatus.name), + status: this.getState(containerStatus) + }; + return containerForTable; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.html new file mode 100644 index 0000000000..49403a09ed --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.html @@ -0,0 +1 @@ +
{{ row.expandedStatus.status }}
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.scss new file mode 100644 index 0000000000..c385ef5762 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.scss @@ -0,0 +1,7 @@ +.pod-status { + display: inline; + border-radius: 3px; + border-style: solid; + border-width: 1px; + padding: 2px 4px; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.spec.ts new file mode 100644 index 0000000000..3b4aa18faa --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.spec.ts @@ -0,0 +1,37 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesPod, KubernetesStatus } from '../../../store/kube.types'; +import { KubernetesPodStatusComponent } from './kubernetes-pod-status.component'; + +describe('KubernetesPodStatusComponent', () => { + let component: KubernetesPodStatusComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + KubernetesPodStatusComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesPodStatusComponent); + component = fixture.componentInstance; + component.row = { + status: { + phase: KubernetesStatus.FAILED, + }, + spec: { + containers: [] + }, + expandedStatus: {} + } as KubernetesPod; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.ts new file mode 100644 index 0000000000..37fab13a02 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-status/kubernetes-pod-status.component.ts @@ -0,0 +1,45 @@ +import { Component, Input } from '@angular/core'; + +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubernetesPodExpandedStatusTypes } from '../../../services/kubernetes-expanded-state'; +import { KubernetesPod } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-pod-status', + templateUrl: './kubernetes-pod-status.component.html', + styleUrls: ['./kubernetes-pod-status.component.scss'] +}) +export class KubernetesPodStatusComponent extends TableCellCustom { + + public style = 'border-success'; + + private pRow: KubernetesPod; + @Input('row') + get row(): KubernetesPod { return this.pRow; } + set row(row: KubernetesPod) { + this.pRow = row; + if (row) { + this.updateStatus(); + } + } + + private updateStatus() { + const status = this.convertStatus(this.row.expandedStatus.status); + this.style = `border-${status} text-${status}`; + } + + private convertStatus(status: string): string { + if (!status) { + return 'tentative'; + } + // Everything is fine + if ( + status === KubernetesPodExpandedStatusTypes.RUNNING || + status === KubernetesPodExpandedStatusTypes.COMPLETED) { + return 'success'; + } + // Everything else... probably some kind of issue (still coming up or failed) + // Includes pods with init containers in anything other than terminated with exit code = 0 + return 'warning'; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.html new file mode 100644 index 0000000000..45d06ab580 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.html @@ -0,0 +1 @@ + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.spec.ts new file mode 100644 index 0000000000..01d73f4751 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.spec.ts @@ -0,0 +1,40 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesStatus } from '../../../store/kube.types'; +import { KubernetesPodTagsComponent } from './kubernetes-pod-tags.component'; + +describe('KubernetesPodTagsComponent', () => { + let component: KubernetesPodTagsComponent; + let fixture: ComponentFixture>; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesPodTagsComponent], + imports: KubernetesBaseTestModules + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesPodTagsComponent); + component = fixture.componentInstance; + component.row = { + spec: {}, + status: { + phase: KubernetesStatus.RUNNING + }, + metadata: { + namespace: 'test', + name: 'test', + uid: 'test', + labels: {} + } + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.ts new file mode 100644 index 0000000000..f1b28c19a0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pod-tags/kubernetes-pod-tags.component.ts @@ -0,0 +1,34 @@ +import { Component, OnInit } from '@angular/core'; + +import { AppChip } from '../../../../../../core/src/shared/components/chips/chips.component'; +import { TableCellCustom } from '../../../../../../core/src/shared/components/list/list.types'; +import { KubeAPIResource, PodLabel } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-pod-tags', + templateUrl: './kubernetes-pod-tags.component.html', + styleUrls: ['./kubernetes-pod-tags.component.scss'] +}) +export class KubernetesPodTagsComponent extends TableCellCustom implements OnInit { + + tags: AppChip[] = []; + + constructor() { + super(); + } + + ngOnInit() { + const labels = this.row.metadata.labels; + for (const label in labels) { + if (labels.hasOwnProperty(label)) { + this.tags.push({ + value: `${label}:${labels[label]}`, + key: { + key: label, + value: labels[label] + } + }); + } + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pods-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pods-data-source.ts new file mode 100644 index 0000000000..dd6b22afcb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pods-data-source.ts @@ -0,0 +1,30 @@ +import { Store } from '@ngrx/store'; + +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../../../store/src/public-api'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesPod } from '../../store/kube.types'; + +export class KubernetesPodsDataSource extends ListDataSource { + + constructor( + store: Store, + kubeGuid: BaseKubeGuid, + listConfig: IListConfig + ) { + const action = kubeEntityCatalog.pod.actions.getMultiple(kubeGuid.guid); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (row) => action.entity[0].getId(row), + paginationKey: action.paginationKey, + isLocal: true, + listConfig, + transformEntities: [{ type: 'filter', field: 'metadata.name' }] + }); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pods-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pods-list-config.service.ts new file mode 100644 index 0000000000..6534cf9b8a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-pods/kubernetes-pods-list-config.service.ts @@ -0,0 +1,167 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { + IListDataSource, +} from 'frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { of } from 'rxjs'; + +import { + TableCellSidePanelComponent, + TableCellSidePanelConfig, +} from '../../../../../core/src/shared/components/list/list-table/table-cell-side-panel/table-cell-side-panel.component'; +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { IListConfig, ListViewTypes } from '../../../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../../../store/src/public-api'; +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { + KubernetesResourceViewerComponent, + KubernetesResourceViewerConfig, +} from '../../kubernetes-resource-viewer/kubernetes-resource-viewer.component'; +import { KubernetesPod } from '../../store/kube.types'; +import { defaultHelmKubeListPageSize } from '../kube-helm-list-types'; +import { createKubeAgeColumn } from '../kube-list.helper'; +import { KubernetesPodContainersComponent } from './kubernetes-pod-containers/kubernetes-pod-containers.component'; +import { KubernetesPodStatusComponent } from './kubernetes-pod-status/kubernetes-pod-status.component'; +import { KubernetesPodsDataSource } from './kubernetes-pods-data-source'; + +export abstract class BaseKubernetesPodsListConfigService implements IListConfig { + + static namespaceColumnId = 'namespace'; + static nodeColumnId = 'node'; + public showNamespaceLink = true; + + constructor( + private kubeId: string, + hideColumns: string[] = [ + BaseKubernetesPodsListConfigService.namespaceColumnId, + BaseKubernetesPodsListConfigService.nodeColumnId + ] + ) { + if (hideColumns && hideColumns.filter.length) { + this.columns = this.columns.filter(column => hideColumns.indexOf(column.columnId) < 0); + } + } + + columns: Array> = [ + // Name + { + columnId: 'name', headerCell: () => 'Name', + cellComponent: TableCellSidePanelComponent, + sort: { + type: 'sort', + orderKey: 'name', + field: 'metadata.name' + }, + cellFlex: '3', + cellConfig: (pod): TableCellSidePanelConfig => ({ + text: pod.metadata.name, + sidePanelComponent: KubernetesResourceViewerComponent, + sidePanelConfig: { + title: pod.metadata.name, + resourceKind: 'pod', + resource$: of(pod) + } + }) + }, + // TODO: See #150 - keep out RC bring back after demo + // { + // columnId: 'tags', headerCell: () => 'Tags', + // cellComponent: KubernetesPodTagsComponent, + // cellFlex: '5', + // }, + // Namespace + { + columnId: BaseKubernetesPodsListConfigService.namespaceColumnId, headerCell: () => 'Namespace', + cellDefinition: { + valuePath: 'metadata.namespace', + getLink: row => this.showNamespaceLink ? `/kubernetes/${this.kubeId}/namespaces/${row.metadata.namespace}` : null + }, + sort: { + type: 'sort', + orderKey: BaseKubernetesPodsListConfigService.namespaceColumnId, + field: 'metadata.namespace' + }, + cellFlex: '2', + }, + // Node + { + columnId: BaseKubernetesPodsListConfigService.nodeColumnId, headerCell: () => 'Node', + cellDefinition: { + valuePath: 'spec.nodeName', + getLink: pod => `/kubernetes/${this.kubeId}/nodes/${pod.spec.nodeName}/summary` + }, + sort: { + type: 'sort', + orderKey: BaseKubernetesPodsListConfigService.nodeColumnId, + field: 'spec.nodeName' + }, + cellFlex: '2', + }, + { + columnId: 'ready', + headerCell: () => 'Ready', + cellDefinition: { + getValue: pod => `${pod.expandedStatus.readyContainers}/${pod.expandedStatus.totalContainers}` + }, + cellFlex: '1' + }, + { + columnId: 'expandedStatus', + headerCell: () => 'Status', + cellComponent: KubernetesPodStatusComponent, + sort: { + type: 'sort', + orderKey: 'expandedStatus', + field: 'expandedStatus.status' + }, + cellFlex: '2' + }, + { + columnId: 'restarts', + headerCell: () => 'Restarts', + cellDefinition: { + getValue: pod => pod.expandedStatus.restarts.toString() + }, + sort: { + type: 'sort', + orderKey: 'restarts', + field: 'expandedStatus.restarts' + }, + cellFlex: '1' + }, + createKubeAgeColumn() + ]; + + pageSizeOptions = defaultHelmKubeListPageSize; + viewType = ListViewTypes.TABLE_ONLY; + enableTextFilter = true; + text = { + filter: 'Filter by Name', + noEntries: 'There are no pods' + }; + abstract getDataSource: () => IListDataSource; + expandComponent = KubernetesPodContainersComponent; + + getGlobalActions = () => null; + getMultiActions = () => []; + getSingleActions = () => []; + getColumns = () => this.columns; + getMultiFiltersConfigs = () => []; +} + +@Injectable() +export class KubernetesPodsListConfigService extends BaseKubernetesPodsListConfigService { + private podsDataSource: KubernetesPodsDataSource; + + getDataSource = () => this.podsDataSource; + + constructor( + store: Store, + kubeId: BaseKubeGuid, + ) { + super(kubeId.guid, []); + this.podsDataSource = new KubernetesPodsDataSource(store, kubeId, this); + } + +} + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.html new file mode 100644 index 0000000000..51391122e0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.html @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + +
NameProtocolPortTarget PortNode Port
{{port.name}}{{port.protocol}}{{port.port}}{{port.targetPort}}{{port.nodePort}}
+
+- \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.scss new file mode 100644 index 0000000000..2aa856a7c5 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.scss @@ -0,0 +1,11 @@ +th { + font-style: italic; + font-weight: 400; +} + +th, +td { + padding-right: 15px; + text-align: left; +} + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.spec.ts new file mode 100644 index 0000000000..3beaf03a86 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesServicePortsComponent } from './kubernetes-service-ports.component'; + +describe('KubernetesServicePortsComponent', () => { + let component: KubernetesServicePortsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesServicePortsComponent] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesServicePortsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.ts new file mode 100644 index 0000000000..2d1defd4de --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-service-ports/kubernetes-service-ports.component.ts @@ -0,0 +1,13 @@ +import { Component, Input } from '@angular/core'; + +import { CardCell } from '../../../../../core/src/shared/components/list/list.types'; +import { KubeService } from '../../store/kube.types'; + +@Component({ + selector: 'app-kubernetes-service-ports', + templateUrl: './kubernetes-service-ports.component.html', + styleUrls: ['./kubernetes-service-ports.component.scss'] +}) +export class KubernetesServicePortsComponent extends CardCell { + @Input() row: KubeService; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.html b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.html new file mode 100644 index 0000000000..72b4959df7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.html @@ -0,0 +1,19 @@ + + + {{ row?.metadata.name }} + + + Cluster IP + {{row?.spec.clusterIP}} + + + Port Type + {{row?.spec.type}} + + + Ports + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.scss new file mode 100644 index 0000000000..139597f9cb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.scss @@ -0,0 +1,2 @@ + + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.spec.ts new file mode 100644 index 0000000000..d689c5baf0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesServicePortsComponent } from '../../kubernetes-service-ports/kubernetes-service-ports.component'; +import { KubeServiceCardComponent } from './kubernetes-service-card.component'; + + + +describe('KubeServiceCardComponent', () => { + let component: KubeServiceCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + KubeServiceCardComponent, + KubernetesServicePortsComponent, + ], + imports: [...KubernetesBaseTestModules], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubeServiceCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.ts new file mode 100644 index 0000000000..fb6e81d43c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-card/kubernetes-service-card.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; +import { CardCell } from 'frontend/packages/core/src/shared/components/list/list.types'; + +import { KubeService } from '../../../store/kube.types'; + +@Component({ + selector: 'app-kube-service-card', + templateUrl: './kubernetes-service-card.component.html', + styleUrls: ['./kubernetes-service-card.component.scss'] +}) +export class KubeServiceCardComponent extends CardCell { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-list-config.service.ts new file mode 100644 index 0000000000..6c861ff29c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-service-list-config.service.ts @@ -0,0 +1,81 @@ +import { of } from 'rxjs'; + +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { + TableCellSidePanelComponent, + TableCellSidePanelConfig, +} from '../../../../../core/src/shared/components/list/list-table/table-cell-side-panel/table-cell-side-panel.component'; +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { IListConfig, ListViewTypes } from '../../../../../core/src/shared/components/list/list.component.types'; +import { + KubernetesResourceViewerComponent, + KubernetesResourceViewerConfig, +} from '../../kubernetes-resource-viewer/kubernetes-resource-viewer.component'; +import { KubeService } from '../../store/kube.types'; +import { defaultHelmKubeListPageSize } from '../kube-helm-list-types'; +import { createKubeAgeColumn } from '../kube-list.helper'; +import { KubernetesServicePortsComponent } from '../kubernetes-service-ports/kubernetes-service-ports.component'; +import { KubeServiceCardComponent } from './kubernetes-service-card/kubernetes-service-card.component'; + +export abstract class BaseKubernetesServicesListConfig implements IListConfig { + columns: Array> = [ + { + columnId: 'name', headerCell: () => 'Name', + cellComponent: TableCellSidePanelComponent, + sort: { + type: 'sort', + orderKey: 'name', + field: 'metadata.name' + }, + cellFlex: '4', + cellConfig: (service): TableCellSidePanelConfig => ({ + text: service.metadata.name, + sidePanelComponent: KubernetesResourceViewerComponent, + sidePanelConfig: { + title: service.metadata.name, + resource$: of(service), + resourceKind: 'service' + } + }) + }, + { + columnId: 'clusterIp', + headerCell: () => 'Cluster IP', + cellDefinition: { + valuePath: 'spec.clusterIP' + }, + cellFlex: '2' + }, + { + columnId: 'portType', + headerCell: () => 'Port Type', + cellDefinition: { + valuePath: 'spec.type' + }, + cellFlex: '2' + }, + { + columnId: 'Ports', + headerCell: () => 'Ports', + cellComponent: KubernetesServicePortsComponent, + cellFlex: '4' + }, + createKubeAgeColumn() + ]; + + pageSizeOptions = defaultHelmKubeListPageSize; + cardComponent = KubeServiceCardComponent; + viewType = ListViewTypes.BOTH; + enableTextFilter = true; + text = { + filter: 'Filter by Name', + noEntries: 'There are no services' + }; + getDataSource: () => ListDataSource; + + getGlobalActions = () => null; + getMultiActions = () => []; + getSingleActions = () => []; + getColumns = () => this.columns; + getMultiFiltersConfigs = () => []; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-services-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-services-data-source.ts new file mode 100644 index 0000000000..e973ca1f75 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/list-types/kubernetes-services/kubernetes-services-data-source.ts @@ -0,0 +1,31 @@ +import { Store } from '@ngrx/store'; +import { OperatorFunction } from 'rxjs'; + +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { AppState } from '../../../../../store/src/public-api'; +import { PaginatedAction } from '../../../../../store/src/types/pagination.types'; +import { KubeService } from '../../store/kube.types'; + +export class BaseKubernetesServicesDataSource extends ListDataSource { + + constructor( + store: Store, + action: PaginatedAction, + listConfig: IListConfig, + transformEntity: OperatorFunction = null + ) { + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (row) => action.entity[0].getId(row), + paginationKey: action.paginationKey, + transformEntity, + isLocal: true, + listConfig, + transformEntities: [{ type: 'filter', field: 'metadata.name' }] + }); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.html b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.html new file mode 100644 index 0000000000..261e2aa33c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.html @@ -0,0 +1,10 @@ + +

{{ podName }}

+
+
+
+ + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.scss new file mode 100644 index 0000000000..882d178c00 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.scss @@ -0,0 +1,5 @@ +.helm-release-pod { + display: flex; + flex-direction: column; + justify-content: space-around; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.spec.ts new file mode 100644 index 0000000000..cbf4e5677f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.spec.ts @@ -0,0 +1,44 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { TabNavService } from '../../../../core/src/tab-nav.service'; +import { KubernetesBaseTestModules } from '../kubernetes.testing.module'; +import { PodMetricsComponent } from './pod-metrics.component'; + + +describe('PodMetricsComponent', () => { + let component: PodMetricsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [PodMetricsComponent], + imports: KubernetesBaseTestModules, + providers: [ + TabNavService, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + params: { + endpointId: 'anything' + }, + queryParams: {} + } + } + } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PodMetricsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.ts new file mode 100644 index 0000000000..6d0801f813 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/pod-metrics/pod-metrics.component.ts @@ -0,0 +1,168 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { getIdFromRoute } from '../../../../core/src/core/utils.service'; +import { MetricsConfig } from '../../../../core/src/shared/components/metrics-chart/metrics-chart.component'; +import { MetricsLineChartConfig } from '../../../../core/src/shared/components/metrics-chart/metrics-chart.types'; +import { + ChartDataTypes, + getMetricsChartConfigBuilder, +} from '../../../../core/src/shared/components/metrics-chart/metrics.component.helpers'; +import { IHeaderBreadcrumb } from '../../../../core/src/shared/components/page-header/page-header.types'; +import { EntityInfo } from '../../../../store/src/types/api.types'; +import { ChartSeries, IMetricMatrixResult } from '../../../../store/src/types/base-metric.types'; +import { IMetricApplication } from '../../../../store/src/types/metric.types'; +import { kubeEntityCatalog } from '../kubernetes-entity-catalog'; +import { formatAxisCPUTime, formatCPUTime } from '../kubernetes-metrics.helpers'; +import { BaseKubeGuid } from '../kubernetes-page.types'; +import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; +import { KubernetesService } from '../services/kubernetes.service'; +import { KubernetesPod } from '../store/kube.types'; +import { FetchKubernetesMetricsAction } from '../store/kubernetes.actions'; + +@Component({ + selector: 'app-pod-metrics', + templateUrl: './pod-metrics.component.html', + styleUrls: ['./pod-metrics.component.scss'], + providers: [ + { + provide: BaseKubeGuid, + useFactory: (activatedRoute: ActivatedRoute) => { + return { + guid: activatedRoute.snapshot.params.endpointId + }; + }, + deps: [ + ActivatedRoute + ] + }, + KubernetesService, + KubernetesEndpointService + ] +}) +export class PodMetricsComponent { + podName: string; + podEntity$: Observable>; + namespaceName: any; + public breadcrumbs$: Observable; + + public instanceMetricConfigs: [ + MetricsConfig>, + MetricsLineChartConfig + ][]; + + constructor( + public activatedRoute: ActivatedRoute, + public kubeEndpointService: KubernetesEndpointService + ) { + this.podName = activatedRoute.snapshot.params.podName; + this.namespaceName = getIdFromRoute(activatedRoute, 'namespaceName'); + const namespace = getIdFromRoute(activatedRoute, 'namespace') ? getIdFromRoute(activatedRoute, 'namespace') : this.namespaceName; + const chartConfigBuilder = getMetricsChartConfigBuilder(result => `${result.metric.container}`); + const cpuChartConfigBuilder = getMetricsChartConfigBuilder + (result => !!result.metric.cpu ? `${result.metric.container}:${result.metric.cpu}` : `${result.metric.container}`); + const networkChartConfigBuilder = getMetricsChartConfigBuilder + (result => `Network Interface: ${result.metric.interface}`); + this.instanceMetricConfigs = [ + chartConfigBuilder( + new FetchKubernetesMetricsAction( + this.podName, + kubeEndpointService.kubeGuid, + `container_memory_usage_bytes{pod="${this.podName}",namespace="${namespace}"}` + ), + 'Memory Usage (MB)', + ChartDataTypes.BYTES, + (series: ChartSeries[]) => { + // Remove the metric series for pod overhead and for the total! + return series.filter(s => !!s.metadata.container && s.metadata.container !== 'POD'); + }, + null, + (value: string) => value + ' MB' + ), + cpuChartConfigBuilder( + new FetchKubernetesMetricsAction( + this.podName, + kubeEndpointService.kubeGuid, + `container_cpu_usage_seconds_total{pod="${this.podName}",namespace="${namespace}"}` + ), + 'CPU Usage', + ChartDataTypes.CPU_TIME, + (series: ChartSeries[]) => { + return series.filter(s => !!s.metadata.container && s.metadata.container !== 'POD'); + }, + (tick: string) => formatAxisCPUTime(tick), + (value: string) => formatCPUTime(value), + ), + networkChartConfigBuilder( + new FetchKubernetesMetricsAction( + this.podName, + kubeEndpointService.kubeGuid, + `container_network_transmit_bytes_total{pod="${this.podName}",namespace="${namespace}"}` + ), + 'Cumulative Data transmitted (MB)', + ChartDataTypes.BYTES, + null, + null, + (value: string) => value + ' MB' + ), + networkChartConfigBuilder( + new FetchKubernetesMetricsAction( + this.podName, + kubeEndpointService.kubeGuid, + `container_network_receive_bytes_total{pod="${this.podName}",namespace="${namespace}"}` + ), + 'Cumulative Data received (MB)', + ChartDataTypes.BYTES, + null, + null, + (value: string) => value + ' MB' + ) + ]; + + + this.breadcrumbs$ = kubeEndpointService.endpoint$.pipe( + map(endpoint => { + + // check if this is being invoked from the node path + const nodeName = getIdFromRoute(activatedRoute, 'nodeName'); + if (!!nodeName) { + return [{ + breadcrumbs: [ + { value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}/nodes` }, + { value: nodeName, routerLink: `/kubernetes/${endpoint.entity.guid}/nodes/${nodeName}/pods` }, + ] + }]; + } + // check if this is being invoked from the namespace path + if (!!this.namespaceName) { + return [{ + breadcrumbs: [ + { value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}/namespaces` }, + { value: this.namespaceName, routerLink: `/kubernetes/${endpoint.entity.guid}/namespaces/${this.namespaceName}/pods` }, + ] + }]; + } + // Finally, check if this is being invoked from the helm-release path + const releaseName = getIdFromRoute(activatedRoute, 'releaseName'); + if (!!releaseName) { + return [{ + breadcrumbs: [ + { value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}/apps` }, + { value: releaseName, routerLink: `/kubernetes/${endpoint.entity.guid}/apps/${releaseName}/pods` }, + ] + }]; + } + return [{ + breadcrumbs: [ + { value: endpoint.entity.name, routerLink: `/kubernetes/${endpoint.entity.guid}/pods` }, + ] + }]; + }) + ); + this.podEntity$ = kubeEntityCatalog.pod.store.getEntityService(this.podName, this.kubeEndpointService.kubeGuid, { + namespace: this.namespaceName + }).entityObs$; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/analysis-report.types.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/analysis-report.types.ts new file mode 100644 index 0000000000..620640c6c9 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/analysis-report.types.ts @@ -0,0 +1,22 @@ +export enum ResourceAlertLevel { + OK = 0, + Info, + Warning, + Error, + Unknown, +} + +// We re-map an analysis reprot into a map of resource alerts that is better for us +// to overlay in the UI to show issues from reports +export interface ResourceAlert { + apiVersion?: string; + kind: string; + message: string; + namespace: string; + name: string; + level: ResourceAlertLevel; +} + +export interface ResourceAlertMap { + [key: string]: ResourceAlert[]; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.ts new file mode 100644 index 0000000000..b33f03a1f1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-endpoint.service.ts @@ -0,0 +1,285 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { combineLatest, Observable, of } from 'rxjs'; +import { filter, first, map, shareReplay, startWith, switchMap } from 'rxjs/operators'; + +import { GetAllEndpoints } from '../../../../store/src/actions/endpoint.actions'; +import { AppState } from '../../../../store/src/app-state'; +import { EntityService } from '../../../../store/src/entity-service'; +import { EntityServiceFactory } from '../../../../store/src/entity-service-factory.service'; +import { EndpointModel } from '../../../../store/src/public-api'; +import { PaginationObservables } from '../../../../store/src/reducers/pagination-reducer/pagination-reducer.types'; +import { EntityInfo } from '../../../../store/src/types/api.types'; +import { EndpointUser } from '../../../../store/src/types/endpoint.types'; +import { kubeEntityCatalog } from '../kubernetes-entity-catalog'; +import { BaseKubeGuid } from '../kubernetes-page.types'; +import { + KubernetesDeployment, + KubernetesNode, + KubernetesPod, + KubernetesStatefulSet, + KubeService, +} from '../store/kube.types'; +import { KubeDashboardStatus } from '../store/kubernetes.effects'; +import { Annotations } from './../store/kube.types'; + +const CAASP_VERSION_ANNOTATION = 'caasp.suse.com/caasp-release-version'; +const CAASP_DISRUPTIVE_UPDATES_ANNOTATION = 'caasp.suse.com/has-disruptive-updates'; +const CAASP_SECURITY_UPDATES_ANNOTATION = 'caasp.suse.com/has-security-updates'; +const CAASP_HAS_UPDATES_ANNOTATION = 'caasp.suse.com/has-updates'; + +export interface CaaspNodesData { + version: string; + versionMismatch: boolean; + updates: number; + disruptiveUpdates: number; + securityUpdates: number; +} + +export interface CaaspNodeData { + version: string; + updates: boolean; + disruptiveUpdates: boolean; + securityUpdates: boolean; +} + + +@Injectable() +export class KubernetesEndpointService { + info$: Observable>; + cfInfoEntityService: EntityService; + endpoint$: Observable>; + kubeEndpointEntityService: EntityService; + connected$: Observable; + currentUser$: Observable; + kubeGuid: string; + deployments$: Observable; + statefulSets$: Observable; + services$: Observable; + pods$: Observable; + nodes$: Observable; + kubeDashboardEnabled$: Observable; + kubeDashboardVersion$: Observable; + kubeDashboardStatus$: Observable; + kubeDashboardLabel$: Observable; + kubeDashboardConfigured$: Observable; + kubeTerminalEnabled$: Observable; + + constructor( + public baseKube: BaseKubeGuid, + private store: Store, + private entityServiceFactory: EntityServiceFactory, + ) { + const kubeGuid = baseKube.guid; + + if (kubeGuid) { + this.initialize(kubeGuid); + } + } + + initialize(kubeGuid) { + this.kubeGuid = kubeGuid; + + this.kubeEndpointEntityService = this.entityServiceFactory.create( + this.kubeGuid, + new GetAllEndpoints() + ); + + this.constructCoreObservables(); + } + + getCaaspNodesData(nodes$: Observable = this.nodes$): Observable { + return nodes$.pipe( + map(nodes => { + const info: CaaspNodesData = { + version: 'Unknown', + versionMismatch: false, + updates: 0, + disruptiveUpdates: 0, + securityUpdates: 0 + }; + const versions = {}; + + nodes.forEach(n => { + const nodeData = this.getCaaspNodeData(n); + if (!nodeData) { + return; + } + + // Only has a version if it is a CaaSP node + if (nodeData.version) { + if (!versions[nodeData.version]) { + versions[nodeData.version] = 0; + } + versions[nodeData.version]++; + } + + info.updates += nodeData.updates ? 1 : 0; + info.disruptiveUpdates += nodeData.disruptiveUpdates ? 1 : 0; + info.securityUpdates += nodeData.securityUpdates ? 1 : 0; + }); + + if (Object.keys(versions).length === 0) { + return null; + } + + info.version = Object.keys(versions).join(', '); + info.versionMismatch = Object.keys(versions).length !== 1; + return info; + }) + ); + } + + getCaaspNodeData(n: KubernetesNode): CaaspNodeData { + if (n && n.metadata && n.metadata.annotations) { + return { + version: n.metadata.annotations[CAASP_VERSION_ANNOTATION], + updates: this.hasBooleanAnnotation(n.metadata.annotations, CAASP_HAS_UPDATES_ANNOTATION), + disruptiveUpdates: this.hasBooleanAnnotation(n.metadata.annotations, CAASP_DISRUPTIVE_UPDATES_ANNOTATION), + securityUpdates: this.hasBooleanAnnotation(n.metadata.annotations, CAASP_SECURITY_UPDATES_ANNOTATION) + }; + } + } + + // Check for the specified annotation with a value of 'yes' + private hasBooleanAnnotation(annotations: Annotations, annotation: string): boolean { + return annotations[annotation] && annotations[annotation] === 'yes' ? true : false; + } + + getNodeKubeVersions(nodes$: Observable = this.nodes$) { + return nodes$.pipe( + map(nodes => { + const versions = {}; + nodes.forEach(node => { + const v = node.status.nodeInfo.kubeletVersion; + if (!versions[v]) { + versions[v] = v; + } + }); + return Object.keys(versions).join(','); + }) + ); + } + + getCountObservable(entities$: Observable) { + return entities$.pipe( + map(entities => entities.length), + startWith(null) + ); + } + + getPodCapacity(nodes$: Observable = this.nodes$, pods$: Observable = this.pods$) { + return combineLatest(nodes$, pods$).pipe( + map(([nodes, pods]) => ({ + total: nodes.reduce((cap, node) => { + return cap + parseInt(node.status.capacity.pods, 10); + }, 0), + used: pods.length + })) + ); + } + + getNodeStatusCount( + nodes$: Observable, + conditionType: string, + valueLabels: object = {}, + countStatus = 'True' + ) { + return nodes$.pipe( + map(nodes => { + const total = nodes.length; + const { unknown, unavailable, used } = nodes.reduce((cap, node) => { + const conditionStatus = node.status.conditions.find(con => con.type === conditionType); + if (!conditionStatus || !conditionStatus.status) { + ++cap.unavailable; + } else { + if (conditionStatus.status === countStatus) { + ++cap.used; + } else if (conditionStatus.status === 'Unknown') { + ++cap.unknown; + } + } + return cap; + }, { unavailable: 0, used: 0, unknown: 0 }); + const result = { + total, + supported: total !== unavailable, + // Depends on K8S version as to what is supported + unavailable, + used, + unknown, + ...valueLabels + }; + result.supported = result.total !== result.unavailable; + return result; + }) + ); + } + + private constructCoreObservables() { + this.endpoint$ = this.kubeEndpointEntityService.waitForEntity$; + + this.connected$ = this.endpoint$.pipe( + map(p => p.entity.connectionStatus === 'connected') + ); + + this.currentUser$ = this.endpoint$.pipe(map(e => e.entity.user), shareReplay(1)); + + this.deployments$ = this.getObservable(kubeEntityCatalog.deployment.store.getPaginationService(this.kubeGuid)); + + this.pods$ = this.getObservable(kubeEntityCatalog.pod.store.getPaginationService(this.kubeGuid)); + + this.nodes$ = this.getObservable(kubeEntityCatalog.node.store.getPaginationService(this.kubeGuid)); + + this.statefulSets$ = this.getObservable(kubeEntityCatalog.statefulSet.store.getPaginationService(this.kubeGuid)); + + this.services$ = this.getObservable(kubeEntityCatalog.service.store.getPaginationService(this.kubeGuid)); + + this.kubeDashboardEnabled$ = this.store.select('auth').pipe( + filter(auth => !!auth.sessionData['plugin-config']), + map(auth => auth.sessionData['plugin-config'].kubeDashboardEnabled === 'true') + ); + + this.kubeTerminalEnabled$ = this.store.select('auth').pipe( + filter(auth => !!auth.sessionData['plugin-config']), + map(auth => auth.sessionData['plugin-config'].kubeTerminalEnabled === 'true') + ); + + const kubeDashboardStatus$ = kubeEntityCatalog.dashboard.store.getEntityService(this.kubeGuid).waitForEntity$.pipe( + map(status => status.entity), + filter(status => !!status) + ); + + this.kubeDashboardStatus$ = this.kubeDashboardEnabled$.pipe( + switchMap(enabled => enabled ? kubeDashboardStatus$ : of(null)), + ); + + this.kubeDashboardConfigured$ = this.kubeDashboardStatus$.pipe( + map(status => status && status.installed && !!status.serviceAccount && !!status.service), + ); + + this.kubeDashboardLabel$ = this.kubeDashboardStatus$.pipe( + map(status => { + if (!status) { + return ''; + } + if (!status.installed) { + return 'Not installed'; + } else if (!status.serviceAccount) { + return 'Not configured'; + } else { + return status.version; + } + }) + ); + } + + public refreshKubernetesDashboardStatus() { + kubeEntityCatalog.dashboard.api.get(this.kubeGuid); + } + + private getObservable(obs: PaginationObservables): Observable { + return obs.entities$.pipe(filter(p => !!p), first()); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-expanded-state.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-expanded-state.ts new file mode 100644 index 0000000000..89bbad518f --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-expanded-state.ts @@ -0,0 +1,170 @@ +import { KubernetesPod } from '../store/kube.types'; + + +export interface KubernetesPodExpandedStatus { + readyContainers: number; + totalContainers: number; + status: string; + restarts: number; + podIP: string; + nodeName: string; + nominatedNodeName: string; + readinessGates: string; +} + +export enum KubernetesNodeConstants { + // NodeUnreachablePodReason is the reason on a pod when its state cannot be confirmed as kubelet is unresponsive + // on the node it is (was) running. + NodeUnreachablePodReason = 'NodeLost' +} + +/** + * List of known status that could be returned from `createPodExpandedStatus` + */ +export enum KubernetesPodExpandedStatusTypes { + RUNNING = 'Running', + UNKNOWN = 'Unknown', + TERMINATING = 'Terminating', + INIT = 'Init', + COMPLETED = 'Completed' +} + +export class KubernetesPodExpandedStatusHelper { + + static updatePodWithExpandedStatus(pod: KubernetesPod): KubernetesPod { + return { + ...pod, + expandedStatus: this.createPodExpandedStatus(pod) + }; + } + + /** + * This function is similar to kubectl printPod from + * https://github.com/kubernetes/kubernetes/blob/master/pkg/printers/internalversion/printers.go#L659 + * No optimisation was done to ensure ease of update later on + */ + static createPodExpandedStatus(pod: KubernetesPod): KubernetesPodExpandedStatus { + + let restarts = 0; + const totalContainers = pod.spec.containers ? pod.spec.containers.length : 0; + let readyContainers = 0; + + let reason = pod.status.phase ? pod.status.phase.toString() : ''; + if (!!pod.status.reason) { + reason = pod.status.reason; + } + + let initializing = false; + const initContainerStatuses = pod.status.initContainerStatuses || []; + for (let i = 0; i < initContainerStatuses.length; i++) { + const container = initContainerStatuses[i]; + restarts += container.restartCount; + + const state = container.state || {}; + + if (!!state.terminated && state.terminated.exitCode === 0) { + + } else if (!!state.terminated) { + if (state.terminated.reason.length === 0) { + if (state.terminated.signal !== 0) { + reason = `${KubernetesPodExpandedStatusTypes.INIT}:Signal:${state.terminated.signal}`; + } else { + reason = `${KubernetesPodExpandedStatusTypes.INIT}:ExitCode:${state.terminated.exitCode}`; + } + } else { + reason = `${KubernetesPodExpandedStatusTypes.INIT}:${state.terminated.reason}`; + } + initializing = true; + } else if (!!state.waiting && !!state.waiting.reason && state.waiting.reason !== 'PodInitializing') { + reason = `${KubernetesPodExpandedStatusTypes.INIT}:${state.waiting.reason}`; + initializing = true; + } else { + reason = `${KubernetesPodExpandedStatusTypes.INIT}:${i}/${pod.spec.initContainers.length}`; + initializing = true; + } + } + if (!initializing) { + restarts = 0; + let hasRunning = false; + + const containerStatuses = pod.status.containerStatuses || []; + for (let i = containerStatuses.length - 1; i >= 0; i--) { + const container = containerStatuses[i]; + const state = container.state || {}; + restarts += container.restartCount; + if (!!state.waiting) { + reason = state.waiting.reason; + } else if (!!state.terminated) { + reason = state.terminated.reason; + if (!!state.terminated.signal && state.terminated.signal !== 0) { + reason = `Signal:${state.terminated.signal}`; + } else if (!!state.terminated.exitCode && state.terminated.exitCode !== 0) { + reason = `ExitCode:${state.terminated.exitCode}`; + } + } else if (!!container.ready && !!state.running) { + hasRunning = true; + readyContainers++; + } + } + + // change pod status back to "Running" if there is at least one container still reporting as "Running" status + if (reason === KubernetesPodExpandedStatusTypes.COMPLETED && hasRunning) { + reason = KubernetesPodExpandedStatusTypes.RUNNING; + } + } + + if (!!pod.deletionTimestamp && pod.status.reason === KubernetesNodeConstants.NodeUnreachablePodReason) { + reason = KubernetesPodExpandedStatusTypes.UNKNOWN; + } else if (!!pod.deletionTimestamp) { + reason = KubernetesPodExpandedStatusTypes.TERMINATING; + } + + + let nodeName = pod.spec.nodeName; + let nominatedNodeName = pod.status.nominatedNodeName; + let podIP = pod.status.podIP; + if (pod.status.podIPs && pod.status.podIPs.length > 0) { + podIP = pod.status.podIPs[0].ip; + } + + if (!podIP) { + podIP = ''; + } + if (!nodeName) { + nodeName = ''; + } + if (!nominatedNodeName) { + nominatedNodeName = ''; + } + + let readinessGates = ''; + if (pod.spec.readinessGates && pod.spec.readinessGates.length > 0) { + let trueConditions = 0; + pod.spec.readinessGates.forEach(readinessGate => { + const conditionType = readinessGate.ConditionType; + for (const condition of pod.status.conditions) { + if (condition.type === conditionType) { + if (condition.status === 'True') { + trueConditions++; + } + break; + } + } + }); + readinessGates = `${trueConditions}/${pod.spec.readinessGates.length}`; + } + + const res: KubernetesPodExpandedStatus = { + readyContainers, + totalContainers, + status: reason, + restarts, + podIP, + nodeName, + nominatedNodeName, + readinessGates + }; + + return res; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-namespace.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-namespace.service.ts new file mode 100644 index 0000000000..d6c62cf663 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-namespace.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { filter, first, map, publishReplay } from 'rxjs/operators'; + +import { getIdFromRoute } from '../../../../core/src/core/utils.service'; +import { kubeEntityCatalog } from '../kubernetes-entity-catalog'; +import { KubernetesNamespace } from '../store/kube.types'; +import { KubernetesEndpointService } from './kubernetes-endpoint.service'; + +@Injectable() +export class KubernetesNamespaceService { + namespaceName: string; + kubeGuid: string; + namespace$: Observable; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public activatedRoute: ActivatedRoute, + ) { + + this.namespaceName = getIdFromRoute(activatedRoute, 'namespaceName'); + this.kubeGuid = kubeEndpointService.kubeGuid; + + const namespaceEntity = kubeEntityCatalog.namespace.store.getEntityService(this.namespaceName, this.kubeGuid); + + this.namespace$ = namespaceEntity.entityObs$.pipe( + filter(p => !!p), + map(p => p.entity), + publishReplay(1), + first() + ); + + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts new file mode 100644 index 0000000000..951a50de18 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes-node.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { filter, first, map, publishReplay, refCount } from 'rxjs/operators'; + +import { getIdFromRoute } from '../../../../core/src/core/utils.service'; +import { MetricQueryConfig, MetricsAction } from '../../../../store/src/actions/metrics.actions'; +import { EntityMonitorFactory } from '../../../../store/src/monitors/entity-monitor.factory.service'; +import { AppState } from '../../../../store/src/public-api'; +import { EntityInfo } from '../../../../store/src/types/api.types'; +import { MetricQueryType } from '../../../../store/src/types/metric.types'; +import { kubeEntityCatalog } from '../kubernetes-entity-catalog'; +import { KubernetesNode, MetricStatistic } from '../store/kube.types'; +import { FetchKubernetesMetricsAction } from '../store/kubernetes.actions'; +import { KubernetesEndpointService } from './kubernetes-endpoint.service'; + + +export enum KubeNodeMetric { + CPU = 'container_cpu_usage_seconds_total', + MEMORY = 'container_memory_usage_bytes' +} + +@Injectable() +export class KubernetesNodeService { + public nodeName: string; + public kubeGuid: string; + public node$: Observable>; + nodeEntity$: Observable; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public activatedRoute: ActivatedRoute, + public store: Store, + public entityMonitorFactory: EntityMonitorFactory + ) { + this.nodeName = getIdFromRoute(activatedRoute, 'nodeName'); + this.kubeGuid = kubeEndpointService.kubeGuid; + + const nodeEntityService = kubeEntityCatalog.node.store.getEntityService(this.nodeName, this.kubeGuid); + + this.node$ = nodeEntityService.entityObs$.pipe( + filter(p => !!p && !!p.entity), + first(), + publishReplay(1), + refCount() + ); + + this.nodeEntity$ = this.node$.pipe( + map(p => p.entity) + ); + } + + + + public setupMetricObservable(metric: KubeNodeMetric, metricStatistic: MetricStatistic) { + const containerFilter = ',container!="POD", container!=""'; + const query = `${metricStatistic}(${metricStatistic}_over_time(${metric}{kubernetes_io_hostname="${this.nodeName}"${containerFilter}}[1h]))`; + const metricsAction = new FetchKubernetesMetricsAction(this.nodeName, this.kubeGuid, query); + const metricsId = MetricsAction.buildMetricKey(this.nodeName, new MetricQueryConfig(query), true, MetricQueryType.QUERY); + const metricsMonitor = this.entityMonitorFactory.create(metricsId, metricsAction); + this.store.dispatch(metricsAction); + const pollSub = metricsMonitor.poll(30000, () => this.store.dispatch(metricsAction), + request => ({ busy: request.fetching, error: request.error, message: request.message })) + .subscribe(); + return { + entity$: metricsMonitor.entity$.pipe(filter(metrics => !!metrics), map(metrics => { + const result = metrics.data && metrics.data.result; + if (!!result && result.length === 1) { + return result[0].value[1]; + } else { + return 0; + } + })), + pollerSub: pollSub + }; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes.analysis.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes.analysis.service.ts new file mode 100644 index 0000000000..5c0454d3ad --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes.analysis.service.ts @@ -0,0 +1,164 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { combineLatest, Observable } from 'rxjs'; +import { filter, first, map, pairwise, startWith, tap } from 'rxjs/operators'; + +import { SnackBarService } from '../../../../core/src/shared/services/snackbar.service'; +import { ResetPaginationOfType } from '../../../../store/src/actions/pagination.actions'; +import { AppState } from '../../../../store/src/app-state'; +import { ListActionState, RequestInfoState } from '../../../../store/src/reducers/api-request-reducer/types'; +import { kubeEntityCatalog } from '../kubernetes-entity-catalog'; +import { GetAnalysisReports } from '../store/analysis.actions'; +import { AnalysisReport } from '../store/kube.types'; +import { getHelmReleaseDetailsFromGuid } from '../workloads/store/workloads-entity-factory'; +import { KubernetesEndpointService } from './kubernetes-endpoint.service'; + +export interface KubernetesAnalysisType { + name: string; + id: string; + namespaceAware: boolean; + iconUrl?: string; + descriptionUrl?: string; +} + +@Injectable() +export class KubernetesAnalysisService { + kubeGuid: string; + + public analyzers$: Observable; + public namespaceAnalyzers$: Observable; + + public enabled$: Observable; + public hideAnalysis$: Observable; + + private action: GetAnalysisReports; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public activatedRoute: ActivatedRoute, + public store: Store, + private snackbarService: SnackBarService + ) { + this.kubeGuid = kubeEndpointService.kubeGuid || getHelmReleaseDetailsFromGuid(activatedRoute.snapshot.params.guid).endpointId; + + // Is the backend plugin available? + this.enabled$ = this.store.select('auth').pipe( + map(auth => auth.sessionData.plugins && auth.sessionData.plugins.analysis) + ); + + this.hideAnalysis$ = this.enabled$.pipe( + map(enabled => !enabled), + startWith(true), + ); + + const allEngines = { + popeye: + { + name: 'PopEye', + id: 'popeye', + namespaceAware: true, + // iconUrl: '/core/assets/custom/popeye.png', + // iconWidth: '80', + descriptionUrl: '/core/assets/custom/popeye.md' + }, + 'kube-score': + { + name: 'Kube Score', + id: 'kube-score', + namespaceAware: true, + // iconUrl: '/core/assets/custom/kubescore.png', + // iconWidth: '120', + descriptionUrl: '/core/assets/custom/kubescore.md' + } + // { + // name: 'Sonobuoy', + // id: 'sonobuoy', + // namespaceAware: false, + // iconUrl: '/core/assets/custom/sonobuoy.png', + // iconWidth: '70', + // descriptionUrl: '/core/assets/custom/sonobuoy.md' + // } + }; + + // Determine which analyzers are enabled + this.analyzers$ = this.store.select('auth').pipe( + filter(auth => !!auth.sessionData['plugin-config']), + map(auth => auth.sessionData['plugin-config'].analysisEngines), + map(engines => engines.split(',').map(e => allEngines[e.trim()]).filter(e => !!e)) + ); + + this.namespaceAnalyzers$ = combineLatest( + this.analyzers$, + this.enabled$ + ).pipe( + map(([a, enabled]) => { + if (!enabled) { + return null; + } + return a.filter(v => v.namespaceAware); + }) + ); + + this.action = kubeEntityCatalog.analysisReport.actions.getMultiple(this.kubeGuid); + } + + public delete(endpointID: string, item: { id: string, }) { + return kubeEntityCatalog.analysisReport.api.delete(endpointID, item.id); + } + + public refresh() { + this.store.dispatch(new ResetPaginationOfType(this.action)); + } + + public run(id: string, endpointID: string, namespace?: string, app?: string): Observable { + const obs$ = kubeEntityCatalog.analysisReport.api.run(endpointID, id, namespace, app).pipe( + pairwise(), + filter(([oldE, newE]) => oldE.creating && !newE.creating), + map(([, newE]) => newE), + first() + ); + obs$.subscribe(() => { + const type = id.charAt(0).toUpperCase() + id.substring(1); + let msg; + if (app) { + msg = `${type} analysis started for workload '${app}'`; + } else if (namespace) { + msg = `${type} analysis started for namespace '${namespace}'`; + } else { + msg = `${type} analysis started for the Kubernetes cluster`; + } + this.snackbarService.showReturn(msg, ['kubernetes', endpointID, 'analysis'], 'View', 5000); + this.refresh(); + }); + return obs$; + } + + public getByID(endpoint: string, id: string, refresh = false): Observable { + if (refresh) { + kubeEntityCatalog.analysisReport.api.getById(endpoint, id); + } + + const entityService = kubeEntityCatalog.analysisReport.store.getById.getEntityService(endpoint, id); + return entityService.waitForEntity$.pipe( + map(e => e.entity), + tap(entity => { + if (!refresh && !entity.report) { + kubeEntityCatalog.analysisReport.api.getById(endpoint, id); + refresh = true; + } + }), + filter(entity => !!entity.report) + ); + } + + public getByPath(endpointID: string, path: string, refresh = false): Observable { + if (refresh) { + kubeEntityCatalog.analysisReport.api.getByPath(endpointID, path); + } + return kubeEntityCatalog.analysisReport.store.getByPath.getPaginationService(endpointID, path).entities$.pipe( + filter(entities => !!entities) + ); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes.service.ts new file mode 100644 index 0000000000..3f103e61f1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubernetes.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map, shareReplay } from 'rxjs/operators'; + +import { PaginationMonitor } from '../../../../store/src/monitors/pagination-monitor'; +import { EndpointModel } from '../../../../store/src/public-api'; +import { stratosEntityCatalog } from '../../../../store/src/stratos-entity-catalog'; +import { APIResource, EntityInfo } from '../../../../store/src/types/api.types'; +import { KUBERNETES_ENDPOINT_TYPE } from '../kubernetes-entity-factory'; + +@Injectable() +export class KubernetesService { + kubeEndpoints$: Observable; + kubeEndpointsMonitor: PaginationMonitor; + waitForAppEntity$: Observable>; + + constructor() { + this.kubeEndpointsMonitor = stratosEntityCatalog.endpoint.store.getAll.getPaginationMonitor(); + + this.kubeEndpoints$ = this.kubeEndpointsMonitor.currentPage$.pipe( + map(endpoints => endpoints.filter(e => e.cnsi_type === KUBERNETES_ENDPOINT_TYPE)), + shareReplay(1) + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/kubescore-report.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/kubescore-report.helper.ts new file mode 100644 index 0000000000..b07c0e7abf --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/kubescore-report.helper.ts @@ -0,0 +1,57 @@ +import { ResourceAlert, ResourceAlertLevel, ResourceAlertMap } from './analysis-report.types'; + +export class KubeScoreReportHelper { + + constructor(public report: any) {} + + public map() { + if (!this.report.report) { + return; + } + + const kubescore = this.report.report; + // Go through the report and re-map + const result = {} as ResourceAlertMap; + + Object.keys(kubescore).forEach(key => { + const item = kubescore[key]; + let id = item.TypeMeta.kind.toLowerCase(); + id = `${id}/${item.ObjectMeta.namespace}/${item.ObjectMeta.name}`; + + item.Checks.forEach(check => { + if (check.Grade !== 10 && !check.Skipped) { + // Add an alert for each comment + check.Comments.forEach(comment => { + // Include this comment + const alert = { + kind: item.TypeMeta.kind.toLowerCase(), + namespace: item.ObjectMeta.namespace, + name: item.ObjectMeta.name, + message: comment.Summary, + level: this.convertMessageLevel(check.Grade) + } as ResourceAlert; + if (!result[id]) { + result[id] = [] as ResourceAlert[]; + } + result[id].push(alert); + }); + } + }); + }); + this.report.alerts = result; + } + private convertMessageLevel(level: number): ResourceAlertLevel { + switch (level) { + case 10: + return ResourceAlertLevel.OK; + case 7: + return ResourceAlertLevel.Info; + case 5: + return ResourceAlertLevel.Warning; + case 1: + return ResourceAlertLevel.Error; + default: + return ResourceAlertLevel.Unknown; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/popeye-report.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/popeye-report.helper.ts new file mode 100644 index 0000000000..12de4c61c1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/popeye-report.helper.ts @@ -0,0 +1,69 @@ +import { ResourceAlert, ResourceAlertLevel, ResourceAlertMap } from './analysis-report.types'; + +export class PopeyeReportHelper { + + constructor(public report: any) { } + + // Map the report to the alert format + public map() { + if (!this.report.report || !this.report.report.popeye) { + return; + } + + const popeye = this.report.report.popeye; + // Go through the report and re-map + const result = {} as ResourceAlertMap; + popeye.sanitizers.forEach(s => { + // We just care about issues + const resourceType = s.sanitizer; + if (s.issues) { + Object.keys(s.issues).forEach(resourcePath => { + const issues = s.issues[resourcePath]; + issues.forEach(issue => { + // Level must be greater than 0 (OK) + if (issue.level > 0) { + let namespace; + let name; + if (resourcePath.indexOf('/') !== -1) { + // Has a namespace + namespace = resourcePath.split('/')[0]; + name = resourcePath.split('/')[1]; + } else { + name = resourcePath; + namespace = ''; + } + const alert = { + kind: resourceType, + namespace, + name, + message: issue.message, + level: this.convertMessageLevel(issue.level) + } as ResourceAlert; + const id = `${resourceType}/${resourcePath}`; + if (!result[id]) { + result[id] = [] as ResourceAlert[]; + } + result[id].push(alert); + } + }); + }); + } + }); + + this.report.alerts = result; + } + private convertMessageLevel(level: number): ResourceAlertLevel { + switch (level) { + case 0: + return ResourceAlertLevel.OK; + case 1: + return ResourceAlertLevel.Info; + case 2: + return ResourceAlertLevel.Warning; + case 3: + return ResourceAlertLevel.Error; + default: + return ResourceAlertLevel.Unknown; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/services/route.helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/services/route.helper.ts new file mode 100644 index 0000000000..847fa6dd84 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/services/route.helper.ts @@ -0,0 +1,11 @@ +import { ActivatedRoute } from '@angular/router'; + +export function getParentURL(route: ActivatedRoute, removeLastParts = 1): string { + const reducer = (a: string, v) => { + const p = v.url.join('/'); + return p.length > 0 ? `${a}/${p}` : a; + }; + const res = route.snapshot.pathFromRoot.reduce(reducer, '').split('/'); + res.splice(-removeLastParts, removeLastParts); + return res.join('/'); +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/store/action-builders/kube.action-builders.ts b/src/frontend/packages/kubernetes/src/kubernetes/store/action-builders/kube.action-builders.ts new file mode 100644 index 0000000000..7d1cf49d2a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/store/action-builders/kube.action-builders.ts @@ -0,0 +1,184 @@ +import { OrchestratedActionBuilders } from '../../../../../store/src/entity-catalog/action-orchestrator/action-orchestrator'; +import { GetHelmReleasePods, GetHelmReleaseServices } from '../../workloads/store/workloads.actions'; +import { + DeleteAnalysisReport, + GetAnalysisReportById, + GetAnalysisReports, + GetAnalysisReportsByPath, + RunAnalysisReport, +} from '../analysis.actions'; +import { + CreateKubernetesNamespace, + GeKubernetesDeployments, + GetKubernetesDashboard, + GetKubernetesNamespace, + GetKubernetesNamespaces, + GetKubernetesNode, + GetKubernetesNodes, + GetKubernetesPod, + GetKubernetesPods, + GetKubernetesPodsInNamespace, + GetKubernetesPodsOnNode, + GetKubernetesServices, + GetKubernetesServicesInNamespace, + GetKubernetesStatefulSets, + KubeHealthCheck, +} from '../kubernetes.actions'; + +export interface KubeStatefulSetsActionBuilders extends OrchestratedActionBuilders { + getMultiple: ( + kubeGuid: string, + paginationKey?: string, + ) => GetKubernetesStatefulSets; +} + +export const kubeStatefulSetsActionBuilders: KubeStatefulSetsActionBuilders = { + getMultiple: (kubeGuid: string, paginationKey?: string) => new GetKubernetesStatefulSets(kubeGuid) +}; + +export interface KubePodActionBuilders extends OrchestratedActionBuilders { + get: ( + podName: string, + kubeGuid: string, + extraArgs: { namespace: string, } + ) => GetKubernetesPod, + getMultiple: ( + kubeGuid: string, + paginationKey?: string, + ) => GetKubernetesPods; + getOnNode: ( + kubeGuid: string, + nodeName: string + ) => GetKubernetesPodsOnNode; + getInNamespace: ( + kubeGuid: string, + namespace: string + ) => GetKubernetesPodsInNamespace; + getInWorkload: ( + kubeGuid: string, + releaseTitle: string + ) => GetHelmReleasePods; +} + +export const kubePodActionBuilders: KubePodActionBuilders = { + get: (podName: string, kubeGuid: string, { namespace }) => new GetKubernetesPod(podName, namespace, kubeGuid), + getMultiple: (kubeGuid: string, paginationKey?: string) => new GetKubernetesPods(kubeGuid), + getOnNode: (kubeGuid: string, nodeName: string) => new GetKubernetesPodsOnNode(kubeGuid, nodeName), + getInNamespace: (kubeGuid: string, namespace: string) => new GetKubernetesPodsInNamespace(kubeGuid, namespace), + getInWorkload: (kubeGuid: string, releaseTitle: string) => new GetHelmReleasePods(kubeGuid, releaseTitle) +}; + +export interface KubeDeploymentActionBuilders extends OrchestratedActionBuilders { + getMultiple: ( + kubeGuid: string, + paginationKey?: string, + ) => GeKubernetesDeployments; +} + +export const kubeDeploymentActionBuilders: KubeDeploymentActionBuilders = { + getMultiple: (kubeGuid: string, paginationKey?: string) => new GeKubernetesDeployments(kubeGuid) +}; + +export interface KubeNodeActionBuilders extends OrchestratedActionBuilders { + get: ( + nodeName: string, + kubeGuid: string + ) => GetKubernetesNode; + getMultiple: ( + kubeGuid: string, + paginationKey?: string, + ) => GetKubernetesNodes; + healthCheck: ( + kubeGuid: string, + ) => KubeHealthCheck; +} + +export const kubeNodeActionBuilders: KubeNodeActionBuilders = { + get: (nodeName: string, endpointGuid: string) => new GetKubernetesNode(nodeName, endpointGuid), + getMultiple: (kubeGuid: string, paginationKey?: string) => new GetKubernetesNodes(kubeGuid), + healthCheck: (kubeGuid: string) => new KubeHealthCheck(kubeGuid) +}; + +export interface KubeNamespaceActionBuilders extends OrchestratedActionBuilders { + get: ( + namespace: string, + kubeGuid: string + ) => GetKubernetesNamespace; + create: ( + namespace: string, + kubeGuid: string + ) => CreateKubernetesNamespace; + getMultiple: ( + kubeGuid: string, + paginationKey?: string, + ) => GetKubernetesNamespaces; +} + +export const kubeNamespaceActionBuilders: KubeNamespaceActionBuilders = { + get: (namespace: string, kubeGuid: string) => new GetKubernetesNamespace(namespace, kubeGuid), + create: (namespace: string, kubeGuid: string) => new CreateKubernetesNamespace(namespace, kubeGuid), + getMultiple: (kubeGuid: string, paginationKey?: string) => new GetKubernetesNamespaces(kubeGuid) +}; + +export interface KubeServiceActionBuilders extends OrchestratedActionBuilders { + getMultiple: ( + kubeGuid: string, + paginationKey?: string + ) => GetKubernetesServices; + getInNamespace: ( + namespace: string, + kubeGuid: string + ) => GetKubernetesServicesInNamespace; + getInWorkload: ( + releaseTitle: string, + kubeGuid: string + ) => GetHelmReleaseServices; +} + +export const kubeServiceActionBuilders: KubeServiceActionBuilders = { + getMultiple: (kubeGuid: string, paginationKey?: string) => new GetKubernetesServices(kubeGuid), + getInNamespace: (namespace: string, kubeGuid: string) => new GetKubernetesServicesInNamespace(kubeGuid, namespace), + getInWorkload: (releaseTitle: string, kubeGuid: string) => new GetHelmReleaseServices(kubeGuid, releaseTitle) +}; + +export interface KubeDashboardActionBuilders extends OrchestratedActionBuilders { + get: ( + kubeGuid: string + ) => GetKubernetesDashboard; +} + +export const kubeDashboardActionBuilders: KubeDashboardActionBuilders = { + get: (kubeGuid: string) => new GetKubernetesDashboard(kubeGuid) +}; + +export interface AnalysisReportsActionBuilders extends OrchestratedActionBuilders { + getMultiple: ( + kubeGuid: string + ) => GetAnalysisReports; + getById: ( + kubeGuid: string, + id: string, + ) => GetAnalysisReportById; + getByPath: ( + kubeGuid: string, + path: string, + ) => GetAnalysisReportsByPath; + delete: ( + kubeGuid: string, + id: string, + ) => DeleteAnalysisReport; + run: ( + kubeGuid: string, + id: string, + namespace?: string, + app?: string + ) => RunAnalysisReport; +} + +export const analysisReportsActionBuilders: AnalysisReportsActionBuilders = { + getMultiple: (kubeGuid: string) => new GetAnalysisReports(kubeGuid), + getById: (kubeGuid: string, id: string) => new GetAnalysisReportById(kubeGuid, id), + getByPath: (kubeGuid: string, path: string) => new GetAnalysisReportsByPath(kubeGuid, path), + delete: (kubeGuid: string, id: string) => new DeleteAnalysisReport(kubeGuid, id), + run: (kubeGuid: string, id: string, namespace?: string, app?: string) => new RunAnalysisReport(kubeGuid, id, namespace, app) +}; diff --git a/src/frontend/packages/kubernetes/src/kubernetes/store/analysis.actions.ts b/src/frontend/packages/kubernetes/src/kubernetes/store/analysis.actions.ts new file mode 100644 index 0000000000..551b3c43d1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/store/analysis.actions.ts @@ -0,0 +1,69 @@ +import { getActions } from '../../../../store/src/actions/action.helper'; +import { PaginatedAction } from '../../../../store/src/types/pagination.types'; +import { analysisReportEntityType, KUBERNETES_ENDPOINT_TYPE, kubernetesEntityFactory } from '../kubernetes-entity-factory'; +import { KubeAction, KubePaginationAction, KubeSingleEntityAction } from './kubernetes.actions'; + +export const GET_ANALYSIS_REPORTS_TYPES = getActions('ANALYSIS', 'Get reports'); +export const GET_ANALYSIS_REPORT_BY_ID_TYPES = getActions('ANALYSIS', 'Get report by id'); +export const GET_ANALYSIS_REPORTS_BY_PATH_TYPES = getActions('ANALYSIS', 'Get report by path'); +export const DELETE_ANALYSIS_REPORT_TYPES = getActions('ANALYSIS', 'Delete report'); +export const RUN_ANALYSIS_REPORT_TYPES = getActions('ANALYSIS', 'Run'); + +abstract class AnalysisAction implements KubeAction { + constructor(public kubeGuid: string, public actions: string[]) { + this.type = this.actions[0]; + } + endpointType = KUBERNETES_ENDPOINT_TYPE; + entityType = analysisReportEntityType; + entity = [kubernetesEntityFactory(analysisReportEntityType)]; + type: string; +} +abstract class AnalysisPaginationAction extends AnalysisAction implements KubePaginationAction, PaginatedAction { + flattenPagination = true; + constructor(kubeGuid: string, actionTypes: string[], public paginationKey: string) { + super(kubeGuid, actionTypes); + } +} +abstract class AnalysisSingleEntityAction extends AnalysisAction implements KubeSingleEntityAction { + constructor(kubeGuid: string, actionTypes: string[], public guid: string) { + super(kubeGuid, actionTypes); + } +} + + +/** + * Get the analysis reports for the given endpoint ID + */ +export class GetAnalysisReports extends AnalysisPaginationAction { + constructor(public kubeGuid: string) { + super(kubeGuid, GET_ANALYSIS_REPORTS_TYPES, kubeGuid); + } + initialParams = { + 'order-direction': 'asc', + 'order-direction-field': 'age', + }; +} + +export class GetAnalysisReportById extends AnalysisSingleEntityAction { + constructor(kubeGuid: string, id: string) { + super(kubeGuid, GET_ANALYSIS_REPORT_BY_ID_TYPES, id); + } +} + +export class GetAnalysisReportsByPath extends AnalysisPaginationAction { + constructor(kubeGuid: string, public path: string) { + super(kubeGuid, GET_ANALYSIS_REPORTS_BY_PATH_TYPES, path); + } +} + +export class DeleteAnalysisReport extends AnalysisSingleEntityAction { + constructor(kubeGuid: string, id: string) { + super(kubeGuid, DELETE_ANALYSIS_REPORT_TYPES, id); + } +} + +export class RunAnalysisReport extends AnalysisSingleEntityAction { + constructor(kubeGuid: string, id: string, public namespace?: string, public app?: string) { + super(kubeGuid, RUN_ANALYSIS_REPORT_TYPES, id); + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/store/analysis.effects.ts b/src/frontend/packages/kubernetes/src/kubernetes/store/analysis.effects.ts new file mode 100644 index 0000000000..d3f8dbb8a8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/store/analysis.effects.ts @@ -0,0 +1,258 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { catchError, flatMap, mergeMap } from 'rxjs/operators'; + +import { environment } from '../../../../core/src/environments/environment'; +import { AppState, entityCatalog, NormalizedResponse, WrapperRequestActionSuccess } from '../../../../store/src/public-api'; +import { ApiRequestTypes } from '../../../../store/src/reducers/api-request-reducer/request-helpers'; +import { StartRequestAction, WrapperRequestActionFailed } from '../../../../store/src/types/request.types'; +import { KubeScoreReportHelper } from '../services/kubescore-report.helper'; +import { PopeyeReportHelper } from '../services/popeye-report.helper'; +import { + DELETE_ANALYSIS_REPORT_TYPES, + DeleteAnalysisReport, + GET_ANALYSIS_REPORT_BY_ID_TYPES, + GET_ANALYSIS_REPORTS_BY_PATH_TYPES, + GET_ANALYSIS_REPORTS_TYPES, + GetAnalysisReportById, + GetAnalysisReports, + GetAnalysisReportsByPath, + RUN_ANALYSIS_REPORT_TYPES, + RunAnalysisReport, +} from './analysis.actions'; +import { AnalysisReport } from './kube.types'; + +@Injectable() +export class AnalysisEffects { + proxyAPIVersion = environment.proxyAPIVersion; + + constructor( + private http: HttpClient, + private actions$: Actions, + private store: Store, + ) { } + + @Effect() + fetchAnalysisReports$ = this.actions$.pipe( + ofType(GET_ANALYSIS_REPORTS_TYPES[0]), + flatMap(action => { + this.store.dispatch(new StartRequestAction(action)); + const headers = new HttpHeaders({}); + const requestArgs = { + headers + }; + const url = `/pp/${this.proxyAPIVersion}/analysis/reports/${action.kubeGuid}`; + const entityKey = entityCatalog.getEntityKey(action); + return this.http.get(url, requestArgs).pipe( + mergeMap(response => { + const res: NormalizedResponse = { + entities: { [entityKey]: {} }, + result: [] + }; + const items: any = response as Array; + items.forEach(item => { + const id = item.id; + res.entities[entityKey][id] = item; + res.result.push(id); + }); + return [new WrapperRequestActionSuccess(res, action)]; + }), + catchError(error => [ + new WrapperRequestActionFailed(error.message, action, 'fetch', { + endpointIds: [action.kubeGuid], + url: error.url || url, + eventCode: error.status ? error.status + '' : '500', + message: 'Kubernetes Analysis Report request error', + error + }) + ]) + ); + }) + ); + + @Effect() + fetchAnalysisReportById$ = this.actions$.pipe( + ofType(GET_ANALYSIS_REPORT_BY_ID_TYPES[0]), + flatMap(action => { + this.store.dispatch(new StartRequestAction(action)); + + const url = `/pp/${this.proxyAPIVersion}/analysis/reports/${action.kubeGuid}/${action.guid}`; + const headers = new HttpHeaders({}); + const requestArgs = { + headers, + }; + const entityKey = entityCatalog.getEntityKey(action); + + return this.http.get(url, requestArgs).pipe( + mergeMap(response => { + this.processReport(response); + + const res: NormalizedResponse = { + entities: { + [entityKey]: { + [action.guid]: response + } + }, + result: [action.guid] + }; + return [new WrapperRequestActionSuccess(res, action)]; + }), + catchError(error => [ + new WrapperRequestActionFailed(error.message, action, 'fetch', { + endpointIds: [action.kubeGuid], + url: error.url || url, + eventCode: error.status ? error.status + '' : '500', + message: 'Kubernetes Analysis Report request error', + error + }) + ]) + ); + }) + ); + + @Effect() + fetchAnalysisReportByPath$ = this.actions$.pipe( + ofType(GET_ANALYSIS_REPORTS_BY_PATH_TYPES[0]), + flatMap(action => { + this.store.dispatch(new StartRequestAction(action)); + + const url = `/pp/${this.proxyAPIVersion}/analysis/completed/${action.kubeGuid}/${action.path}`; + const headers = new HttpHeaders({}); + const requestArgs = { + headers, + }; + const schema = action.entity[0]; + const entityKey = entityCatalog.getEntityKey(action); + return this.http.get(url, requestArgs).pipe( + mergeMap((response: AnalysisReport[]) => { + const res: NormalizedResponse = { + entities: { + [entityKey]: {} + }, + result: [] + }; + response.forEach(report => { + const guid = schema.getId(report); + res.entities[entityKey][guid] = report; + res.result.push(guid); + }); + return [new WrapperRequestActionSuccess(res, action)]; + }), + catchError(error => [ + new WrapperRequestActionFailed(error.message, action, 'fetch', { + endpointIds: [action.kubeGuid], + url: error.url || url, + eventCode: error.status ? error.status + '' : '500', + message: 'Kubernetes Analysis Report request error', + error + }) + ]) + ); + }) + ); + + @Effect() + deleteAnalysisReport$ = this.actions$.pipe( + ofType(DELETE_ANALYSIS_REPORT_TYPES[0]), + flatMap(action => { + const type: ApiRequestTypes = 'delete'; + + this.store.dispatch(new StartRequestAction(action, type)); + + const url = `/pp/${this.proxyAPIVersion}/analysis/reports`; + const headers = new HttpHeaders({}); + const requestArgs = { + headers, + body: [action.guid] + }; + + return this.http.delete(url, requestArgs).pipe( + mergeMap(() => { + const res: NormalizedResponse = { + entities: { [entityCatalog.getEntityKey(action)]: {} }, + result: [] + }; + return [new WrapperRequestActionSuccess(res, action, type)]; + }), + catchError(error => [ + new WrapperRequestActionFailed(error.message, action, type, { + endpointIds: [action.kubeGuid], + url: error.url || url, + eventCode: error.status ? error.status + '' : '500', + message: 'Kubernetes Analysis Report request error', + error + }) + ]) + ); + }) + ); + + @Effect() + runAnalysisReport$ = this.actions$.pipe( + ofType(RUN_ANALYSIS_REPORT_TYPES[0]), + flatMap(action => { + const type: ApiRequestTypes = 'create'; + + this.store.dispatch(new StartRequestAction(action, type)); + + const { namespace, app } = action; + const body = { + namespace, + app, + }; + + // Start an Analysis + const url = `/pp/${this.proxyAPIVersion}/analysis/run/${action.guid}/${action.kubeGuid}`; + const headers = new HttpHeaders({}); + const requestArgs = { + headers, + }; + + return this.http.post(url, body, requestArgs).pipe( + mergeMap((response: AnalysisReport) => { + const res: NormalizedResponse = { + entities: { [entityCatalog.getEntityKey(action)]: { [response.id]: response } }, + result: [response.id] + }; + return [new WrapperRequestActionSuccess(res, action, type)]; + }), + catchError(error => [ + new WrapperRequestActionFailed(error.message, action, type, { + endpointIds: [action.kubeGuid], + url: error.url || url, + eventCode: error.status ? error.status + '' : '500', + message: 'Kubernetes Analysis Report request error', + error + }) + ]) + ); + + }) + ); + + + private processReport(report: any) { + // Check the path of the report + if (report.path.split('/').length !== 2) { + return; + } + + switch (report.format) { + case 'popeye': + const helper = new PopeyeReportHelper(report); + helper.map(); + break; + case 'kubescore': + const kubeScoreHelper = new KubeScoreReportHelper(report); + kubeScoreHelper.map(); + break; + default: + console.warn('Do not know how to handle this report type: ', report.format); + break; + } + } + + +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/store/kube.getIds.ts b/src/frontend/packages/kubernetes/src/kubernetes/store/kube.getIds.ts new file mode 100644 index 0000000000..256d39ca39 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/store/kube.getIds.ts @@ -0,0 +1,49 @@ +import { environment } from '../../../../core/src/environments/environment'; +import { + BasicKubeAPIResource, + KubernetesDeployment, + KubernetesNamespace, + KubernetesNode, + KubernetesPod, + KubernetesStatefulSet, + KubeService, +} from './kube.types'; +import { KubeDashboardStatus } from './kubernetes.effects'; + +const deliminate = (...args: string[]) => args.join('_:_'); + +const debugMissingKubeId = (entity: BasicKubeAPIResource, func: (...args: string[]) => string, ...args: string[]) => { + if (!environment.production && (!entity.metadata || !entity.metadata.kubeId)) { + console.warn(`Kube entity does not have a kubeId, this is probably a bug: `, entity); + } + return func(...args); +}; + +export const getGuidFromKubeNode = (kubeGuid: string, name: string): string => deliminate(name, kubeGuid); +export const getGuidFromKubeNodeObj = (entity: KubernetesNode): string => + debugMissingKubeId(entity, getGuidFromKubeNode, entity.metadata.kubeId, entity.metadata.name); + +export const getGuidFromKubeNamespace = (kubeGuid: string, name: string): string => deliminate(name, kubeGuid); +export const getGuidFromKubeNamespaceObj = (entity: KubernetesNamespace): string => + debugMissingKubeId(entity, getGuidFromKubeNamespace, entity.metadata.kubeId, entity.metadata.name); + +export const getGuidFromKubeService = (kubeGuid: string, namespace: string, name: string): string => deliminate(name, namespace, kubeGuid); +export const getGuidFromKubeServiceObj = (entity: KubeService): string => + debugMissingKubeId(entity, getGuidFromKubeService, entity.metadata.kubeId, entity.metadata.namespace, entity.metadata.name); + +export const getGuidFromKubeStatefulSet = (kubeGuid: string, namespace: string, name: string): string => + deliminate(name, namespace, kubeGuid); +export const getGuidFromKubeStatefulSetObj = (entity: KubernetesStatefulSet): string => + debugMissingKubeId(entity, getGuidFromKubeStatefulSet, entity.metadata.kubeId, entity.metadata.namespace, entity.metadata.name); + +export const getGuidFromKubeDeployment = (kubeGuid: string, namespace: string, name: string): string => + deliminate(name, namespace, kubeGuid); +export const getGuidFromKubeDeploymentObj = (entity: KubernetesDeployment): string => + debugMissingKubeId(entity, getGuidFromKubeDeployment, entity.metadata.kubeId, entity.metadata.namespace, entity.metadata.name); + +export const getGuidFromKubePod = (kubeGuid: string, namespace: string, name: string): string => deliminate(name, namespace, kubeGuid); +export const getGuidFromKubePodObj = (entity: KubernetesPod): string => + debugMissingKubeId(entity, getGuidFromKubePod, entity.metadata.kubeId, entity.metadata.namespace, entity.metadata.name); + +export const getGuidFromKubeDashboard = (kubeGuid: string): string => kubeGuid; +export const getGuidFromKubeDashboardObj = (entity: KubeDashboardStatus): string => getGuidFromKubeDashboard(entity.kubeGuid); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/store/kube.types.ts b/src/frontend/packages/kubernetes/src/kubernetes/store/kube.types.ts new file mode 100644 index 0000000000..9192f7014c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/store/kube.types.ts @@ -0,0 +1,519 @@ +import { KubernetesPodExpandedStatus } from '../services/kubernetes-expanded-state'; + +export interface KubernetesInfo { + nodes: {}; + pods: {}; +} + +export const KubernetesDefaultState = { + pods: {}, + namespaces: {}, + nodes: {} +}; + +export interface BasicKubeAPIResource { + metadata: Metadata; + status: any; + spec: any; +} + +export interface KubeAPIResource extends BasicKubeAPIResource { + metadata: Metadata; + status: BaseStatus; + spec: any; +} + +export interface KubeService extends BasicKubeAPIResource { + metadata: KubeServiceMetadata; + status: ServiceStatus; + spec: DeploymentSpec; +} + +export interface KubernetesStatefulSet extends BasicKubeAPIResource { + metadata: KubeServiceMetadata; + status: ServiceStatus; + spec: ServiceSpec; +} + +export interface DeploymentSpec { + replicas: number; + selector?: any; + template?: any; + strategy?: any; + revisionHistoryLimit: number; + progressDeadlineSeconds: number; + type?: string; + clusterIP?: string; +} + +export interface KubernetesDeployment extends BasicKubeAPIResource { + metadata: KubeServiceMetadata; + status: ServiceStatus; + spec: ServiceSpec; +} + +export interface ServiceStatus { + loadBalancer: LoadBalancerStatus; +} + +export interface LoadBalancerStatus { + ingress: LoadBalancerIngress[]; +} + +export interface LoadBalancerIngress { + hostname: string; + ip: string; +} +export interface ServiceSpec { + ports: Port[]; + clusterIP: string; + type: string; + sessionAffinity: string; + sessionAffinityConfig: GenericMap; + selector: GenericMap; + externalTrafficPolicy: string; +} + +export interface GenericMap { + [key: string]: string; +} + + +export interface KubernetesNode extends BasicKubeAPIResource { + metadata: Metadata; + status: NodeStatus; + spec: PodSpec; +} + +export interface KubernetesApp { + kubeId: string; + name: string; + pods: KubernetesPod[]; + namespace?: string; + createdAt: Date; + status: string; + version: string; + chartName: string; + appVersion: string; +} +export interface NodeStatus { + capacity?: Capacity; + allocatable?: Allocatable; + conditions: KubernetesCondition[]; + addresses: KubernetesAddress[]; + daemonEndpoints?: DaemonEndpoints; + nodeInfo?: NodeInfo; + images: Image[]; +} + + +export interface Taint { + key: string; + effect: string; +} + +export interface Spec { + podCIDR: string; + externalID: string; + taints: Taint[]; +} + +export interface Capacity { + cpu: string; + memory: string; + pods: string; +} + +export interface Allocatable { + cpu: string; + memory: string; + pods: string; +} + +export enum ConditionType { + OutOfDisk = 'OutOfDisk', + MemoryPressure = 'MemoryPressure', + DiskPressure = 'DiskPressure', + Ready = 'Ready', + PIDPressure = 'PIDPressure', + NetworkUnavailable = 'NetworkUnavailable', + CaaspUpdates = 'CaaspUpdates', + CaaspDisruptive = 'CaaspDisruptive', + CaaspSecurity = 'CaaspSecurity' +} +export const ConditionTypeLabels = { + [ConditionType.Ready]: 'Ready', + [ConditionType.OutOfDisk]: 'Out of Disk', + [ConditionType.MemoryPressure]: 'Memory Pressure', + [ConditionType.DiskPressure]: 'Disk Pressure', + [ConditionType.PIDPressure]: 'PID Pressure', + [ConditionType.NetworkUnavailable]: 'Network Unavailable', + [ConditionType.CaaspUpdates]: 'Updates Available', + [ConditionType.CaaspDisruptive]: 'Disruptive Update', + [ConditionType.CaaspSecurity]: 'Security Update' +}; + +export enum ConditionStatus { + False = 'False', + True = 'True', + Unknown = 'Unknown' +} + +export interface KubernetesCondition { + type: ConditionType; + status: ConditionStatus; + lastHeartbeatTime: Date; + lastTransitionTime: Date; + reason: string; + message: string; +} + +export interface KubernetesAddress { + type: string; + address: string; +} + +export const KubernetesAddressInternal = 'InternalIP'; +export const KubernetesAddressExternal = 'ExternalIP'; + +export interface KubeletEndpoint { + Port: number; +} + +export interface DaemonEndpoints { + kubeletEndpoint: KubeletEndpoint; +} + +export interface NodeInfo { + machineID: string; + systemUUID: string; + bootID: string; + kernelVersion: string; + osImage: string; + containerRuntimeVersion: string; + kubeletVersion: string; + kubeProxyVersion: string; + operatingSystem: string; + architecture: string; +} + +export interface Image { + names: string[]; + sizeBytes: number; +} + + +export interface KubernetesPod extends BasicKubeAPIResource { + metadata: Metadata; + status: PodStatus; + spec: PodSpec; + deletionTimestamp?: any; + expandedStatus: KubernetesPodExpandedStatus; +} + +export enum KubernetesStatus { + ACTIVE = 'Active', + RUNNING = 'Running', + FAILED = 'Failed', + PENDING = 'Pending' +} +export interface KubernetesNamespace extends BasicKubeAPIResource { + metadata: Metadata; + spec: { + finalizers: string[]; + }; + status: BaseStatus; +} + +export interface BaseStatus { + phase: KubernetesStatus; +} + +export interface PodStatus { + phase: KubernetesStatus; + conditions?: KubernetesCondition[]; + message?: string; + reason?: string; + hostIP?: string; + podIP?: string; + podIPs?: { + ip: string + }[]; + startTime?: Date; + containerStatuses?: ContainerStatus[]; + qosClass?: string; + initContainerStatuses?: ContainerStatus[]; + nominatedNodeName: string; +} +export interface KubernetesCondition { + type: ConditionType; + status: ConditionStatus; + lastProbeTime?: any; + lastTransitionTime: Date; +} + +export interface ContainerStatus { + name: string; + state: ContainerStateCollection; + lastState: ContainerStateCollection; + ready: boolean; + restartCount: number; + image: string; + imageID: string; + containerID: string; +} + +export interface ContainerStateCollection { + [key: string]: ContainerState; +} + +export interface ContainerState { + startedAt: Date; + reason: string; + signal: number; + exitCode: number; +} + +export interface PodSpec { + volumes?: Volume[]; + containers: Container[]; + restartPolicy?: string; + terminationGracePeriodSeconds?: number; + dnsPolicy?: string; + serviceAccountName?: string; + serviceAccount?: string; + nodeName: string; + securityContext?: SecurityContext; + affinity?: Affinity; + schedulerName: string; + tolerations?: Toleration[]; + hostNetwork?: boolean; + initContainers: InitContainer[]; + // nodeSelector?: NodeSelector; + readinessGates: any[]; +} + +export interface InitContainer { + name: string; + image: string; + command: string[]; + resources: Resources; + volumeMounts: VolumeMount[]; + terminationMessagePath: string; + terminationMessagePolicy: string; + imagePullPolicy: string; + securityContext: SecurityContext; +} + +export interface KubernetesConfigMap { + data: { + apiVersion: string, + binaryData: { + [key: string]: any, + }, + data: { + [key: string]: any, + }, + kind: string + }; + metadata: KubeServiceMetadata; +} +export interface Resources { + limits?: Limits; + requests?: Requests; +} + +export interface Limits { + memory: string; +} + +export interface Requests { + memory: string; + cpu: string; +} +export enum MetricStatistic { + AVERAGE = 'avg', + MAXIMUM = 'max', + MINOMUM = 'min' +} + +export interface VolumeMount { + name: string; + mountPath: string; + readOnly?: boolean; +} + +export interface BaseMetadata { + namespace: string; + name: string; + uid: string; +} + +export interface Metadata extends BaseMetadata { + resourceVersion?: string; + creationTimestamp?: Date; + deletionTimestamp?: Date; + labels?: Labels; + annotations?: Annotations; + kubeId?: string; + generation?: number; +} + +export interface KubeServiceMetadata extends Metadata { + selfLink: string; +} + +export interface Container { + name: string; + image: string; + command: string[]; + ports: Port[]; + resources: Resources; + volumeMounts: VolumeMount[]; + livenessProbe: Probe; + readinessProbe: Probe; + terminationMessagePath: string; + terminationMessagePolicy: string; + imagePullPolicy: string; + securityContext: SecurityContext; + args: string[]; + env: Env[]; +} + +export interface Probe { + httpGet: HttpGet; + initialDelaySeconds: number; + timeoutSeconds: number; + periodSeconds: number; + successThreshold: number; + failureThreshold: number; +} +export interface Env { + name: string; + value: string; + valueFrom?: any; +} + +export interface HttpGet { + path: string; + port: any; + scheme: string; +} + +export interface Port { + name: string; + containerPort: number; + protocol: string; + hostPort?: number; +} + +export interface MatchExpression { + key: string; + operator: string; + values: string[]; +} + +export interface LabelSelector { + matchExpressions: MatchExpression[]; +} + +export interface SecurityContext { + allowPrivilegeEscalation?: boolean; + privileged?: boolean; +} + + +export interface PodAffinityTerm { + labelSelector: LabelSelector; + topologyKey: string; +} + +export interface PreferredDuringSchedulingIgnoredDuringExecution { + weight: number; + podAffinityTerm: PodAffinityTerm; +} + +export interface PodAntiAffinity { + preferredDuringSchedulingIgnoredDuringExecution: PreferredDuringSchedulingIgnoredDuringExecution[]; +} + +export interface Affinity { + podAntiAffinity: PodAntiAffinity; +} + +export interface Toleration { + key: string; + operator: string; + effect: string; + tolerationSeconds?: number; +} + +export interface Labels { + [key: string]: string; +} + +export interface PodLabel { + key: string; + value: string; +} + +export interface Annotations { + [key: string]: string; +} +export interface OwnerReference { + [key: string]: string; +} + +export interface Volume { + name: string; + configMap: ConfigMap; + secret: Secret; + hostPath: HostPath; +} + + +export interface ConfigMap { + name: string; + items: T[]; + defaultMode: number; +} + +export interface Secret { + secretName: string; + defaultMode: number; +} + +export interface HostPath { + path: string; + type: string; +} +export interface Item { + key: string; +} + +export interface KubeStatus { + kind: string; + apiVersion: string; + metadata: Metadata; + status: string; + message: string; + reason: string; + details: {} + code: number +} + +// Analysis Reports + +export interface AnalysisReport { + id: string; + endpoint: string; + type: string; + name: string; + path: string; + created: Date; + read: boolean; + status: string; + duration: number; + report?: any; + title?: string; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.actions.ts b/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.actions.ts new file mode 100644 index 0000000000..ebba3d6dca --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.actions.ts @@ -0,0 +1,372 @@ +import { SortDirection } from '@angular/material/sort'; +import { getActions } from 'frontend/packages/store/src/actions/action.helper'; +import { ApiRequestTypes } from 'frontend/packages/store/src/reducers/api-request-reducer/request-helpers'; + +import { MetricQueryConfig, MetricsAction, MetricsChartAction } from '../../../../store/src/actions/metrics.actions'; +import { getPaginationKey } from '../../../../store/src/actions/pagination.actions'; +import { PaginatedAction, PaginationParam } from '../../../../store/src/types/pagination.types'; +import { EntityRequestAction } from '../../../../store/src/types/request.types'; +import { + KUBERNETES_ENDPOINT_TYPE, + kubernetesDashboardEntityType, + kubernetesDeploymentsEntityType, + kubernetesEntityFactory, + kubernetesNamespacesEntityType, + kubernetesNodesEntityType, + kubernetesPodsEntityType, + kubernetesServicesEntityType, + kubernetesStatefulSetsEntityType, +} from '../kubernetes-entity-factory'; +import { getGuidFromKubeDashboard, getGuidFromKubeNamespace, getGuidFromKubeNode, getGuidFromKubePod } from './kube.getIds'; + +export const GET_RELEASE_POD_INFO = '[KUBERNETES Endpoint] Get Release Pods Info'; +export const GET_RELEASE_POD_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Release Pods Info Success'; +export const GET_RELEASE_POD_INFO_FAILURE = '[KUBERNETES Endpoint] Get Release Pods Info Failure'; + +export const GET_NODES_INFO = '[KUBERNETES Endpoint] Get Nodes Info'; +export const GET_NODES_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Nodes Info Success'; +export const GET_NODES_INFO_FAILURE = '[KUBERNETES Endpoint] Get Nodes Info Failure'; + +export const GET_NODE_INFO = '[KUBERNETES Endpoint] Get Node Info'; +export const GET_NODE_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Node Info Success'; +export const GET_NODE_INFO_FAILURE = '[KUBERNETES Endpoint] Get Node Info Failure'; + +export const GET_POD_INFO = '[KUBERNETES Endpoint] Get Pod Info'; +export const GET_POD_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Pod Info Success'; +export const GET_POD_INFO_FAILURE = '[KUBERNETES Endpoint] Get Pod Info Failure'; + +export const GET_PODS_ON_NODE_INFO = '[KUBERNETES Endpoint] Get Pods on Node Info'; +export const GET_PODS_ON_NODE_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Pods on Node Success'; +export const GET_PODS_ON_NODE_INFO_FAILURE = '[KUBERNETES Endpoint] Get Pods on Node Failure'; + +export const GET_PODS_IN_NAMESPACE_INFO = '[KUBERNETES Endpoint] Get Pods in Namespace Info'; +export const GET_PODS_IN_NAMEPSACE_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Pods in Namespace Success'; +export const GET_PODS_IN_NAMEPSACE_INFO_FAILURE = '[KUBERNETES Endpoint] Get Pods in Namespace Failure'; + +export const GET_SERVICES_IN_NAMESPACE_INFO = '[KUBERNETES Endpoint] Get Services in Namespace Info'; +export const GET_SERVICES_IN_NAMESPACE_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Services in Namespace Success'; +export const GET_SERVICES_IN_NAMESPACE_INFO_FAILURE = '[KUBERNETES Endpoint] Get Services in Namespace Failure'; + +export const GET_NAMESPACES_INFO = '[KUBERNETES Endpoint] Get Namespaces Info'; +export const GET_NAMESPACES_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Namespaces Info Success'; +export const GET_NAMESPACES_INFO_FAILURE = '[KUBERNETES Endpoint] Get Namespaces Info Failure'; + +export const GET_NAMESPACE_INFO = '[KUBERNETES Endpoint] Get Namespace Info'; +export const GET_NAMESPACE_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Namespace Info Success'; +export const GET_NAMESPACE_INFO_FAILURE = '[KUBERNETES Endpoint] Get Namespace Info Failure'; + +export const CREATE_NAMESPACE = '[KUBERNETES Endpoint] Create Namespace'; + +export const GET_KUBERNETES_APP_INFO = '[KUBERNETES Endpoint] Get Kubernetes App Info'; +export const GET_KUBERNETES_APP_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Kubernetes App Info Success'; +export const GET_KUBERNETES_APP_INFO_FAILURE = '[KUBERNETES Endpoint] Get Kubernetes App Info Failure'; + +export const GET_SERVICE_INFO = '[KUBERNETES Endpoint] Get Services Info'; +export const GET_SERVICE_INFO_SUCCESS = '[KUBERNETES Endpoint] Get Services Info Success'; +export const GET_SERVICE_INFO_FAILURE = '[KUBERNETES Endpoint] Get Services Info Failure'; + +export const GET_KUBE_POD = '[KUBERNETES Endpoint] Get K8S Pod Info'; +export const GET_KUBE_POD_SUCCESS = '[KUBERNETES Endpoint] Get K8S Pod Success'; +export const GET_KUBE_POD_FAILURE = '[KUBERNETES Endpoint] Get K8S Pod Failure'; + +export const GET_KUBE_STATEFULSETS = '[KUBERNETES Endpoint] Get K8S Stateful Sets Info'; +export const GET_KUBE_STATEFULSETS_SUCCESS = '[KUBERNETES Endpoint] Get Stateful Sets Success'; +export const GET_KUBE_STATEFULSETS_FAILURE = '[KUBERNETES Endpoint] Get Stateful Sets Failure'; + +export const GET_KUBE_DEPLOYMENT = '[KUBERNETES Endpoint] Get K8S Deployments Info'; +export const GET_KUBE_DEPLOYMENT_SUCCESS = '[KUBERNETES Endpoint] Get Deployments Success'; +export const GET_KUBE_DEPLOYMENT_FAILURE = '[KUBERNETES Endpoint] Get Deployments Failure'; + +export const GET_KUBE_DASHBOARD = '[KUBERNETES Endpoint] Get K8S Dashboard Info'; +export const GET_KUBE_DASHBOARD_SUCCESS = '[KUBERNETES Endpoint] Get Dashboard Success'; +export const GET_KUBE_DASHBOARD_FAILURE = '[KUBERNETES Endpoint] Get Dashboard Failure'; + +const defaultSortParams = { + 'order-direction': 'desc' as SortDirection, + 'order-direction-field': 'name' +}; + + +export interface KubeAction extends EntityRequestAction { + kubeGuid: string; +} +export interface KubePaginationAction extends PaginatedAction, KubeAction { + flattenPagination: boolean; +} +export interface KubeSingleEntityAction extends KubeAction { + guid: string; +} + +export class GetKubernetesNode implements KubeSingleEntityAction { + constructor(public nodeName: string, public kubeGuid: string) { + this.guid = getGuidFromKubeNode(kubeGuid, nodeName); + } + type = GET_NODE_INFO; + entityType = kubernetesNodesEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesNodesEntityType)]; + actions = [ + GET_NODE_INFO, + GET_NODE_INFO_SUCCESS, + GET_NODE_INFO_FAILURE + ]; + guid: string; +} + +export class GetKubernetesNodes implements KubePaginationAction { + constructor(public kubeGuid) { + this.paginationKey = getPaginationKey(kubernetesNodesEntityType, kubeGuid); + } + type = GET_NODES_INFO; + entityType = kubernetesNodesEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesNodesEntityType)]; + actions = [ + GET_NODES_INFO, + GET_NODES_INFO_SUCCESS, + GET_NODES_INFO_FAILURE + ]; + paginationKey: string; + initialParams: PaginationParam = { + ...defaultSortParams + }; + flattenPagination = true; +} + +export class KubeHealthCheck extends GetKubernetesNodes { + constructor(kubeGuid) { + super(kubeGuid); + this.paginationKey = kubeGuid + '-health-check'; + this.initialParams.limit = 1; + } +} + +export class CreateKubernetesNamespace implements KubeSingleEntityAction { + + constructor(public namespaceName: string, public kubeGuid: string) { + this.guid = getGuidFromKubeNamespace(kubeGuid, namespaceName); + } + + type = CREATE_NAMESPACE; + entityType = kubernetesNamespacesEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesNamespacesEntityType)]; + actions = getActions('Namespace', 'Create'); + requestType: ApiRequestTypes = 'create'; + guid: string; +} + +export class GetKubernetesNamespace implements KubeSingleEntityAction { + constructor(public namespaceName: string, public kubeGuid: string) { + this.guid = getGuidFromKubeNamespace(kubeGuid, namespaceName); + } + type = GET_NAMESPACE_INFO; + entityType = kubernetesNamespacesEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesNamespacesEntityType)]; + actions = [ + GET_NAMESPACE_INFO, + GET_NAMESPACE_INFO_SUCCESS, + GET_NAMESPACE_INFO_FAILURE + ]; + guid: string; +} + +export class GetKubernetesNamespaces implements KubePaginationAction { + constructor(public kubeGuid: string) { + this.paginationKey = getPaginationKey(kubernetesNamespacesEntityType, kubeGuid || 'all'); + } + type = GET_NAMESPACES_INFO; + entityType = kubernetesNamespacesEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesNamespacesEntityType)]; + actions = [ + GET_NAMESPACES_INFO, + GET_NAMESPACES_INFO_SUCCESS, + GET_NAMESPACES_INFO_FAILURE + ]; + paginationKey: string; + initialParams = { + ...defaultSortParams + }; + flattenPagination = true; +} + +export class GetKubernetesPod implements KubeSingleEntityAction { + constructor(public podName, public namespaceName, public kubeGuid) { + this.guid = getGuidFromKubePod(kubeGuid, namespaceName, podName); + } + type = GET_KUBE_POD; + entityType = kubernetesPodsEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesPodsEntityType)]; + actions = [ + GET_KUBE_POD, + GET_KUBE_POD_SUCCESS, + GET_KUBE_POD_FAILURE + ]; + guid: string; +} + +export class GetKubernetesPods implements KubePaginationAction { + constructor(public kubeGuid) { + this.paginationKey = getPaginationKey(kubernetesPodsEntityType, 'k8', kubeGuid); + } + type = GET_POD_INFO; + entityType = kubernetesPodsEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesPodsEntityType)]; + actions = [ + GET_POD_INFO, + GET_POD_INFO_SUCCESS, + GET_POD_INFO_FAILURE + ]; + paginationKey: string; + initialParams: PaginationParam = { + ...defaultSortParams + }; + flattenPagination = true; +} + +export class GetKubernetesPodsOnNode extends GetKubernetesPods { + constructor(kubeGuid: string, public nodeName: string) { + super(kubeGuid); + this.paginationKey = getPaginationKey(kubernetesPodsEntityType, `node-${nodeName}`, kubeGuid); + this.initialParams.fieldSelector = `spec.nodeName=${nodeName}`; + } + type = GET_PODS_ON_NODE_INFO; + actions = [ + GET_PODS_ON_NODE_INFO, + GET_PODS_ON_NODE_INFO_SUCCESS, + GET_PODS_ON_NODE_INFO_FAILURE + ]; +} + +export class GetKubernetesPodsInNamespace extends GetKubernetesPods { + constructor(kubeGuid: string, public namespaceName: string) { + super(kubeGuid); + this.paginationKey = getPaginationKey(kubernetesPodsEntityType, `ns-${namespaceName}`, kubeGuid); + } + type = GET_PODS_IN_NAMESPACE_INFO; + actions = [ + GET_PODS_IN_NAMESPACE_INFO, + GET_PODS_IN_NAMEPSACE_INFO_SUCCESS, + GET_PODS_IN_NAMEPSACE_INFO_FAILURE + ]; +} + +export class GetKubernetesServices implements KubePaginationAction { + constructor(public kubeGuid) { + this.paginationKey = getPaginationKey(kubernetesServicesEntityType, kubeGuid); + } + type = GET_SERVICE_INFO; + entityType = kubernetesServicesEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesServicesEntityType)]; + actions = [ + GET_SERVICE_INFO, + GET_SERVICE_INFO_SUCCESS, + GET_SERVICE_INFO_FAILURE + ]; + paginationKey: string; + initialParams: PaginationParam = { + ...defaultSortParams + }; + flattenPagination = true; +} + +export class GetKubernetesServicesInNamespace extends GetKubernetesServices { + constructor(kubeGuid: string, public namespaceName: string) { + super(kubeGuid); + this.paginationKey = getPaginationKey(kubernetesPodsEntityType, namespaceName, kubeGuid); + } + type = GET_SERVICES_IN_NAMESPACE_INFO; + actions = [ + GET_SERVICES_IN_NAMESPACE_INFO, + GET_SERVICES_IN_NAMESPACE_INFO_SUCCESS, + GET_SERVICES_IN_NAMESPACE_INFO_FAILURE + ]; +} + + +export class GetKubernetesStatefulSets implements KubePaginationAction { + constructor(public kubeGuid) { + this.paginationKey = getPaginationKey(kubernetesStatefulSetsEntityType, kubeGuid); + } + type = GET_KUBE_STATEFULSETS; + entityType = kubernetesStatefulSetsEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesStatefulSetsEntityType)]; + actions = [ + GET_KUBE_STATEFULSETS, + GET_KUBE_STATEFULSETS_SUCCESS, + GET_KUBE_STATEFULSETS_FAILURE + ]; + paginationKey: string; + flattenPagination = true; +} + +export class GeKubernetesDeployments implements KubePaginationAction { + constructor(public kubeGuid) { + this.paginationKey = getPaginationKey(kubernetesDeploymentsEntityType, kubeGuid); + } + type = GET_KUBE_DEPLOYMENT; + entityType = kubernetesDeploymentsEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesDeploymentsEntityType)]; + actions = [ + GET_KUBE_DEPLOYMENT, + GET_KUBE_DEPLOYMENT_SUCCESS, + GET_KUBE_DEPLOYMENT_FAILURE + ]; + paginationKey: string; + flattenPagination = true; +} + +export class GetKubernetesDashboard implements KubeSingleEntityAction { + constructor(public kubeGuid: string) { + this.guid = getGuidFromKubeDashboard(kubeGuid); + } + type = GET_KUBE_DASHBOARD; + entityType = kubernetesDashboardEntityType; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = [kubernetesEntityFactory(kubernetesDashboardEntityType)]; + + actions = [ + GET_KUBE_DASHBOARD, + GET_KUBE_DASHBOARD_SUCCESS, + GET_KUBE_DASHBOARD_FAILURE + ]; + guid: string; +} + +function getKubeMetricsAction(guid: string) { + return `${MetricsAction.getBaseMetricsURL()}/kubernetes/${guid}`; +} + +export class FetchKubernetesMetricsAction extends MetricsAction { + constructor(guid: string, cfGuid: string, metricQuery: string) { + super( + guid, + cfGuid, + new MetricQueryConfig(metricQuery), + getKubeMetricsAction(guid), + undefined, + undefined, + undefined, + KUBERNETES_ENDPOINT_TYPE + ); + } +} + +export class FetchKubernetesChartMetricsAction extends MetricsChartAction { + constructor(guid: string, cfGuid: string, metricQuery: string) { + super( + guid, + cfGuid, + new MetricQueryConfig(metricQuery), + getKubeMetricsAction(guid), + KUBERNETES_ENDPOINT_TYPE + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.effects.ts b/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.effects.ts new file mode 100644 index 0000000000..a430bc9bb1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/store/kubernetes.effects.ts @@ -0,0 +1,419 @@ +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Action, Store } from '@ngrx/store'; +import { ClearPaginationOfType } from 'frontend/packages/store/src/actions/pagination.actions'; +import { ApiRequestTypes } from 'frontend/packages/store/src/reducers/api-request-reducer/request-helpers'; +import { connectedEndpointsOfTypesSelector } from 'frontend/packages/store/src/selectors/endpoint.selectors'; +import { of } from 'rxjs'; +import { catchError, first, flatMap, map, mergeMap, switchMap } from 'rxjs/operators'; + +import { environment } from '../../../../core/src/environments/environment'; +import { isJetstreamError } from '../../../../store/src/jetstream'; +import { AppState, entityCatalog, NormalizedResponse, WrapperRequestActionSuccess } from '../../../../store/src/public-api'; +import { StartRequestAction, WrapperRequestActionFailed } from '../../../../store/src/types/request.types'; +import { + KUBERNETES_ENDPOINT_TYPE, + kubernetesDashboardEntityType, + kubernetesPodsEntityType, +} from '../kubernetes-entity-factory'; +import { KubernetesPodExpandedStatusHelper } from '../services/kubernetes-expanded-state'; +import { + BasicKubeAPIResource, + KubernetesDeployment, + KubernetesNamespace, + KubernetesNode, + KubernetesPod, + KubernetesStatefulSet, + KubeService, +} from './kube.types'; +import { + CREATE_NAMESPACE, + CreateKubernetesNamespace, + GeKubernetesDeployments, + GET_KUBE_DASHBOARD, + GET_KUBE_DEPLOYMENT, + GET_KUBE_POD, + GET_KUBE_STATEFULSETS, + GET_NAMESPACE_INFO, + GET_NAMESPACES_INFO, + GET_NODE_INFO, + GET_NODES_INFO, + GET_POD_INFO, + GET_PODS_IN_NAMESPACE_INFO, + GET_PODS_ON_NODE_INFO, + GET_SERVICE_INFO, + GET_SERVICES_IN_NAMESPACE_INFO, + GetKubernetesDashboard, + GetKubernetesNamespace, + GetKubernetesNamespaces, + GetKubernetesNode, + GetKubernetesNodes, + GetKubernetesPod, + GetKubernetesPods, + GetKubernetesPodsInNamespace, + GetKubernetesPodsOnNode, + GetKubernetesServices, + GetKubernetesServicesInNamespace, + GetKubernetesStatefulSets, + KubeAction, + KubePaginationAction, +} from './kubernetes.actions'; + +export interface KubeDashboardContainer { + name: string; + image: string; +} + +export interface KubeDashboardStatus { + guid: string; + kubeGuid: string; + installed: boolean; + stratosInstalled: boolean; + running: boolean; + pod: { + spec: { + containers: KubeDashboardContainer[]; + }; + }; + version: string; + service: { + namespace: string; + name: string; + scheme: string; + }; + serviceAccount: any; +} + +@Injectable() +export class KubernetesEffects { + proxyAPIVersion = environment.proxyAPIVersion; + + constructor(private http: HttpClient, private actions$: Actions, private store: Store) { } + + @Effect() + fetchDashboardInfo$ = this.actions$.pipe( + ofType(GET_KUBE_DASHBOARD), + flatMap(action => { + this.store.dispatch(new StartRequestAction(action)); + const headers = new HttpHeaders({}); + const requestArgs = { + headers + }; + const url = `/pp/${this.proxyAPIVersion}/kubedash/${action.kubeGuid}/status`; + const dashboardEntityConfig = entityCatalog.getEntity(KUBERNETES_ENDPOINT_TYPE, kubernetesDashboardEntityType); + return this.http + .get(url, requestArgs) + .pipe(mergeMap(response => { + const result = { + entities: { [dashboardEntityConfig.entityKey]: {} }, + result: [] + } as NormalizedResponse; + const status = response as KubeDashboardStatus; + status.kubeGuid = action.kubeGuid; + result.entities[dashboardEntityConfig.entityKey][action.guid] = status; + result.result.push(action.guid); + return [ + new WrapperRequestActionSuccess(result, action) + ]; + }), catchError(error => [ + new WrapperRequestActionFailed(error.message, action, 'fetch', { + endpointIds: [action.kubeGuid], + url: error.url || url, + eventCode: error.status ? error.status + '' : '500', + message: 'Kubernetes Dashboard request error', + error + }) + ])); + }) + ); + + @Effect() + fetchNodesInfo$ = this.actions$.pipe( + ofType(GET_NODES_INFO), + flatMap(action => this.processNodeAction(action)) + ); + + @Effect() + fetchNodeInfo$ = this.actions$.pipe( + ofType(GET_NODE_INFO), + flatMap(action => this.processSingleItemAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/nodes/${action.nodeName}` + )) + ); + + @Effect() + fetchNamespaceInfo$ = this.actions$.pipe( + ofType(GET_NAMESPACE_INFO), + flatMap(action => this.processSingleItemAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/namespaces/${action.namespaceName}`) + ) + ); + + @Effect() + fetchPodsInfo$ = this.actions$.pipe( + ofType(GET_POD_INFO), + flatMap(action => this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/pods` + )) + ); + + @Effect() + fetchPodsOnNodeInfo$ = this.actions$.pipe( + ofType(GET_PODS_ON_NODE_INFO), + flatMap(action => + this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/pods`, + // Note - filtering done via param in action + ) + ) + ); + + @Effect() + fetchPodsInNamespaceInfo$ = this.actions$.pipe( + ofType(GET_PODS_IN_NAMESPACE_INFO), + flatMap(action => this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/namespaces/${action.namespaceName}/pods`, + )) + ); + + @Effect() + fetchServicesInNamespaceInfo$ = this.actions$.pipe( + ofType(GET_SERVICES_IN_NAMESPACE_INFO), + flatMap(action => this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/namespaces/${action.namespaceName}/services`, + )) + ); + + @Effect() + fetchPodInfo$ = this.actions$.pipe( + ofType(GET_KUBE_POD), + flatMap(action => this.processSingleItemAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/namespaces/${action.namespaceName}/pods/${action.podName}`, + )) + ); + + @Effect() + fetchServicesInfo$ = this.actions$.pipe( + ofType(GET_SERVICE_INFO), + flatMap(action => this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/services`, + )) + ); + + @Effect() + fetchNamespacesInfo$ = this.actions$.pipe( + ofType(GET_NAMESPACES_INFO), + flatMap(action => this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/namespaces`, + )) + ); + + + @Effect() + createNamespace$ = this.actions$.pipe( + ofType(CREATE_NAMESPACE), + flatMap(action => this.processSingleItemAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/namespaces`, + { + kind: 'Namespace', + apiVersion: 'v1', + metadata: { + name: action.namespaceName, + }, + } + ) + ) + ); + + @Effect() + fetchStatefulSets$ = this.actions$.pipe( + ofType(GET_KUBE_STATEFULSETS), + flatMap(action => this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/apis/apps/v1/statefulsets`, + )) + ); + + @Effect() + fetchDeployments$ = this.actions$.pipe( + ofType(GET_KUBE_DEPLOYMENT), + flatMap(action => this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/apis/apps/v1/deployments`, + )) + ); + + private processNodeAction(action: GetKubernetesNodes) { + return this.processListAction( + action, + `/pp/${this.proxyAPIVersion}/proxy/api/v1/nodes` + ); + } + + private processListAction( + action: KubePaginationAction, + url: string) { + this.store.dispatch(new StartRequestAction(action)); + + const getKubeIds = action.kubeGuid ? + of([action.kubeGuid]) : + this.store.select(connectedEndpointsOfTypesSelector(KUBERNETES_ENDPOINT_TYPE)).pipe( + first(), + map(endpoints => Object.values(endpoints).map(endpoint => endpoint.guid)) + ); + let pKubeIds: string[]; + + const entityKey = entityCatalog.getEntityKey(action); + return getKubeIds.pipe( + switchMap(kubeIds => { + pKubeIds = kubeIds; + const headers = new HttpHeaders({ 'x-cap-cnsi-list': pKubeIds }); + const requestArgs = { + headers, + params: null + }; + const paginationAction = action as KubePaginationAction; + if (paginationAction.initialParams) { + requestArgs.params = Object.keys(paginationAction.initialParams).reduce((httpParams, initialKey: string) => { + return httpParams.set(initialKey, paginationAction.initialParams[initialKey].toString()); + }, new HttpParams()); + } + return this.http.get(url, requestArgs); + }), + mergeMap(allRes => { + const base = { + entities: { [entityKey]: {} }, + result: [] + } as NormalizedResponse; + + const items: Array = Object.entries(allRes).reduce((combinedRes, [kubeId, res]) => { + if (!res.items) { + // The request to this endpoint has failed. Note - throwing this hides any other failures, + // however we follow the same approach elsewhere + throw res; + } + res.items.forEach(item => { + item.metadata.kubeId = kubeId; + combinedRes.push(item); + }); + return combinedRes; + }, []); + const processesData = items + .reduce((res, data) => { + const id = action.entity[0].getId(data); + const updatedData = action.entityType === kubernetesPodsEntityType ? + KubernetesPodExpandedStatusHelper.updatePodWithExpandedStatus(data as unknown as KubernetesPod) : + data; + res.entities[entityKey][id] = updatedData; + res.result.push(id); + return res; + }, base); + return [ + new WrapperRequestActionSuccess(processesData, action) + ]; + }), + catchError(error => { + const { status, message } = this.createKubeError(error); + return [ + new WrapperRequestActionFailed(message, action, 'fetch', { + endpointIds: pKubeIds, + url: error.url || url, + eventCode: status, + message, + error, + }) + ]; + }) + ); + } + + private processSingleItemAction( + action: KubeAction, + url: string, + body?: any) { + const requestType: ApiRequestTypes = body ? 'create' : 'fetch'; + this.store.dispatch(new StartRequestAction(action, requestType)); + const headers = new HttpHeaders({ + 'x-cap-cnsi-list': action.kubeGuid, + 'x-cap-passthrough': 'true' + }, + ); + const requestArgs = { + headers + }; + const request = body ? this.http.post(url, body, requestArgs) : this.http.get(url, requestArgs); + const entityKey = entityCatalog.getEntityKey(action); + return request + .pipe( + mergeMap((response: T) => { + const res = { + entities: { [entityKey]: {} }, + result: [] + } as NormalizedResponse; + const data = action.entityType === kubernetesPodsEntityType ? + KubernetesPodExpandedStatusHelper.updatePodWithExpandedStatus(response as unknown as KubernetesPod) : + response; + res.entities[entityKey][action.guid] = data; + res.result.push(action.guid); + const actions: Action[] = [ + new WrapperRequestActionSuccess(res, action) + ]; + if (requestType === 'create') { + actions.push(new ClearPaginationOfType(action)); + } + return actions; + }), + catchError(error => { + const { status, message } = this.createKubeError(error); + return [ + new WrapperRequestActionFailed(message, action, requestType, { + endpointIds: [action.kubeGuid], + url: error.url || url, + eventCode: status, + message, + error + }) + ]; + }) + ); + } + + private createKubeErrorMessage(err: any): string { + if (err) { + if (err.error && err.error.message) { + // Kube error + return err.error.message; + } else if (err.message) { + // Http error + return err.message; + } + } + return 'Kubernetes API request error'; + } + + private createKubeError(err: any): { status: string, message: string, } { + const jetstreamError = isJetstreamError(err); + if (jetstreamError) { + // Wrapped error + return { + status: jetstreamError.error.statusCode.toString(), + message: this.createKubeErrorMessage(jetstreamError.errorResponse) + }; + } + return { + status: err && err.status ? err.status + '' : '500', + message: this.createKubeErrorMessage(err) + }; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.html b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.html new file mode 100644 index 0000000000..a695a022d0 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.html @@ -0,0 +1,4 @@ +
+
+
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.scss new file mode 100644 index 0000000000..0f224e7f96 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.scss @@ -0,0 +1,21 @@ +.info { + + &__card { + display: flex; + } + + &__card-icon { + flex: 0 0 140px; + width: 140px; + &>img { + max-width: 120px; + } + margin-right: 10px; + text-align: center; + } + + &__card-text { + flex: 1; + } + +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.spec.ts new file mode 100644 index 0000000000..88894b763d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.spec.ts @@ -0,0 +1,29 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AnalysisInfoCardComponent } from './analysis-info-card.component'; + +describe('AnalysisInfoCardComponent', () => { + let component: AnalysisInfoCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AnalysisInfoCardComponent ], + imports: [ + HttpClientTestingModule, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AnalysisInfoCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.theme.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.theme.scss new file mode 100644 index 0000000000..06983eb900 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.theme.scss @@ -0,0 +1,21 @@ + +@mixin kube-analysis-card-theme($theme, $app-theme) { + + .info__card { + + P { + font-size: 14px; + line-height: 1.24em; + } + + h2 { + font-size: 18px; + padding: 0; + } + + h2 { + font-size: 16px; + padding: 0; + } + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.ts new file mode 100644 index 0000000000..4fb75c972c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.ts @@ -0,0 +1,55 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, Input } from '@angular/core'; +import markdown from 'marked'; +import { Observable, of } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +@Component({ + selector: 'app-analysis-info-card', + templateUrl: './analysis-info-card.component.html', + styleUrls: ['./analysis-info-card.component.scss'] +}) +export class AnalysisInfoCardComponent { + + public loading = true; + public content$: Observable; + private renderer = new markdown.Renderer(); + + public mAanalyzer = {}; + + @Input() set analyzer(analyzer: any) { + if (analyzer && analyzer.descriptionUrl) { + this.content$ = this.getDescription(analyzer.descriptionUrl); + } + this.mAanalyzer = analyzer; + } + + get analyzer() { + return this.mAanalyzer; + } + + constructor(private http: HttpClient) { + this.renderer.link = (href, title, text) => `${text}`; + this.renderer.code = (text: string) => `${text}`; + } + + private getDescription(url): Observable { + return this.http.get(url, { responseType: 'text' }).pipe( + map(resp => { + this.loading = false; + return markdown(resp, { + renderer: this.renderer + }); + }), + catchError((error) => { + this.loading = false; + if (error.status === 404) { + return of('

Unable to load description for this Analyzer

'); + } else { + return of('

An error occurred retrieving description for this Analyzer

'); + } + } + )); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.html b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.html new file mode 100644 index 0000000000..b88ffd84a2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.html @@ -0,0 +1,7 @@ + +
+
+ +
+
+
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.scss new file mode 100644 index 0000000000..53951f99ae --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.scss @@ -0,0 +1,5 @@ +.info__title { + padding: 0; + margin: 0 0 30px 0; + font-size: 22px; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.spec.ts new file mode 100644 index 0000000000..12fc0e9c0b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedModule } from '../../../../../../core/src/public-api'; +import { SidePanelService } from '../../../../../../core/src/shared/services/side-panel.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { AnalysisInfoCardComponent } from './analysis-info-card/analysis-info-card.component'; +import { KubernetesAnalysisInfoComponent } from './kubernetes-analysis-info.component'; + + +describe('KubernetesAnalysisInfoComponent', () => { + let component: KubernetesAnalysisInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesAnalysisInfoComponent, AnalysisInfoCardComponent], + imports: [ + SharedModule, + KubernetesBaseTestModules, + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + SidePanelService, + KubeBaseGuidMock, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesAnalysisInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.ts new file mode 100644 index 0000000000..13213f6394 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { PreviewableComponent } from 'frontend/packages/core/src/shared/previewable-component'; +import { Observable } from 'rxjs'; + +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; + + +@Component({ + selector: 'app-kubernetes-analysis-info', + templateUrl: './kubernetes-analysis-info.component.html', + styleUrls: ['./kubernetes-analysis-info.component.scss'], + providers: [ + KubernetesAnalysisService + ] +}) +export class KubernetesAnalysisInfoComponent implements PreviewableComponent { + + analyzers$: Observable; + + setProps(props: { [key: string]: any, }) { + this.analyzers$ = props.analyzers$; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.html b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.html new file mode 100644 index 0000000000..25926fc569 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.scss new file mode 100644 index 0000000000..f70ee0c2c3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.scss @@ -0,0 +1,43 @@ +.report { + &__report-header { + align-items: center; + display: flex; + margin-bottom: 8px; + } + &__header { + align-items: center; + display: flex; + } + &__title { + flex: 1; + } + &__stat { + display: flex; + flex-direction: column; + padding: 5px 12px; + &>div:first-child { + opacity: 0.8; + } + } + &__score { + flex: 0; + font-size: 20px; + } + &__grade { + flex: 0; + font-size: 20px; + } + &__table { + margin-left: 20px; + } + &__issue { + align-items: center; + display: flex; + } + &__icon { + padding-right: 4px; + } + &__table-name { + vertical-align: top; + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.spec.ts new file mode 100644 index 0000000000..5e94f069a2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../../../core/src/tab-nav.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { CoreModule } from './../../../../../../core/src/core/core.module'; +import { AnalysisReportViewerComponent } from './../../../analysis-report-viewer/analysis-report-viewer.component'; +import { KubernetesAnalysisReportComponent } from './kubernetes-analysis-report.component'; + + +describe('KubernetesAnalysisReportComponent', () => { + let component: KubernetesAnalysisReportComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesAnalysisReportComponent, AnalysisReportViewerComponent], + imports: [ + KubernetesBaseTestModules, + CoreModule, + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + TabNavService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesAnalysisReportComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.theme.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.theme.scss new file mode 100644 index 0000000000..4ea5002e16 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.theme.scss @@ -0,0 +1,13 @@ + +@mixin kube-analysis-report-theme($theme, $app-theme) { + $backgrounds: map-get($theme, background); + $background: mat-color($backgrounds, card); + $background-color: map-get($app-theme, app-background-color); + $darker-background-color: darken($background-color, 4%); + .report__header { + background-color: $darker-background-color; + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + padding-left: 10px; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.ts new file mode 100644 index 0000000000..ab39ef6700 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.ts @@ -0,0 +1,84 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { IHeaderBreadcrumbLink } from 'frontend/packages/core/src/shared/components/page-header/page-header.types'; +import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; +import { catchError, first, map, startWith } from 'rxjs/operators'; + +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { getParentURL } from '../../../services/route.helper'; + +@Component({ + selector: 'app-kubernetes-analysis-report', + templateUrl: './kubernetes-analysis-report.component.html', + styleUrls: ['./kubernetes-analysis-report.component.scss'] +}) +export class KubernetesAnalysisReportComponent implements OnInit { + + report$: Observable; + private errorMsg = new Subject(); + errorMsg$ = this.errorMsg.pipe(startWith('')); + isLoading$: Observable; + + endpointID: string; + id: string; + + private breadcrumbsSubject: BehaviorSubject; + public breadcrumbs$: Observable; + + constructor( + private analysisService: KubernetesAnalysisService, + private route: ActivatedRoute, + private kubeEndpointService: KubernetesEndpointService, + ) { + this.id = route.snapshot.params.id; + + this.breadcrumbsSubject = new BehaviorSubject(undefined); + this.breadcrumbs$ = this.breadcrumbsSubject.asObservable(); + this.breadcrumbsSubject.next([ + { value: 'Analysis', routerLink: getParentURL(route, 2) }, + { value: 'Report' }, + ]); + } + + ngOnInit() { + if (!this.id) { + return; + } + + this.report$ = this.analysisService.getByID(this.kubeEndpointService.baseKube.guid, this.id).pipe( + map((response: any) => { + if (!response.type) { + this.error(); + return false; + } + this.errorMsg.next(''); + return response; + }), + catchError((e, c) => { + this.error(); + return of(false); + }) + ); + + this.isLoading$ = this.report$.pipe( + map(() => false), + startWith(true) + ); + + // When the report has loaded, update the name in the breadcrumbs + this.report$.pipe(first()).subscribe(report => { + this.breadcrumbsSubject.next([ + { value: 'Analysis', routerLink: getParentURL(this.route, 2) }, + { value: report.name }, + ]); + }); + } + + error() { + const msg = { firstLine: 'Failed to load Analysis Report' }; + this.errorMsg.next(msg); + } +} + + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.html new file mode 100644 index 0000000000..42ebd5c5d8 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.spec.ts new file mode 100644 index 0000000000..ea14935d32 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MDAppModule } from '../../../../../core/src/public-api'; +import { TabNavService } from '../../../../../core/src/tab-nav.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { AnalysisReportViewerComponent } from './../../analysis-report-viewer/analysis-report-viewer.component'; +import { KubernetesAnalysisTabComponent } from './kubernetes-analysis-tab.component'; + + +describe('KubernetesAnalysisTabComponent', () => { + let component: KubernetesAnalysisTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesAnalysisTabComponent, AnalysisReportViewerComponent], + imports: [ + KubernetesBaseTestModules, + MDAppModule + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + TabNavService, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesAnalysisTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.ts new file mode 100644 index 0000000000..f0db6bbd91 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; +import { ListConfig } from 'frontend/packages/core/src/shared/components/list/list.component.types'; + +import { AnalysisReportsListConfig } from '../../list-types/analysis-reports-list-config.service'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; + +@Component({ + selector: 'app-kubernetes-analysis-tab', + templateUrl: './kubernetes-analysis-tab.component.html', + styleUrls: ['./kubernetes-analysis-tab.component.scss'], + providers: [ + KubernetesAnalysisService, + { + provide: ListConfig, + useClass: AnalysisReportsListConfig, + } + ] +}) +export class KubernetesAnalysisTabComponent { + + constructor(public kubeEndpointService: KubernetesEndpointService) { } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component.html new file mode 100644 index 0000000000..dd2b6cb351 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component.ts new file mode 100644 index 0000000000..3bbe73f96a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-namespaces-tab/kubernetes-namespaces-tab.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; + +import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { + KubernetesNamespacesListConfigService, +} from '../../list-types/kubernetes-namespaces/kubernetes-namespaces-list-config.service'; + +@Component({ + selector: 'app-kubernetes-namespaces-tab', + templateUrl: './kubernetes-namespaces-tab.component.html', + styleUrls: ['./kubernetes-namespaces-tab.component.scss'], + providers: [{ + provide: ListConfig, + useClass: KubernetesNamespacesListConfigService, + }] +}) +export class KubernetesNamespacesTabComponent { + + constructor(private activatedRoute: ActivatedRoute) { } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.html new file mode 100644 index 0000000000..dd2b6cb351 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.spec.ts new file mode 100644 index 0000000000..139bdd8e5a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.spec.ts @@ -0,0 +1,29 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesNodesTabComponent } from './kubernetes-nodes-tab.component'; + +describe('KubernetesNodesTabComponent', () => { + let component: KubernetesNodesTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesNodesTabComponent], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesNodesTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.ts new file mode 100644 index 0000000000..a33344f0d1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-nodes-tab/kubernetes-nodes-tab.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { KubernetesNodesListConfigService } from '../../list-types/kubernetes-nodes/kubernetes-nodes-list-config.service'; + +@Component({ + selector: 'app-kubernetes-nodes-tab', + templateUrl: './kubernetes-nodes-tab.component.html', + styleUrls: ['./kubernetes-nodes-tab.component.scss'], + providers: [{ + provide: ListConfig, + useClass: KubernetesNodesListConfigService, + }] +}) +export class KubernetesNodesTabComponent { + + constructor() { } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.html new file mode 100644 index 0000000000..dd2b6cb351 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.spec.ts new file mode 100644 index 0000000000..b046e6a850 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.spec.ts @@ -0,0 +1,30 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BaseKubeGuid } from '../../kubernetes-page.types'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesPodsTabComponent } from './kubernetes-pods-tab.component'; + +describe('KubernetesPodsTabComponent', () => { + let component: KubernetesPodsTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesPodsTabComponent], + imports: KubernetesBaseTestModules, + providers: [BaseKubeGuid, KubernetesEndpointService] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesPodsTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.ts new file mode 100644 index 0000000000..3315248a53 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-pods-tab/kubernetes-pods-tab.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; + +import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { KubernetesPodsListConfigService } from '../../list-types/kubernetes-pods/kubernetes-pods-list-config.service'; + +@Component({ + selector: 'app-kubernetes-pods-tab', + templateUrl: './kubernetes-pods-tab.component.html', + styleUrls: ['./kubernetes-pods-tab.component.scss'], + providers: [{ + provide: ListConfig, + useClass: KubernetesPodsListConfigService, + }] +}) +export class KubernetesPodsTabComponent { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.html b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.html new file mode 100644 index 0000000000..6029db052e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.html @@ -0,0 +1,125 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+ + + + +
+
+
+
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.scss new file mode 100644 index 0000000000..ff59086255 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.scss @@ -0,0 +1,92 @@ +.kube-details { + margin-bottom: 0; + margin-left: 20px; + margin-top: 10px; + &__metrics { + max-width: 350px; + + app-tile { + flex: 0; + &:nth-child(2) { + display: flex; + flex: 2; + justify-content: center; + } + } + } + &__header { + margin: 0; + } + &__graphs { + margin: 10px; + } + &__graph { + display: inline-block; + padding: 0 20px 20px 0; + width: 250px; + } + &__dashboard { + font-size: 14px; + margin: -24px 20px 24px 24px; + } + &__spacing { + font-size: 14px; + margin: 12px 0; + } + &__button { + margin-left: 4px; + } + &__dashboard-version { + align-items: baseline; + display: flex; + } + &__dashboard-version-label { + margin-right: 10px; + } + &__dashboard-configure { + line-height: initial; + margin: 0; + min-width: auto; + padding: 0; + } +} + +.app-metadata { + display: flex; + flex-direction: row; + margin-left: 12px; + + &__two-cols { + margin-right: 20px; + + app-metadata-item:first-child { + margin-top: 0; + } + } + + .caasp-version { + display: flex; + + mat-icon { + cursor: help; + font-size: 20px; + height: 20px; + margin-left: 12px; + width: 20px; + } + } + + .caasp-updates { + align-items: center; + display: flex; + + &__security, + &__disruptive { + border-radius: 8px; + font-size: 12px; + margin-left: 10px; + padding: 1px 8px; + } + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.spec.ts new file mode 100644 index 0000000000..05b38cdc1a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.spec.ts @@ -0,0 +1,37 @@ +import { HttpClient, HttpHandler } from '@angular/common/http'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../../core/src/tab-nav.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubernetesSummaryTabComponent } from './kubernetes-summary.component'; + +describe('KubernetesSummaryTabComponent', () => { + let component: KubernetesSummaryTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [KubernetesSummaryTabComponent], + imports: [...KubernetesBaseTestModules], + providers: [ + KubernetesEndpointService, + KubeBaseGuidMock, + HttpClient, + HttpHandler, + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesSummaryTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.theme.scss b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.theme.scss new file mode 100644 index 0000000000..6b9afbebb3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.theme.scss @@ -0,0 +1,17 @@ + +@mixin kube-summary-theme($theme, $app-theme) { + $status-colors: map-get($app-theme, status); + $status-warning: map-get($status-colors, warning); + $status-danger: map-get($status-colors, danger); + + .caasp-updates { + &__security { + border: 1px solid $status-danger; + color: $status-danger; + } + &__disruptive { + border: 1px solid $status-warning; + color: $status-warning; + } + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.ts new file mode 100644 index 0000000000..efe1eeab7c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/tabs/kubernetes-summary-tab/kubernetes-summary.component.ts @@ -0,0 +1,209 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { SafeResourceUrl } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { combineLatest, interval, Observable, Subscription } from 'rxjs'; +import { first, map, startWith } from 'rxjs/operators'; + +import { safeUnsubscribe } from '../../../../../core/src/core/utils.service'; +import { + IChartThresholds, + ISimpleUsageChartData, +} from '../../../../../core/src/shared/components/simple-usage-chart/simple-usage-chart.types'; +import { PaginationMonitorFactory } from '../../../../../store/src/monitors/pagination-monitor.factory'; +import { AppState, entityCatalog } from '../../../../../store/src/public-api'; +import { getCurrentPageRequestInfo } from '../../../../../store/src/reducers/pagination-reducer/pagination-reducer.types'; +import { PaginatedAction, PaginationEntityState } from '../../../../../store/src/types/pagination.types'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { CaaspNodesData, KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; + +interface IValueLabels { + usedLabel?: string; + remainingLabel?: string; + unknownLabel?: string; + warningText?: string; +} +interface IEndpointDetails { + imagePath: string; + label: string; + name: string; +} + +@Component({ + selector: 'app-kubernetes-summary', + templateUrl: './kubernetes-summary.component.html', + styleUrls: ['./kubernetes-summary.component.scss'] +}) +export class KubernetesSummaryTabComponent implements OnInit, OnDestroy { + public podCount$: Observable; + public nodeCount$: Observable; + public namespaceCount$: Observable; + + public highUsageColors = { + domain: ['#00000026', '#00af00'] + }; + public normalUsageColors = { + domain: ['#00af00', '#00af002e'] + }; + public chartHeight = '150px'; + public endpointDetails$: Observable = this.kubeEndpointService.endpoint$.pipe( + map(endpoint => { + const endpointConfig = entityCatalog.getEndpoint(endpoint.entity.cnsi_type, endpoint.entity.sub_type); + const { logoUrl, label } = endpointConfig.definition; + // const { imagePath, label } = entityCatalog.getEndpoint(endpoint.entity.cnsi_type, endpoint.entity.sub_type); + + // const { imagePath, label } = getEndpointType(endpoint.entity.cnsi_type, endpoint.entity.sub_type); + return { + imagePath: logoUrl, + label, + name: endpoint.entity.name, + }; + }) + ); + source: SafeResourceUrl; + + dashboardLink: string; + kubeTerminalLink: string; + + public podCapacity$: Observable; + public diskPressure$: Observable; + public memoryPressure$: Observable; + public outOfDisk$: Observable; + public nodesReady$: Observable; + public networkUnavailable$: Observable; + public kubeNodeVersions$: Observable; + public caaspData$: Observable; + + public pressureChartThresholds: IChartThresholds = { + danger: 90, + warning: 0, + }; + + public nominalPressureChartThresholds: IChartThresholds = { + warning: 100, + inverted: true + }; + + public criticalPressureChartThresholds: IChartThresholds = { + danger: 0 + }; + + public criticalPressureChartThresholdsInverted: IChartThresholds = { + danger: 100, + inverted: true + }; + + private polls: Subscription[] = []; + + public isLoading$: Observable; + + constructor( + public kubeEndpointService: KubernetesEndpointService, + public httpClient: HttpClient, + public paginationMonitorFactory: PaginationMonitorFactory, + private store: Store, + private ngZone: NgZone, + private router: Router, + ) { + } + + // Go the Kubernetes Dashboard configuration page + public configureDashboard() { + const guid = this.kubeEndpointService.baseKube.guid; + this.router.navigate([`/kubernetes/${guid}/dashboard-config`]); + } + ngOnInit() { + const guid = this.kubeEndpointService.baseKube.guid; + + const podsObs = kubeEntityCatalog.pod.store.getPaginationService(guid); + const pods$ = podsObs.entities$; + this.poll(kubeEntityCatalog.pod.actions.getMultiple(guid), podsObs.pagination$); + const nodesObs = kubeEntityCatalog.node.store.getPaginationService(guid); + const nodes$ = nodesObs.entities$; + this.poll(kubeEntityCatalog.node.actions.getMultiple(guid), nodesObs.pagination$); + const namespacesObs = kubeEntityCatalog.namespace.store.getPaginationService(guid); + const namespaces$ = namespacesObs.entities$; + this.poll(kubeEntityCatalog.namespace.actions.getMultiple(guid), namespacesObs.pagination$); + + this.podCount$ = this.kubeEndpointService.getCountObservable(pods$); + this.nodeCount$ = this.kubeEndpointService.getCountObservable(nodes$); + this.namespaceCount$ = this.kubeEndpointService.getCountObservable(namespaces$); + + this.podCapacity$ = this.kubeEndpointService.getPodCapacity(nodes$, pods$); + this.diskPressure$ = this.kubeEndpointService.getNodeStatusCount(nodes$, 'DiskPressure', { + usedLabel: 'Nodes with disk pressure', + remainingLabel: 'Nodes with no disk pressure', + unknownLabel: 'Nodes with unknown disk pressure', + warningText: 'Nodes with unknown disk pressure found' + }); + this.memoryPressure$ = this.kubeEndpointService.getNodeStatusCount(nodes$, 'MemoryPressure', { + usedLabel: 'Nodes with memory pressure', + remainingLabel: 'Nodes with no memory pressure', + unknownLabel: 'Nodes with unknown memory pressure', + warningText: 'Nodes with unknown memory pressure found' + }); + this.outOfDisk$ = this.kubeEndpointService.getNodeStatusCount(nodes$, 'OutOfDisk', { + usedLabel: 'Nodes that are out of disk space', + remainingLabel: 'Nodes that have disk space remaining', + unknownLabel: 'Nodes with unknown remaining disk space', + warningText: 'Nodes with unknown remaining disk space found' + }); + this.networkUnavailable$ = this.kubeEndpointService.getNodeStatusCount(nodes$, 'NetworkUnavailable', { + usedLabel: 'Nodes with available networks', + remainingLabel: 'Nodes with unavailable networks', + unknownLabel: 'Nodes with unknown networks availability', + warningText: 'Nodes with unknown networks availability found' + }, 'False'); + this.nodesReady$ = this.kubeEndpointService.getNodeStatusCount(nodes$, 'Ready', { + usedLabel: 'Nodes are ready', + remainingLabel: 'Nodes are not ready', + unknownLabel: 'Nodes with unknown ready status', + warningText: `Nodes with unknown ready status found` + }); + this.dashboardLink = `/kubernetes/${guid}/dashboard`; + this.kubeTerminalLink = `/kubernetes/${guid}/terminal`; + + this.kubeNodeVersions$ = this.kubeEndpointService.getNodeKubeVersions(nodes$).pipe(startWith('-')); + + this.caaspData$ = this.kubeEndpointService.getCaaspNodesData(nodes$); + + this.isLoading$ = combineLatest([ + this.endpointDetails$, + this.podCount$, + this.nodeCount$, + this.podCapacity$, + this.diskPressure$, + this.memoryPressure$, + this.outOfDisk$, + this.nodesReady$, + this.networkUnavailable$, + ]).pipe( + map(() => false), + startWith(true), + ); + } + + private poll(action: PaginatedAction, pagination$: Observable) { + this.ngZone.runOutsideAngular(() => + this.polls.push( + interval(10000).subscribe(() => { + this.ngZone.run(() => this.updateList(action, pagination$)); + }) + ) + ); + } + + private updateList(action: PaginatedAction, pagination$: Observable) { + pagination$.pipe(first()).subscribe(pag => { + if (!getCurrentPageRequestInfo(pag, { busy: true, error: false, message: '' }).busy) { + this.store.dispatch(action); + } + }); + } + + ngOnDestroy() { + safeUnsubscribe(...(this.polls || [])); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.html new file mode 100644 index 0000000000..2b5bf1b3fd --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.html @@ -0,0 +1,63 @@ + +
+
+
Loading ...
+ +
+
+ + + Form + YAML + + + +
YAML Editor
+
+ +
+ +
+ + + + + + + + + +
+ +
+ + format_list_numbered + + + map + +
+ +
+
+
+
+
+ warning +
+
+
Error - YAML is not valid
+
Use the YAML editor to correct it so the values can be loaded into the form
+
+
+
+ +
+
+ +
+ +
diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.scss new file mode 100644 index 0000000000..8e28d24ff1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.scss @@ -0,0 +1,104 @@ +:host { + display: flex; + width: 100%; +} +.editor { + + $toolbar-height: 40px; + + &-card { + flex: 1; + padding: 0; + } + + &-title { + font-size: 14px; + } + + &-form { + display: block; + height: calc(100% - #{$toolbar-height}); + overflow-y: scroll; + padding: 20px; + width: 100%; + + &.editor-hidden { + display: none; + } + } + + &-loading { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + position: absolute; + width: 100%; + z-index: 100; + + &__msg { + text-align: center; + } + &__progress-bar { + margin-top: 10px; + min-width: 120px; + } + } + + &-yaml-error { + align-items: center; + display: flex; + height: calc(100% - #{$toolbar-height}); + justify-content: center; + margin: -20px; + opacity: 0.7; + position: absolute; + width: 100%; + z-index: 100; + + &__msg { + display: flex; + flex-direction: row; + } + &__text { + line-height: 24px; + } + &__icon { + font-size: 32px; + height: 32px; + margin-right: 12px; + width: 32px; + } + } + + &-toolbar-buttons { + display: flex; + mat-button-toggle { + margin-left: 8px; + } + } + + &-spacer { + flex: 1 1 auto; + } + + &-monaco { + display: block; + + &.editor-hidden { + display: none; + } + } + + &-monaco-edit { + position: absolute; + } + + &-menu-divider { + margin: 4px 0; + } +} + +.mat-card.editor-card>:first-child { + margin: 0; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.spec.ts new file mode 100644 index 0000000000..0c222d2b2d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.spec.ts @@ -0,0 +1,41 @@ +import { HttpClient, HttpClientModule, HttpHandler } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { createBasicStoreModule } from '@stratosui/store/testing'; + +import { MDAppModule } from '../../../../../core/src/public-api'; +import { ConfirmationDialogService } from '../../../../../core/src/shared/components/confirmation-dialog.service'; +import { ChartValuesEditorComponent } from './chart-values-editor.component'; + +describe('ChartValuesEditorComponent', () => { + let component: ChartValuesEditorComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ChartValuesEditorComponent], + providers: [ + HttpClient, + HttpHandler, + ConfirmationDialogService, + ], + imports: [ + MDAppModule, + HttpClientModule, + HttpClientTestingModule, + createBasicStoreModule(), + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChartValuesEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme.scss new file mode 100644 index 0000000000..fa694c22cd --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.theme.scss @@ -0,0 +1,65 @@ +@mixin app-chart-values-editor-theme($theme, $app-theme) { + + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + + $app-background: map-get($app-theme, app-background-color); + + // Fixes some layout issues with Angular Json Schema Form due to use of Angular flex-layout + + // Also tweaks sizing and spacing of elements + + $vert-padding: 10px; + $toolbar-icon-size: 16px; + $toolbar-item-height: 24px; + $toolbar-item-font-size: 12px; + + // We discourage use of !important, but this is only way to override + // the angular flex settings in the ajsf library - otherwise the layout is wrong + .form-flex-column { + flex-flow: column !important; + } + + .legend { + font-size: 14px; + padding: $vert-padding 0; + } + + .editor-loading, .editor-yaml-error { + background-color: $app-background; + } + + // Make controls in the editor toolbar smaller + .editor-toolbar { + height: 40px; + + .mat-button-toggle-button { + display: flex; + } + .mat-button-toggle-label-content { + font-size: $toolbar-item-font-size; + line-height: $toolbar-item-height; + padding: 0 8px; + + .mat-icon { + font-size: $toolbar-icon-size; + height: $toolbar-icon-size; + width: $toolbar-icon-size; + } + } + .mat-button { + font-size: $toolbar-item-font-size; + line-height: $toolbar-item-height; + padding: 0 12px; + } + + } + + // Override hover color for context menu to align with Stratos theme + // Monaco doesn't seem to expose this as a theme colour + .monaco-editor .monaco-menu .action-item.focused a.action-menu-item { + background: mat-color($background, 'hover') !important; + color: mat-color($foreground, 'text') !important; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts new file mode 100644 index 0000000000..928a764be7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/chart-values-editor.component.ts @@ -0,0 +1,435 @@ +import { HttpClient } from '@angular/common/http'; +import { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, Renderer2, ViewChild } from '@angular/core'; +import { JsonSchemaFormComponent } from '@cfstratos/ajsf-core'; +import * as yaml from 'js-yaml'; +import { BehaviorSubject, combineLatest, fromEvent, Observable, of, Subscription } from 'rxjs'; +import { catchError, debounceTime, filter, map, startWith, tap } from 'rxjs/operators'; + +import { ConfirmationDialogConfig } from '../../../../../core/src/shared/components/confirmation-dialog.config'; +import { ConfirmationDialogService } from '../../../../../core/src/shared/components/confirmation-dialog.service'; +import { ThemeService } from '../../../../../store/src/theme.service'; +import { diffObjects } from './diffvalues'; +import { generateJsonSchemaFromObject } from './json-schema-generator'; +import { mergeObjects } from './merge'; + + +export interface ChartValuesConfig { + + // URL of the JSON Schema for the chart values + schemaUrl: string; + + // URL of the Chart Values + valuesUrl: string; + + // Values for the current release (optional) + releaseValues?: string; +} + +// Height of the toolbar that sits above the editor conmponent +const TOOLBAR_HEIGHT = 40; + +// Editor modes - can be either using the form or the code editor +enum EditorMode { + CodeEditor = 'editor', + JSonSchemaForm = 'form', +} + +@Component({ + selector: 'app-chart-values-editor', + templateUrl: './chart-values-editor.component.html', + styleUrls: ['./chart-values-editor.component.scss'] +}) +export class ChartValuesEditorComponent implements OnInit, OnDestroy, AfterViewInit { + + @Input() set config(config: ChartValuesConfig) { + if (!!config) { + this.schemaUrl = config.schemaUrl; + this.valuesUrl = config.valuesUrl; + this.releaseValues = config.releaseValues; + this.init(); + } + } + + schemaUrl: string; + valuesUrl: string; + releaseValues: string; + + // Model for the editor - we set this once when the YAML support has been loaded + public model; + + // Editor mode - either 'editor' for the Monaco Code Editor or 'form' for the JSON Schema Form editor + public mode: EditorMode = EditorMode.CodeEditor; + + // Content shown in the code editor + public code = ''; + + // JSON Schema + public schema: any; + + public hasSchema = false; + + // Data shown in the form on load + public initialFormData = {}; + + // Data updated in the form as the user changes it + public formData = {}; + + // Is the YAML in the code editor invalid? + public yamlError = false; + + // Monaco Code Editor settings + public minimap = true; + public lineNumbers = true; + + // Chart Values - as both raw text (keeping comments) and parsed JSON + public chartValuesYaml: string; + public chartValues: any; + + // Default Monaco options + public editorOptions = { + automaticLayout: false, // We will resize the editor to fit the available space + contextmenu: false, // Turn off the right-click context menu + tabSize: 2, + }; + + // Monaco editor + public editor: any; + + // Observable - are we still loading resources? + public loading$: Observable; + + public initing = true; + + // Observable for tracking if the Monaco editor has loaded + private monacoLoaded$ = new BehaviorSubject(false); + + private resizeSub: Subscription; + private themeSub: Subscription; + + // Track whether the user changes the code in the text editor + private codeOnEnter: string; + + // Reference to the editor, so we can adjust its size to fit + @ViewChild('monacoEditor', { read: ElementRef }) monacoEditor: ElementRef; + + @ViewChild('schemaForm') schemaForm: JsonSchemaFormComponent; + + // Confirmation dialog - copy values + overwriteValuesConfirmation = new ConfirmationDialogConfig( + 'Overwrite Values?', + 'Are you sure you want to replace your values with those from values.yaml?', + 'Overwrite' + ); + + // Confirmation dialog - copy release values + overwriteReleaseValuesConfirmation = new ConfirmationDialogConfig( + 'Overwrite Values?', + 'Are you sure you want to replace your values with those from the release?', + 'Overwrite' + ); + + // Confirmation dialog - diff values + overwriteDiffValuesConfirmation = new ConfirmationDialogConfig( + 'Overwrite Values?', + 'Are you sure you want to replace your values with the diff with values.yaml?', + 'Overwrite' + ); + + // Confirmation dialog - clear values + clearValuesConfirmation = new ConfirmationDialogConfig( + 'Clear Values?', + 'Are you sure you want to clear the form values?', + 'Overwrite' + ); + + constructor( + private elRef: ElementRef, + private renderer: Renderer2, + private httpClient: HttpClient, + private themeService: ThemeService, + private confirmDialog: ConfirmationDialogService, + ) { } + + ngOnInit(): void { + // Listen for window resize and resize the editor when this happens + this.resizeSub = fromEvent(window, 'resize').pipe(debounceTime(150)).subscribe(event => this.resize()); + } + + private init() { + // Observabled for loading schema and values for the Chart + const schema$ = this.httpClient.get(this.schemaUrl).pipe(catchError(e => of(null))); + const values$: Observable = this.httpClient.get(this.valuesUrl, { responseType: 'text' }).pipe( + catchError(e => of(null)) + ); + + // We need the schame, value sand the monaco editor to be all loaded before we're ready + this.loading$ = combineLatest(schema$, values$, this.monacoLoaded$).pipe( + filter(([schema, values, loaded]) => schema !== undefined && values !== undefined && loaded), + tap(([schema, values, loaded]) => { + this.schema = schema; + if (values !== null) { + this.chartValuesYaml = values as string; + this.chartValues = yaml.safeLoad(values, { json: true }); + // Set the form to the chart values initially, so if the user does nothing, they get the defaults + this.initialFormData = this.chartValues; + } + // Default to form if there is a schema + if (schema !== null) { + this.hasSchema = true; + this.mode = EditorMode.JSonSchemaForm; + // Register schema with the Monaco editor + this.registerSchema(this.schema); + } else { + // No Schema, so register an auto-generated schema from the Chart's values + this.registerSchema(generateJsonSchemaFromObject('Generated Schema', this.chartValues)); + + // Inherit the previous values if available (upgrade) + if (this.releaseValues) { + this.code = yaml.safeDump(this.releaseValues); + } + } + this.updateModel(); + }), + map(([schema, values, loaded]) => !loaded), + startWith(true) + ); + + this.initing = false; + } + + ngAfterViewInit(): void { + this.resizeEditor(); + } + + ngOnDestroy(): void { + if (this.resizeSub) { + this.resizeSub.unsubscribe(); + } + if (this.themeSub) { + this.themeSub.unsubscribe(); + } + } + + // Toggle editor minimap on/off + toggleMinimap() { + this.minimap = !this.minimap; + this.editor.updateOptions({ minimap: { enabled: this.minimap } }); + } + + // Toggle editor line numbers on/off + toggleLineNumbers() { + this.lineNumbers = !this.lineNumbers; + this.editor.updateOptions({ lineNumbers: this.lineNumbers ? 'on' : 'off' }); + } + + // Store the update form data when the form changes + // AJSF two-way binding seems to cause issues + formChanged(data: any) { + this.formData = data; + } + + // The edit mode has changed (form or editor) + editModeChanged(mode) { + this.mode = mode.value; + + if (this.mode === EditorMode.CodeEditor) { + // Form -> Editor + // Only copy if there is not an error - otherwise keep the invalid yaml from the editor that needs fixing + if (!this.yamlError) { + const codeYaml = yaml.safeLoad(this.code || '{}', { json: true }); + const data = mergeObjects(codeYaml, this.formData); + this.code = this.getDiff(data); + this.codeOnEnter = this.code; + } + + // Need to resize the editor, as it will be freshly shown + this.resizeEditor(); + } else { + // Editor -> Form + // Try and parse the YAML - if we can't this is an error, so we can't edit this back in the form + try { + if (this.codeOnEnter === this.code) { + // Code did not change + return; + } + + // Parse as json + const json = yaml.safeLoad(this.code || '{}', { json: true }); + // Must be an object, otherwise it was not valid + if (typeof (json) !== 'object') { + throw new Error('Invalid YAML'); + } + this.yamlError = false; + const data = mergeObjects(this.formData, json); + this.initialFormData = data; + this.formData = data; + } catch (e) { + // The yaml in the code editor is invalid, so we can't marshal it back to json for the from editor + this.yamlError = true; + } + } + } + + // Called once the Monaco editor has loaded and then each time the model is update + // Store a reference to the editor and ensure the editor theme is synchronized with the Stratos theme + onMonacoInit(editor) { + this.editor = editor; + this.resize(); + + // Only load the YAML support once - when we set the model, onMonacoInit will et + if (this.model) { + return; + } + + // Load the YAML Language support - require is available as it will have been loaded by the Monaco vs loader + const req = (window as any).require; + req(['vs/language/yaml/monaco.contribution'], () => { + // Set the model now that YAML support is loaded - this will update the editor correctly + this.updateModel(); + this.monacoLoaded$.next(true); + }); + + // Watch for theme changes - set light/dark theme in the monaco editor as the Stratos theme changes + this.themeSub = this.themeService.getTheme().subscribe(theme => { + const monaco = (window as any).monaco; + const monacoTheme = (theme.styleName === 'dark-theme') ? 'vs-dark' : 'vs'; + monaco.editor.setTheme(monacoTheme); + }); + } + + private updateModel() { + this.model = { + language: 'yaml', + uri: this.getSchemaUri() + }; + } + + // Delayed resize of editor to fit + resizeEditor() { + setTimeout(() => this.resize(), 1); + } + + // Resize editor to fit + resize() { + // Return if resize before editor has been set + if (!this.editor) { + return; + } + + // Get width and height of the host element + const w = this.elRef.nativeElement.offsetWidth; + let h = this.elRef.nativeElement.offsetHeight; + + // Check if host element not visible (does not have a size) + if ((w === 0) && (h === 0)) { + return; + } + + // Remove height of toolbar (since this is incluced in the height of the host element) + h = h - TOOLBAR_HEIGHT; + + // Set the Monaco editor to the same size as the container + this.renderer.setStyle(this.monacoEditor.nativeElement, 'width', `${w}px`); + this.renderer.setStyle(this.monacoEditor.nativeElement, 'height', `${h}px`); + + // Ask Monaco to layout again with its new size + this.editor.layout(); + } + + // Get an absolute URI for the Schema - it is not fetched, just used as a reference + // schemaUrl is a relative URL - e.g. /p1/v1/chartsvc.... + getSchemaUri(): string { + return `https://stratos.app/schemas${this.schemaUrl}`; + } + + // Register the schema with the Monaco editor + // Reference: https://github.com/pengx17/monaco-yaml/blob/master/examples/umd/index.html#L69 + registerSchema(schema: any) { + const monaco = (window as any).monaco; + monaco.languages.yaml.yamlDefaults.setDiagnosticsOptions({ + enableSchemaRequest: true, + hover: true, + completion: true, + validate: true, + format: true, + schemas: [ + { + uri: this.getSchemaUri(), + fileMatch: [this.getSchemaUri()], + schema + } + ] + }); + } + + public getValues(): object { + // Always diff the form with the Chart Values to get only the changes that the user has made + return (this.mode === EditorMode.JSonSchemaForm) ? diffObjects(this.formData, this.chartValues) : yaml.safeLoad(this.code); + } + + public copyValues() { + const confirm = this.mode === EditorMode.JSonSchemaForm || this.mode === EditorMode.CodeEditor && this.code.length > 0; + if (confirm) { + this.confirmDialog.open(this.overwriteValuesConfirmation, () => { + this.doCopyValues(); + }); + } else { + this.doCopyValues(); + } + } + + // Copy the chart values into either the form or the code editor, depending on the current mode + private doCopyValues() { + if (this.mode === EditorMode.JSonSchemaForm) { + this.initialFormData = this.chartValues; + } else { + // Use the raw Yaml, so we keep comments and formatting + this.code = this.chartValuesYaml; + } + } + + public copyReleaseValues() { + const confirm = this.mode === EditorMode.JSonSchemaForm || this.mode === EditorMode.CodeEditor && this.code.length > 0; + if (confirm) { + this.confirmDialog.open(this.overwriteReleaseValuesConfirmation, () => { + this.doCopyReleaseValues(); + }); + } else { + this.doCopyReleaseValues(); + } + } + + // Copy the release values into either the form or the code editor, depending on the current mode + private doCopyReleaseValues() { + if (this.mode === EditorMode.JSonSchemaForm) { + this.initialFormData = this.releaseValues; + } else { + this.code = yaml.safeDump(this.releaseValues); + } + } + + // Reset the form values and the code + clearFormValues() { + this.confirmDialog.open(this.clearValuesConfirmation, () => { + this.initialFormData = {}; + this.code = ''; + this.codeOnEnter = ''; + }); + } + + // Update the code editor to only show the YAML that contains the differences with the values.yaml + diff() { + this.confirmDialog.open(this.overwriteDiffValuesConfirmation, () => { + const userValues = yaml.safeLoad(this.code, { json: true }); + this.code = this.getDiff(userValues); + }); + } + + getDiff(userValues: any): string { + let code = yaml.safeDump(diffObjects(userValues, this.chartValues)); + if (code.trim() === '{}') { + code = ''; + } + return code; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/diffvalues.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/diffvalues.ts new file mode 100644 index 0000000000..d4a27c6e8d --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/diffvalues.ts @@ -0,0 +1,74 @@ +// Helper for diffing user values and chart values + +function arraysAreEqual(a1: any[], a2: any[]): boolean { + if (a1.length !== a2.length) { + return false + } + + // Compare each item in the array + for (let i=0; i { + if (typeof(src[key]) !== typeof(dest[key])) { + return false; + } else if(src[key] === null && dest[key] === null) { + return true; + } else if (Array.isArray(src[key]) && !arraysAreEqual(src[key], dest[key])) { + return false; + } else if (typeof(src[key]) === 'object' && !objectsAreEqual(src[key], dest[key])) { + return false; + } + }); + + return true; +} + +// NOTE: This is a one-way diff only +// diffObjects is main export - diffs two objects and returns only the diffrence +export function diffObjects(src: any, dest: any): any { + if (!src) { + return {}; + } + + Object.keys(src).forEach(key => { + if (typeof(src[key]) === typeof(dest[key])) { + if(src[key] === null && dest[key] === null) { + delete src[key]; + } else if(Array.isArray(src[key])) { + // Array + if (arraysAreEqual(src[key], dest[key])) { + delete src[key]; + } + } else if (typeof(src[key]) === 'object') { + // Object + diffObjects(src[key], dest[key]); + if (src[key] && Object.keys(src[key]).length === 0) { + delete src[key]; + } + } else if (src[key] === dest[key]) { + // Value + delete src[key]; + } + } + }); + return src; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/json-schema-generator.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/json-schema-generator.ts new file mode 100644 index 0000000000..531d3aa985 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/json-schema-generator.ts @@ -0,0 +1,288 @@ +// Generate a JSON Schema from an object +// This code incorporates the library: https://github.com/nijikokun/generate-schema/blob/master/src/schemas/json.js +// It is modified for Typescript and to mark all properties as not required + +// Reference: https://github.com/stephenhandley/type-of-is/blob/master/index.js +// Modified for Typescript + +const BUILT_IN_TYPES = [ + Object, + Function, + Array, + String, + Boolean, + Number, + Date, + RegExp, + Error +]; + +const _toString = ({}).toString; + +function isBuiltIn(_constructor): boolean { + for (const bit of BUILT_IN_TYPES) { + if (bit === _constructor) { + return true; + } + } + return false; +}; + +function of(obj) { + if ((obj === null) || (obj === undefined)) { + return obj; + } else { + return obj.constructor; + } +} + +function stringType(obj) { + // [object Blah] -> Blah + const stype = _toString.call(obj).slice(8, -1); + if ((obj === null) || (obj === undefined)) { + return stype.toLowerCase(); + } + + const ctype = of(obj); + if (ctype && !isBuiltIn(ctype)) { + return ctype.name; + } else { + return stype; + } +}; + +// Reference: https://github.com/nijikokun/generate-schema/blob/master/src/schemas/json.js + + +const DRAFT = 'http://json-schema.org/draft-04/schema#' + +function getPropertyFormat(value) { + const type = stringType(value).toLowerCase() + + if (type === 'date') return 'date-time' + return null +} + +function getPropertyType(value) { + const type = stringType(value).toLowerCase() + + if (type === 'number') return Number.isInteger(value) ? 'integer' : type + if (type === 'date') return 'string' + if (type === 'regexp') return 'string' + if (type === 'function') return 'string' + return type +} + +function getUniqueKeys(a, b, c) { + a = Object.keys(a) + b = Object.keys(b) + c = c || [] + + let value + let cIndex + let aIndex + + for (let keyIndex = 0, keyLength = b.length; keyIndex < keyLength; keyIndex++) { + value = b[keyIndex] + aIndex = a.indexOf(value) + cIndex = c.indexOf(value) + + if (aIndex === -1) { + if (cIndex !== -1) { + // Value is optional, it doesn't exist in A but exists in B(n) + c.splice(cIndex, 1) + } + } else if (cIndex === -1) { + // Value is required, it exists in both B and A, and is not yet present in C + c.push(value) + } + } + + return c +} + +function processArray(array, output?, nested?: boolean) { + let format + let oneOf + let type + + if (nested && output) { + output = { items: output } + } else { + output = output || {} + output.type = getPropertyType(array) + output.items = output.items || {} + type = output.items.type || null + } + + // Determine whether each item is different + for (let arrIndex = 0, arrLength = array.length; arrIndex < arrLength; arrIndex++) { + const elementType = getPropertyType(array[arrIndex]) + const elementFormat = getPropertyFormat(array[arrIndex]) + + if (type && elementType !== type) { + output.items.oneOf = [] + oneOf = true + break + } else { + type = elementType + format = elementFormat + } + } + + // Setup type otherwise + if (!oneOf && type) { + output.items.type = type + if (format) { + output.items.format = format + } + } else if (oneOf && type !== 'object') { + output.items = { + oneOf: [{ type }], + required: false + } + } + + // Process each item depending + if (typeof output.items.oneOf !== 'undefined' || type === 'object') { + for (let itemIndex = 0, itemLength = array.length; itemIndex < itemLength; itemIndex++) { + const value = array[itemIndex] + const itemType = getPropertyType(value) + const itemFormat = getPropertyFormat(value) + let arrayItem + if (itemType === 'object') { + if (output.items.properties) { + output.items.required = false + } + arrayItem = processObject(value, oneOf ? {} : output.items.properties, true) + } else if (itemType === 'array') { + arrayItem = processArray(value, oneOf ? {} : output.items.properties, true) + } else { + arrayItem = {} + arrayItem.type = itemType + if (itemFormat) { + arrayItem.format = itemFormat + } + } + if (oneOf) { + const childType = stringType(value).toLowerCase() + const tempObj: any = {}; + if (!arrayItem.type && childType === 'object') { + tempObj.properties = arrayItem + tempObj.type = 'object' + arrayItem = tempObj + } + output.items.oneOf.push(arrayItem) + } else { + if (output.items.type !== 'object') { + continue; + } + output.items.properties = arrayItem + } + } + } + return nested ? output.items : output +} + +function processObject(object: any, output?: any, nested?: boolean) { + if (nested && output) { + output = { properties: output } + } else { + output = output || {} + output.type = getPropertyType(object) + output.properties = output.properties || {} + output.required = [] + } + + for(const key of Object.keys(object)) { + const value = object[key] + let typ = getPropertyType(value) + const format = getPropertyFormat(value) + + typ = typ === 'undefined' ? 'null' : typ + + if (typ === 'object') { + output.properties[key] = processObject(value, output.properties[key]) + continue + } + + if (typ === 'array') { + output.properties[key] = processArray(value, output.properties[key]) + continue + } + + if (output.properties[key]) { + const entry = output.properties[key] + const hasTypeArray = Array.isArray(entry.type) + + // When an array already exists, we check the existing + // type array to see if it contains our current property + // type, if not, we add it to the array and continue + if (hasTypeArray && entry.type.indexOf(typ) < 0) { + entry.type.push(typ) + } + + // When multiple fields of differing types occur, + // json schema states that the field must specify the + // primitive types the field allows in array format. + if (!hasTypeArray && entry.type !== typ) { + entry.type = [entry.type, typ] + } + + continue + } + + output.properties[key] = {} + output.properties[key].type = typ + + if (format) { + output.properties[key].format = format + } + } + + return nested ? output.properties : output +} + + +export function generateJsonSchemaFromObject(title, object) { + let processOutput + const output: any = { + $schema: DRAFT + } + + // Determine title exists + if (typeof title !== 'string') { + object = title + title = undefined + } else { + output.title = title + } + + // Set initial object type + output.type = stringType(object).toLowerCase() + + // Process object + if (output.type === 'object') { + processOutput = processObject(object) + output.type = processOutput.type + output.properties = processOutput.properties + + // For a generated schema, nothing is marked as required + // This is a modification to the library + output.required = false; + } + + if (output.type === 'array') { + processOutput = processArray(object) + output.type = processOutput.type + output.items = processOutput.items + + if (output.title) { + output.items.title = output.title + output.title += ' Set' + } + } + + // Output + return output +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/merge.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/merge.ts new file mode 100644 index 0000000000..a1b60d139b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/chart-values-editor/merge.ts @@ -0,0 +1,30 @@ + +export function mergeObjects(src: any, ...dest: any): any { + // Copy src + const data = JSON.parse(JSON.stringify(src)); + // Merge in all of the dest objects + for (const obj of dest) { + doMergeObjects(data, obj); + } + + return data; +} + +// merge from dest into src +function doMergeObjects(src: any, dest: any) { + // Go through the keys of dest an update them in src + if (!dest) { + return + } + + Object.keys(dest).forEach(key => { + if (typeof(dest[key]) === 'object' && !Array.isArray(dest)) { + if (!src[key]) { + src[key] = {}; + } + doMergeObjects(src[key], dest[key]); + } else { + src[key] = dest[key] + } + }); +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.html new file mode 100644 index 0000000000..8be2ac3295 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.html @@ -0,0 +1,41 @@ + + Install Chart + + + +
+ Select the Kubernetes cluster to install to + + + + {{ kube.name }} + + + +
+ Specify name and namespace for the installation + + + + + + + + + {{namespace}} + + + + Namespace does not exist + + + + Create Namespace + +
+
+ + + +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.scss new file mode 100644 index 0000000000..7b6b2917ab --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.scss @@ -0,0 +1,3 @@ +.helm-create-release { + flex: 1; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.spec.ts new file mode 100644 index 0000000000..f4194499a6 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.spec.ts @@ -0,0 +1,61 @@ +import { HttpClient } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfirmationDialogService } from '../../../../../core/src/shared/components/confirmation-dialog.service'; +import { TabNavService } from '../../../../../core/src/tab-nav.service'; +import { EntityMonitorFactory } from '../../../../../store/src/monitors/entity-monitor.factory.service'; +import { InternalEventMonitorFactory } from '../../../../../store/src/monitors/internal-event-monitor.factory'; +import { PaginationMonitorFactory } from '../../../../../store/src/monitors/pagination-monitor.factory'; +import { MockChartService } from '../../../helm/monocular/shared/services/chart.service.mock'; +import { ChartsService } from '../../../helm/monocular/shared/services/charts.service'; +import { ConfigService } from '../../../helm/monocular/shared/services/config.service'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { CreateReleaseComponent } from './create-release.component'; + +describe('CreateReleaseComponent', () => { + let component: CreateReleaseComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + CreateReleaseComponent, + ], + imports: [ + KubernetesBaseTestModules, + HttpClientTestingModule, + ], + providers: [ + HttpClient, + PaginationMonitorFactory, + EntityMonitorFactory, + InternalEventMonitorFactory, + TabNavService, + ConfirmationDialogService, + { provide: ChartsService, useValue: new MockChartService() }, + { provide: ConfigService, useValue: { appName: 'appName' } }, + ] + }) + .compileComponents(); + + httpMock = TestBed.get(HttpTestingController); + + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CreateReleaseComponent); + httpMock.expectOne('/pp/v1/chartsvc/v1/charts/undefined/undefined/versions/undefined'); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + }); + + afterEach(() => { + httpMock.verify(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.ts new file mode 100644 index 0000000000..a126fe31f5 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/create-release/create-release.component.ts @@ -0,0 +1,267 @@ +import { Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { BehaviorSubject, combineLatest, Observable, of, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, first, map, pairwise, startWith, switchMap } from 'rxjs/operators'; + +import { EndpointsService } from '../../../../../core/src/core/endpoints.service'; +import { safeUnsubscribe } from '../../../../../core/src/core/utils.service'; +import { StepOnNextFunction, StepOnNextResult } from '../../../../../core/src/shared/components/stepper/step/step.component'; +import { RequestInfoState } from '../../../../../store/src/reducers/api-request-reducer/types'; +import { helmEntityCatalog } from '../../../helm/helm-entity-catalog'; +import { ChartsService } from '../../../helm/monocular/shared/services/charts.service'; +import { createMonocularProviders } from '../../../helm/monocular/stratos-monocular-providers.helpers'; +import { getMonocularEndpoint, stratosMonocularEndpointGuid } from '../../../helm/monocular/stratos-monocular.helper'; +import { HelmChartReference, HelmInstallValues } from '../../../helm/store/helm.types'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { KUBERNETES_ENDPOINT_TYPE } from '../../kubernetes-entity-factory'; +import { KubernetesNamespace } from '../../store/kube.types'; +import { ChartValuesConfig, ChartValuesEditorComponent } from './../chart-values-editor/chart-values-editor.component'; + +@Component({ + selector: 'app-create-release', + templateUrl: './create-release.component.html', + styleUrls: ['./create-release.component.scss'], + providers: [ + ...createMonocularProviders() + ] +}) +export class CreateReleaseComponent implements OnInit, OnDestroy { + + // isLoading$ = observableOf(false); + paginationStateSub: Subscription; + + public cancelUrl: string; + kubeEndpoints$: Observable; + validate$: Observable; + + details: FormGroup; + namespaces$: Observable; + + private endpointChanged = new BehaviorSubject(null); + + @ViewChild('releaseNameInputField', { static: true }) releaseNameInputField: ElementRef; + @ViewChild('editor', { static: true }) editor: ChartValuesEditorComponent; + + private subs: Subscription[] = []; + private createdNamespace = false; + + private chart: HelmChartReference; + public config: ChartValuesConfig; + + constructor( + private route: ActivatedRoute, + public endpointsService: EndpointsService, + private chartsService: ChartsService, + ) { + const chart = this.route.snapshot.params as HelmChartReference; + this.cancelUrl = this.chartsService.getChartSummaryRoute(chart.repo, chart.name, chart.version, this.route); + this.chart = chart; + + // Fetch the Chart Version metadata so we can get the correct URL for the Chart's JSON Schema + this.chartsService.getVersion(this.chart.repo, this.chart.name, this.chart.version).pipe(first()).subscribe(ch => { + this.config = { + valuesUrl: `/pp/v1/monocular/values/${this.chart.endpoint}/${this.chart.repo}/${chart.name}/${this.chart.version}`, + schemaUrl: this.chartsService.getChartSchemaURL(ch, ch.relationships.chart.data.name, ch.relationships.chart.data.repo) + }; + }); + + this.setupDetailsStep(); + } + + private setupDetailsStep() { + this.details = new FormGroup({ + endpoint: new FormControl('', Validators.required), + releaseName: new FormControl('', Validators.required), + releaseNamespace: new FormControl('', Validators.required), + createNamespace: new FormControl(false), + }); + this.details.controls.createNamespace.disable(); + + this.kubeEndpoints$ = this.endpointsService.connectedEndpointsOfTypes(KUBERNETES_ENDPOINT_TYPE); + + const allNamespaces$ = kubeEntityCatalog.namespace.store.getPaginationService(null).entities$.pipe( + filter(namespaces => !!namespaces), + first() + ); + this.namespaces$ = combineLatest([ + allNamespaces$, + this.endpointChanged.asObservable(), + this.details.controls.releaseNamespace.valueChanges.pipe(startWith(''), distinctUntilChanged()) + ]).pipe( + // Filter out namespaces from other kubes + map(([namespaces, kubeId, namespace]: [KubernetesNamespace[], string, string]) => ([ + namespaces.filter(ns => ns.metadata.kubeId === kubeId), + namespace + ])), + // Map to endpoint names + map(([namespaces, namespace]: [KubernetesNamespace[], string]) => [ + namespaces.map(ns => ns.metadata.name), + namespace + ]), + // Filter out namespaces not matching existing text + map(([namespaces, namespace]: [string[], string]) => this.filterTyped(namespaces, namespace)), + ); + + const namespaceChanged$ = this.details.controls.releaseNamespace.valueChanges.pipe( + distinctUntilChanged() + ); + const createNamespaceChanged$ = this.details.controls.createNamespace.valueChanges.pipe( + startWith(false), + distinctUntilChanged() + ); + + this.subs.push( + combineLatest([ + this.namespaces$, + namespaceChanged$, + createNamespaceChanged$ + ]).pipe().subscribe(([namespaces, namespace, create]) => { + const namespaceExists = !!namespaces.find(val => val === namespace); + if (namespaceExists) { + // All is fine + this.details.controls.releaseNamespace.validator = () => null; + this.details.controls.createNamespace.setValue(false); + this.details.controls.createNamespace.disable(); + } else if (!namespace) { + // Invalid - missing namespace + this.details.controls.releaseNamespace.validator = () => ({ required: true }); + this.details.controls.createNamespace.disable(); + } else if (!create) { + // Invalid - namespace doesn't exist and not creating + this.details.controls.releaseNamespace.validator = () => ({ namespaceDoesNotExist: true }); + this.details.controls.createNamespace.enable(); + } else { + // Valid - namespace doesn't exist but creating + this.details.controls.releaseNamespace.validator = () => null; + // this.details.controls.createNamespace.disable(); + } + this.details.controls.releaseNamespace.updateValueAndValidity(); + }) + ); + + this.subs.push( + this.details.controls.endpoint.valueChanges.subscribe(val => { + this.endpointChanged.next(val); + }) + ); + + this.validate$ = this.details.statusChanges.pipe( + map(() => this.details.valid) + ); + + // Auto-select first endpoint + this.kubeEndpoints$.pipe(first()).subscribe(endpoints => { + if (endpoints.length === 1) { + this.details.controls.endpoint.setValue(endpoints[0].guid); + } + }); + } + + private filterTyped(namespaces: string[], namespace: string): string[] { + const lowerCase = namespace.toLowerCase(); + return lowerCase.length ? namespaces.filter(ns => ns.toLowerCase().indexOf(lowerCase) >= 0) : namespaces; + } + + ngOnInit() { + // Auto select endpoint if there is only one + this.kubeEndpoints$.pipe(first()).subscribe(ep => { + if (ep.length > 1) { + this.details.controls.endpoint.setValue(ep[0].guid, { onlySelf: true }); + this.endpointChanged.next(ep[0].guid); + setTimeout(() => { + this.releaseNameInputField.nativeElement.focus(); + }, 1); + } + }); + } + + // Ensure the editor is resized when the overrides step becomes visible + onEnterOverrides = () => { + this.editor.resizeEditor(); + }; + + submit: StepOnNextFunction = () => { + return this.createNamespace().pipe( + switchMap(createRes => createRes.success ? this.installChart() : of(createRes)) + ); + }; + + createNamespace(): Observable { + if (!this.details.controls.createNamespace.value || this.createdNamespace) { + return of({ + success: true + }); + } + + return kubeEntityCatalog.namespace.api.create( + this.details.controls.releaseNamespace.value, + this.details.controls.endpoint.value + ).pipe( + pairwise(), + filter(([oldVal, newVal]) => oldVal.creating && !newVal.creating), + map(([, newVal]) => newVal), + map(state => { + if (state.error) { + return { + success: false, + message: `Failed to create namespace '${this.details.controls.releaseNamespace.value}': ` + state.message + }; + } + this.createdNamespace = true; + return { + success: true + }; + }) + ); + } + + installChart(): Observable { + const endpoint = getMonocularEndpoint(this.route, null, null); + // Build the request body + const values: HelmInstallValues = { + ...this.details.value, + values: JSON.stringify(this.editor.getValues()), + chart: { + name: this.route.snapshot.params.name, + repo: this.route.snapshot.params.repo, + version: this.route.snapshot.params.version, + }, + monocularEndpoint: endpoint === stratosMonocularEndpointGuid ? null : endpoint + }; + + // Get the chart first, so we can get then install URL, then install + return this.chartsService.getVersion(this.chart.repo, this.chart.name, this.chart.version).pipe( + switchMap(chartInfo => { + if (!chartInfo) { + throw new Error('Could not get Chart URL'); + } + // Add the chart url into the values + values.chartUrl = this.chartsService.getChartURL(chartInfo); + if (values.chartUrl.length === 0) { + throw new Error('Could not get Chart URL'); + } + // Make the request + return helmEntityCatalog.chart.api.install(values).pipe( + // Wait for result of request + filter(state => !!state), + pairwise(), + filter(([oldVal, newVal]) => (oldVal.creating && !newVal.creating)), + map(([, newVal]) => newVal), + map(result => ({ + success: !result.error, + redirect: !result.error, + redirectPayload: { + path: !result.error ? `workloads/${values.endpoint}:${values.releaseNamespace}:${values.releaseName}/summary` : '' + }, + message: !result.error ? '' : result.message + })) + ); + }) + ); + } + + ngOnDestroy() { + safeUnsubscribe(...this.subs); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.html new file mode 100644 index 0000000000..8dde7f3341 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.html @@ -0,0 +1,43 @@ + + +
+ {{ row.name }} +
+ + + workloads + +
+
+
+ + Cluster + + + + + + Namespace + + {{ row.namespace}} + + + + Status + + {{ row.status | titlecase }} + + + + Chart Version + + {{ row.chart.metadata.version }} + + + + Last Deployed + + {{ row.info.last_deployed | date:'medium' }} + + +
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.scss new file mode 100644 index 0000000000..85c8381fbd --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.scss @@ -0,0 +1,30 @@ +.release-card { + cursor: pointer; + &:focus { + outline: 0; + } +} + +.release-title { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + + &--icon { + align-self: flex-end; + display: flex; + &__padding { + padding-right: 24px; + } + img, + mat-icon { + font-size: 48px; + height: 48px; + object-fit: contain; + width: 48px; + } + } +} + + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.spec.ts new file mode 100644 index 0000000000..97d0be3395 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.spec.ts @@ -0,0 +1,41 @@ +import { DatePipe } from '@angular/common'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { HelmRelease } from '../../workload.types'; +import { HelmReleaseCardComponent } from './helm-release-card.component'; + +describe('HelmReleaseCardComponent', () => { + let component: HelmReleaseCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [HelmReleaseCardComponent], + imports: KubernetesBaseTestModules, + providers: [ + DatePipe, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseCardComponent); + component = fixture.componentInstance; + component.row = { + status: 'status', + info: { + last_deployed: null + }, + chart: { + metadata: {} + } + } as HelmRelease; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.ts new file mode 100644 index 0000000000..7090e26af4 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-card/helm-release-card.component.ts @@ -0,0 +1,47 @@ +import { DatePipe } from '@angular/common'; +import { Component, Input } from '@angular/core'; + +import { CardCell } from '../../../../../../core/src/shared/components/list/list.types'; +import { HelmRelease } from '../../workload.types'; + +@Component({ + selector: 'app-helm-release-card', + templateUrl: './helm-release-card.component.html', + styleUrls: ['./helm-release-card.component.scss'] +}) +export class HelmReleaseCardComponent extends CardCell { + + public status: string; + public lastDeployed: string; + private pRow: HelmRelease; + public icon: string; + + @Input('row') + set row(row: HelmRelease) { + this.pRow = row; + if (row) { + this.status = row.status.charAt(0).toUpperCase() + row.status.substring(1); + this.lastDeployed = this.datePipe.transform(row.info.last_deployed, 'medium'); + this.icon = row.chart.metadata.icon; + // FIXME: See #304 + // this.icon = '/pp/v1/chartsvc/v1/assets/aerospike/aerospike-enterprise/logo'; + // this.icon = 'chartsvc/v1/assets/ntppool/geoip/logo' + // chart summary - /pp/v1/chartsvc/v1/assets/charts/aerospike/logo-160x160-fit.png + // chart icon // https://hub.helm.sh/api/chartsvc/v1/assets/aerospike/aerospike-enterprise/logo + // yaml url `/pp/v1/chartsvc/v1/assets/${chart.repo}/${chart.chartName}/versions/${chart.version}/values.yaml`; + } + } + get row(): HelmRelease { + return this.pRow; + } + + + constructor(private datePipe: DatePipe) { + super(); + } + + loadImageError() { + this.icon = null; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-pods-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-pods-list-config.service.ts new file mode 100644 index 0000000000..ba0c1ddd4c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-pods-list-config.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from 'frontend/packages/store/src/app-state'; + +import { BaseKubernetesPodsListConfigService } from '../../list-types/kubernetes-pods/kubernetes-pods-list-config.service'; +import { HelmReleaseHelperService } from '../release/tabs/helm-release-helper.service'; +import { HelmReleasePodsDataSource } from './helm-release-pods-list-source'; + + +@Injectable() +export class HelmReleasePodsListConfig extends BaseKubernetesPodsListConfigService { + + constructor( + store: Store, + helmReleaseHelper: HelmReleaseHelperService + ) { + super( + helmReleaseHelper.endpointGuid, + [BaseKubernetesPodsListConfigService.namespaceColumnId] + ); + this.podsDataSource = new HelmReleasePodsDataSource(store, this, helmReleaseHelper.endpointGuid, helmReleaseHelper.releaseTitle); + } + + private podsDataSource: HelmReleasePodsDataSource; + + hideRefresh = true; + + getDataSource = () => this.podsDataSource; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-pods-list-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-pods-list-source.ts new file mode 100644 index 0000000000..6726c3ef26 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-pods-list-source.ts @@ -0,0 +1,29 @@ +import { Store } from '@ngrx/store'; +import { ListDataSource } from 'frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from 'frontend/packages/core/src/shared/components/list/list.component.types'; +import { AppState } from 'frontend/packages/store/src/app-state'; + +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { KubernetesPod } from '../../store/kube.types'; + + +export class HelmReleasePodsDataSource extends ListDataSource { + + constructor( + store: Store, + listConfig: IListConfig, + endpointGuid: string, + releaseTitle: string + ) { + const action = kubeEntityCatalog.pod.actions.getInWorkload(endpointGuid, releaseTitle); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (row: KubernetesPod) => action.entity[0].getId(row), + paginationKey: action.paginationKey, + isLocal: true, + listConfig, + }); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-services-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-services-list-config.service.ts new file mode 100644 index 0000000000..d9d31a7505 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-services-list-config.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from 'frontend/packages/store/src/app-state'; + +import { + BaseKubernetesServicesListConfig, +} from '../../list-types/kubernetes-services/kubernetes-service-list-config.service'; +import { HelmReleaseHelperService } from '../release/tabs/helm-release-helper.service'; +import { HelmReleaseServicesDataSource } from './helm-release-services-list-source'; + +@Injectable() +export class HelmReleaseServicesListConfig extends BaseKubernetesServicesListConfig { + + constructor( + private store: Store, + public activatedRoute: ActivatedRoute, + helmReleaseHelper: HelmReleaseHelperService + ) { + super(); + this.dataSource = new HelmReleaseServicesDataSource(this.store, this, helmReleaseHelper.endpointGuid, helmReleaseHelper.releaseTitle); + } + dataSource: HelmReleaseServicesDataSource; + + hideRefresh = true; + + public getDataSource = () => this.dataSource; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-services-list-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-services-list-source.ts new file mode 100644 index 0000000000..24186f23f9 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-release-services-list-source.ts @@ -0,0 +1,28 @@ +import { Store } from '@ngrx/store'; +import { ListDataSource } from 'frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { IListConfig } from 'frontend/packages/core/src/shared/components/list/list.component.types'; +import { AppState } from 'frontend/packages/store/src/app-state'; + +import { KubeService } from '../../store/kube.types'; +import { GetHelmReleaseServices } from '../store/workloads.actions'; + +export class HelmReleaseServicesDataSource extends ListDataSource { + + constructor( + store: Store, + listConfig: IListConfig, + endpointGuid: string, + releaseTitle: string + ) { + const action = new GetHelmReleaseServices(endpointGuid, releaseTitle); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (row: KubeService) => action.entity[0].getId(row), + paginationKey: action.paginationKey, + isLocal: true, + listConfig, + }); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-releases-list-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-releases-list-config.service.ts new file mode 100644 index 0000000000..205c587b60 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-releases-list-config.service.ts @@ -0,0 +1,154 @@ +import { DatePipe } from '@angular/common'; +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { ITableColumn } from 'frontend/packages/core/src/shared/components/list/list-table/table.types'; +import { + TableCellEndpointNameComponent, +} from 'frontend/packages/core/src/shared/components/list/list-types/endpoint/table-cell-endpoint-name/table-cell-endpoint-name.component'; +import { + IListConfig, + IListMultiFilterConfig, + ListViewTypes, +} from 'frontend/packages/core/src/shared/components/list/list.component.types'; +import { AppState } from 'frontend/packages/store/src/app-state'; +import { filter, map } from 'rxjs/operators'; + +import { ListView } from '../../../../../store/src/actions/list.actions'; +import { defaultHelmKubeListPageSize } from '../../list-types/kube-helm-list-types'; +import { HelmRelease } from '../workload.types'; +import { HelmReleaseCardComponent } from './helm-release-card/helm-release-card.component'; +import { HelmReleasesDataSource } from './helm-releases-list-source'; +import { KubernetesNamespacesFilterItem, KubernetesNamespacesFilterService } from './kube-namespaces-filter-config.service'; + +@Injectable() +export class HelmReleasesListConfig implements IListConfig { + + isLocal = true; + dataSource: HelmReleasesDataSource; + viewType = ListViewTypes.BOTH; + defaultView = 'cards' as ListView; + text = { + title: '', + filter: 'Filter by Name', + }; + pageSizeOptions = defaultHelmKubeListPageSize; + enableTextFilter = true; + cardComponent = HelmReleaseCardComponent; + columns: ITableColumn[] = [ + { + columnId: 'name', + headerCell: () => 'Name', + cellDefinition: { + valuePath: 'name', + getLink: (row: HelmRelease) => row.guid, + newTab: false, + externalLink: false, + showShortLink: false + }, + sort: { + type: 'sort', + orderKey: 'name', + field: 'name' + }, + cellFlex: '2', + }, + { + columnId: 'cluster', + headerCell: () => 'Cluster', + cellComponent: TableCellEndpointNameComponent, + cellFlex: '2' + }, + { + columnId: 'namespace', + headerCell: () => 'Namespace', + cellDefinition: { + valuePath: 'namespace', + getLink: row => `/kubernetes/${row.endpointId}/namespaces/${row.namespace}` + }, + sort: { + type: 'sort', + orderKey: 'namespace', + field: 'namespace' + }, + cellFlex: '1' + }, + { + columnId: 'status', + headerCell: () => 'Status', + cellDefinition: { + getValue: row => row.status.charAt(0).toUpperCase() + row.status.substring(1) + }, + sort: { + type: 'sort', + orderKey: 'status', + field: 'status' + }, + cellFlex: '2' + }, + { + columnId: 'version', + headerCell: () => 'Chart Version', + cellDefinition: { + valuePath: 'chart.metadata.version' + }, + sort: { + type: 'sort', + orderKey: 'version', + field: 'chart.metadata.version' + }, + cellFlex: '1' + }, + { + columnId: 'last_Deployed', + headerCell: () => 'Last Deployed', + cellDefinition: { + getValue: (row) => `${this.datePipe.transform(row.info.last_deployed, 'medium')}` + }, + sort: { + type: 'sort', + orderKey: 'lastDeployed', + field: 'lastDeployed' + }, + cellFlex: '3' + }, + ]; + + private multiFilterConfigs: IListMultiFilterConfig[]; + + constructor( + private store: Store, + public activatedRoute: ActivatedRoute, + private datePipe: DatePipe, + kubeNamespaceService: KubernetesNamespacesFilterService + ) { + this.dataSource = new HelmReleasesDataSource(this.store, this); + + this.multiFilterConfigs = [ + createKubeNamespaceFilterConfig('kubeId', 'Kubernetes', kubeNamespaceService.kube), + createKubeNamespaceFilterConfig('namespace', 'Namespace', kubeNamespaceService.namespace), + ]; + } + + public getColumns = () => this.columns; + public getGlobalActions = () => []; + public getMultiActions = () => []; + public getSingleActions = () => []; + getMultiFiltersConfigs = () => this.multiFilterConfigs; + public getDataSource = () => this.dataSource; +} + +function createKubeNamespaceFilterConfig(key: string, label: string, cfOrgSpaceItem: KubernetesNamespacesFilterItem) { + return { + key, + label, + ...cfOrgSpaceItem, + list$: cfOrgSpaceItem.list$.pipe(map((entities: any[]) => { + return entities.map(entity => ({ + label: entity.name || entity.metadata.name, + item: entity, + value: entity.guid || entity.metadata.name // Endpoint search via guid, namespace by name (easier filtering) + })); + })), + }; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-releases-list-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-releases-list-source.ts new file mode 100644 index 0000000000..d6fe2020f1 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/helm-releases-list-source.ts @@ -0,0 +1,49 @@ +import { Store } from '@ngrx/store'; +import { + DataFunctionDefinitionType, + ListDataSource, +} from 'frontend/packages/core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { + extractActualListEntity, +} from 'frontend/packages/core/src/shared/components/list/data-sources-controllers/local-filtering-sorting'; +import { IListConfig } from 'frontend/packages/core/src/shared/components/list/list.component.types'; +import { AppState } from 'frontend/packages/store/src/app-state'; +import { PaginationEntityState } from 'frontend/packages/store/src/types/pagination.types'; + +import { HelmRelease } from '../workload.types'; +import { workloadsEntityCatalog } from '../workloads-entity-catalog'; + +const kubeEndpointFilter = (entities: HelmRelease[], paginationState: PaginationEntityState) => { + // Filter by Kube Endpoint and Namespace + const kubeId = paginationState.clientPagination.filter.items.kubeId; + const namespace = paginationState.clientPagination.filter.items.namespace; + return !kubeId && !namespace ? entities : entities.filter(e => { + e = extractActualListEntity(e); + const validKubeId = !(kubeId && kubeId !== e.endpointId); + const validNamespace = !(namespace && namespace !== e.namespace); + return validKubeId && validNamespace; + }); +}; + +export class HelmReleasesDataSource extends ListDataSource { + + constructor( + store: Store, + listConfig: IListConfig + ) { + + const action = workloadsEntityCatalog.release.actions.getMultiple(); + const transformEntities = [{ type: 'filter' as DataFunctionDefinitionType, field: 'name' }, kubeEndpointFilter]; + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: row => action.entity[0].getId(row), + paginationKey: action.paginationKey, + isLocal: true, + transformEntities, + listConfig + }); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/kube-namespaces-filter-config.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/kube-namespaces-filter-config.service.ts new file mode 100644 index 0000000000..ca476270ce --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/list-types/kube-namespaces-filter-config.service.ts @@ -0,0 +1,133 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { safeUnsubscribe } from 'frontend/packages/core/src/core/utils.service'; +import { AppState } from 'frontend/packages/store/src/app-state'; +import { connectedEndpointsOfTypesSelector } from 'frontend/packages/store/src/selectors/endpoint.selectors'; +import { EndpointModel } from 'frontend/packages/store/src/types/endpoint.types'; +import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs'; +import { + distinctUntilChanged, + filter, + first, + map, + publishReplay, + refCount, + startWith, + tap, + withLatestFrom, +} from 'rxjs/operators'; + +import { getCurrentPageRequestInfo } from '../../../../../store/src/reducers/pagination-reducer/pagination-reducer.types'; +import { kubeEntityCatalog } from '../../kubernetes-entity-catalog'; +import { KUBERNETES_ENDPOINT_TYPE } from '../../kubernetes-entity-factory'; +import { KubernetesNamespace } from '../../store/kube.types'; + +export interface KubernetesNamespacesFilterItem { + list$: Observable; + loading$: Observable; + select: BehaviorSubject; +} + +/** + * This service relies on OnDestroy, so must be `provided` by a component + */ +@Injectable() +export class KubernetesNamespacesFilterService implements OnDestroy { + public kube: KubernetesNamespacesFilterItem; + public namespace: KubernetesNamespacesFilterItem; + + private subs: Subscription[] = []; + + private allNamespaces = this.getNamespacesObservable(); + private allNamespacesLoading$ = this.allNamespaces.pagination$.pipe(map( + pag => getCurrentPageRequestInfo(pag).busy + )); + + constructor( + private store: Store, + ) { + this.kube = this.createKube(); + this.namespace = this.createNamespace(); + + // Start watching the namespace plus automatically setting values only when we actually have values to auto select + this.namespace.list$.pipe(first()).subscribe(() => this.setupAutoSelectors()); + } + + private getNamespacesObservable() { + return kubeEntityCatalog.namespace.store.getPaginationService(null); + } + + private createKube() { + const list$ = this.store.select(connectedEndpointsOfTypesSelector(KUBERNETES_ENDPOINT_TYPE)).pipe( + // Ensure we have endpoints + filter(endpoints => endpoints && !!Object.keys(endpoints).length), + publishReplay(1), + refCount(), + ); + return { + list$: list$.pipe( + map(endpoints => Object.values(endpoints)), + first(), + map((endpoints: EndpointModel[]) => { + return Object.values(endpoints).sort((a: EndpointModel, b: EndpointModel) => a.name.localeCompare(b.name)); + }), + ), + loading$: list$.pipe(map(kubes => !kubes)), + select: new BehaviorSubject(undefined) + }; + } + + private createNamespace() { + const namespaceList$ = combineLatest( + this.kube.select.asObservable(), + this.allNamespaces.entities$ + ).pipe(map(([selectedKubeId, entities]) => { + if (selectedKubeId && entities) { + return entities + .filter(namespace => namespace.metadata.kubeId === selectedKubeId) + .sort((a, b) => a.metadata.name.localeCompare(b.metadata.name)); + } + return []; + })); + + return { + list$: namespaceList$, + loading$: this.allNamespacesLoading$, + select: new BehaviorSubject(undefined) + }; + } + + private setupAutoSelectors() { + const namespaceResetSub = this.kube.select.asObservable().pipe( + startWith(undefined), + distinctUntilChanged(), + withLatestFrom(this.namespace.list$), + tap(([, namespaces]) => { + if (!!namespaces.length && namespaces.length === 1 + ) { + this.selectSet(this.namespace.select, namespaces[0].metadata.name); + } else { + this.selectSet(this.namespace.select, undefined); + } + }), + ).subscribe(); + this.subs.push(namespaceResetSub); + } + + private selectSet(select: BehaviorSubject, newValue: string) { + if (select.getValue() !== newValue) { + select.next(newValue); + } + } + + ngOnDestroy(): void { + this.destroy(); + } + + destroy() { + // OnDestroy will be called when the component the service is provided at is destroyed. In theory this should not need to be called + // separately, if you see error's first ensure the service is provided at a component that will be destroyed + // Should be called in the OnDestroy of the component where it's provided + safeUnsubscribe(...this.subs); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-socket-service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-socket-service.ts new file mode 100644 index 0000000000..95f891c894 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-socket-service.ts @@ -0,0 +1,205 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Subject, Subscription } from 'rxjs'; +import makeWebSocketObservable, { GetWebSocketResponses } from 'rxjs-websockets'; +import { catchError, map, share, switchMap } from 'rxjs/operators'; + +import { SnackBarService } from '../../../../../../core/src/shared/services/snackbar.service'; +import { AppState, entityCatalog, WrapperRequestActionSuccess } from '../../../../../../store/src/public-api'; +import { EntityRequestAction } from '../../../../../../store/src/types/request.types'; +import { kubeEntityCatalog } from '../../../kubernetes-entity-catalog'; +import { KubernetesPodExpandedStatusHelper } from '../../../services/kubernetes-expanded-state'; +import { KubernetesPod, KubeService } from '../../../store/kube.types'; +import { KubePaginationAction } from '../../../store/kubernetes.actions'; +import { HelmReleaseGraph, HelmReleasePod, HelmReleaseService } from '../../workload.types'; +import { workloadsEntityCatalog } from '../../workloads-entity-catalog'; +import { HelmReleaseHelperService } from '../tabs/helm-release-helper.service'; + + +enum SocketEventTypes { + PAUSE_TRUE = 20000, + PAUSE_FALSE = 20001, +} + +interface SocketMessage { + type: SocketEventTypes; +} + +@Injectable() +export class HelmReleaseSocketService implements OnDestroy { + + private sub: Subscription; + private sendToSocket = new Subject(); + public isPaused = false; + + constructor( + private helmReleaseHelper: HelmReleaseHelperService, + private store: Store, + private snackbarService: SnackBarService, + ) { + + } + + public start() { + if (this.isStarted()) { + return; + } + + const releaseRef = this.helmReleaseHelper.guidAsUrlFragment(); + const host = window.location.host; + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const streamUrl = ( + `${protocol}://${host}/pp/v1/helm/releases/${releaseRef}/status` + ); + + const socket$ = makeWebSocketObservable(streamUrl).pipe(catchError(e => { + console.error( + 'Error while connecting to socket: ' + JSON.stringify(e) + ); + return []; + }), + share(), + ); + + const messages = socket$.pipe( + switchMap((getResponses: GetWebSocketResponses) => { + return getResponses(this.sendToSocket); + }), + map((message: string) => message), + catchError(e => { + console.error('Workload WS error: ', e); + return []; + }) + ); + + let prefix = ''; + this.sub = messages.subscribe(jsonString => { + const messageObj = JSON.parse(jsonString); + if (messageObj) { + if (messageObj.kind === 'ReleasePrefix') { + prefix = messageObj.data; + } else if (messageObj.kind === 'Pods') { + const pods: KubernetesPod[] = messageObj.data || []; + const podsWithInfo: KubernetesPod[] = pods.map(pod => KubernetesPodExpandedStatusHelper.updatePodWithExpandedStatus(pod)); + const releasePodsAction = kubeEntityCatalog.pod.actions.getInWorkload( + this.helmReleaseHelper.endpointGuid, + this.helmReleaseHelper.releaseTitle + ); + this.populateList(releasePodsAction, podsWithInfo); + } else if (messageObj.kind === 'Graph') { + const graph: HelmReleaseGraph = messageObj.data; + graph.endpointId = this.helmReleaseHelper.endpointGuid; + graph.releaseTitle = this.helmReleaseHelper.releaseTitle; + const releaseGraphAction = workloadsEntityCatalog.graph.actions.get(graph.releaseTitle, graph.endpointId); + this.addResource(releaseGraphAction, graph); + } else if (messageObj.kind === 'Manifest' || messageObj.kind === 'Resources') { + // Store all of the services + const manifest = messageObj.data; + const svcs: KubeService[] = []; + // Store ALL resources for the release + manifest.forEach(resource => { + if (resource.kind === 'Service' && prefix) { + svcs.push(resource); + } + }); + if (svcs.length > 0) { + const releaseServicesAction = kubeEntityCatalog.service.actions.getInWorkload( + this.helmReleaseHelper.releaseTitle, + this.helmReleaseHelper.endpointGuid, + ); + this.populateList(releaseServicesAction, svcs); + } + + // const resources = { ...manifest }; + // kind === 'Resources' is an array, really they should go into a pagination section + messageObj.endpointId = this.helmReleaseHelper.endpointGuid; + messageObj.releaseTitle = this.helmReleaseHelper.releaseTitle; + + const releaseResourceAction = workloadsEntityCatalog.resource.actions.get( + this.helmReleaseHelper.releaseTitle, + this.helmReleaseHelper.endpointGuid, + ); + this.addResource(releaseResourceAction, messageObj); + } else if (messageObj.kind === 'ManifestErrors') { + if (messageObj.data) { + this.snackbarService.show('Errors were found when parsing this workload. Not all resources may be shown', 'Dismiss'); + } + } + } + }); + } + + public stop() { + if (this.sub) { + this.sub.unsubscribe(); + this.sub = null; + } + } + + public enable(enable: boolean) { + if (enable) { + this.start(); + } else { + this.stop(); + } + } + + public isStarted(): boolean { + return !!this.sub; + } + + public pause(pause: boolean) { + if (pause !== this.isPaused) { + const message: SocketMessage = { + type: pause ? SocketEventTypes.PAUSE_TRUE : SocketEventTypes.PAUSE_FALSE + }; + this.sendToSocket.next(JSON.stringify(message)); + this.isPaused = pause; + } + } + + ngOnDestroy() { + this.sub.unsubscribe(); + this.snackbarService.hide(); + } + + private addResource(action: EntityRequestAction, data: any) { + const catalogEntity = entityCatalog.getEntity(action); + const response = { + entities: { + [catalogEntity.entityKey]: { + [action.guid]: data + } + }, + result: [ + action.guid + ] + }; + const successWrapper = new WrapperRequestActionSuccess(response, action); + this.store.dispatch(successWrapper); + } + + private populateList(action: KubePaginationAction, resources: any) { + const entity = entityCatalog.getEntity(action); + const newResources = {}; + resources.forEach(resource => { + const newResource: HelmReleasePod | HelmReleaseService = { + endpointId: action.kubeGuid, + releaseTitle: this.helmReleaseHelper.releaseTitle, + ...resource + }; + newResource.metadata.kubeId = action.kubeGuid; + // The service entity from manifest is missing this, but apply here to ensure any others are caught + newResource.metadata.namespace = this.helmReleaseHelper.namespace; + const entityId = action.entity[0].getId(resource); + newResources[entityId] = newResource; + }); + + const releasePods = { + entities: { [entity.entityKey]: newResources }, + result: Object.keys(newResources) + }; + const successWrapper = new WrapperRequestActionSuccess(releasePods, action, 'fetch', releasePods.result.length, 1); + this.store.dispatch(successWrapper); + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.html new file mode 100644 index 0000000000..4b359ba8f9 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.html @@ -0,0 +1,4 @@ + +

{{ title }}

+
+ \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.spec.ts new file mode 100644 index 0000000000..7dd6fde844 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.spec.ts @@ -0,0 +1,38 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../../../core/src/tab-nav.service'; +import { HelmReleaseProviders, KubeBaseGuidMock, KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { HelmReleaseTabBaseComponent } from './helm-release-tab-base.component'; + + +describe('HelmReleaseTabBaseComponent', () => { + let component: HelmReleaseTabBaseComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [...KubernetesBaseTestModules], + declarations: [HelmReleaseTabBaseComponent], + providers: [ + ...HelmReleaseProviders, + TabNavService, + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseTabBaseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts new file mode 100644 index 0000000000..36b41f47cd --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts @@ -0,0 +1,76 @@ +import { Component, OnDestroy } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { IPageSideNavTab } from '../../../../../../core/src/features/dashboard/page-side-nav/page-side-nav.component'; +import { SessionService } from '../../../../../../core/src/shared/services/session.service'; +import { SnackBarService } from '../../../../../../core/src/shared/services/snackbar.service'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { HelmReleaseGuid } from '../../workload.types'; +import { HelmReleaseHelperService } from '../tabs/helm-release-helper.service'; +import { HelmReleaseSocketService } from './helm-release-socket-service'; + + +@Component({ + selector: 'app-helm-release-tab-base', + templateUrl: './helm-release-tab-base.component.html', + styleUrls: ['./helm-release-tab-base.component.scss'], + providers: [ + HelmReleaseHelperService, + KubernetesAnalysisService, + { + provide: HelmReleaseGuid, + useFactory: (activatedRoute: ActivatedRoute) => ({ + guid: activatedRoute.snapshot.params.guid + }), + deps: [ + ActivatedRoute + ] + }, + HelmReleaseSocketService + ] +}) +export class HelmReleaseTabBaseComponent implements OnDestroy { + + isFetching$: Observable; + + public breadcrumbs = [{ + breadcrumbs: [ + { value: 'Workloads', routerLink: '/workloads' } + ] + }]; + + public title = ''; + + tabLinks: IPageSideNavTab[]; + + constructor( + public helmReleaseHelper: HelmReleaseHelperService, + private analysisService: KubernetesAnalysisService, + private snackbarService: SnackBarService, + sessionService: SessionService, + private socketService: HelmReleaseSocketService + ) { + this.title = this.helmReleaseHelper.releaseTitle; + + this.tabLinks = [ + { link: 'summary', label: 'Summary', icon: 'helm', iconFont: 'stratos-icons' }, + { link: 'notes', label: 'Notes', icon: 'subject' }, + { link: 'values', label: 'Values', icon: 'list' }, + { link: 'history', label: 'History', icon: 'schedule' }, + { link: 'analysis', label: 'Analysis', icon: 'assignment', hidden$: this.analysisService.hideAnalysis$ }, + { link: '-', label: 'Resources' }, + { link: 'graph', label: 'Overview', icon: 'share', hidden$: sessionService.isTechPreview().pipe(map(tp => !tp)) }, + { link: 'pods', label: 'Pods', icon: 'pod', iconFont: 'stratos-icons' }, + { link: 'services', label: 'Services', icon: 'service', iconFont: 'stratos-icons' } + ]; + + this.socketService.start(); + } + + ngOnDestroy() { + this.socketService.stop(); + this.snackbarService.hide(); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/icon-helper.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/icon-helper.ts new file mode 100644 index 0000000000..7029707587 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/icon-helper.ts @@ -0,0 +1,86 @@ +const iconMappings = { + Namespace: { + name: 'namespace', + font: 'stratos-icons' + }, + Container: { + name: 'container', + font: 'stratos-icons' + }, + ClusterRole: { + name: 'cluster_role', + font: 'stratos-icons' + }, + ClusterRoleBinding: { + name: 'cluster_role_binding', + font: 'stratos-icons' + }, + Deployment: { + name: 'deployment', + font: 'stratos-icons' + }, + ReplicaSet: { + name: 'replica_set', + font: 'stratos-icons' + }, + Pod: { + name: 'pod', + font: 'stratos-icons' + }, + Service: { + name: 'service', + font: 'stratos-icons' + }, + Role: { + name: 'assignment_ind', + font: 'Material Icons', + fontSet: 'material-icons' + }, + RoleBinding: { + name: 'role_binding', + font: 'stratos-icons' + }, + StatefulSet: { + name: 'stateful_set', + font: 'stratos-icons' + }, + Ingress: { + name: 'ingress', + font: 'stratos-icons' + }, + ConfigMap: { + name: 'config_map', + font: 'stratos-icons' + }, + Secret: { + name: 'lock', + font: 'Material Icons', + fontSet: 'material-icons' + }, + ServiceAccount: { + name: 'account_circle', + font: 'Material Icons', + fontSet: 'material-icons' + }, + Job: { + name: 'job', + font: 'stratos-icons' + }, + PersistentVolumeClaim: { + name: 'persistent_volume', + font: 'stratos-icons' + }, + default: { + name: 'collocation', + font: 'stratos-icons' + } +}; + +export function getIcon(kind: string) { + const rkind = kind || 'Pod'; + if (iconMappings[rkind]) { + return iconMappings[rkind]; + } else { + return iconMappings.default; + } +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.html new file mode 100644 index 0000000000..15301e10ff --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.html @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.spec.ts new file mode 100644 index 0000000000..3ae0b29782 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.spec.ts @@ -0,0 +1,43 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../../../../core/src/tab-nav.service'; +import { AnalysisReportViewerComponent } from '../../../../analysis-report-viewer/analysis-report-viewer.component'; +import { HelmReleaseProviders, KubeBaseGuidMock, KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { + AnalysisReportSelectorComponent, +} from './../../../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { HelmReleaseAnalysisTabComponent } from './helm-release-analysis-tab.component'; + +describe('HelmReleaseAnalysisTabComponent', () => { + let component: HelmReleaseAnalysisTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [HelmReleaseAnalysisTabComponent, AnalysisReportSelectorComponent, AnalysisReportViewerComponent], + imports: [ + KubernetesBaseTestModules, + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + HelmReleaseProviders, + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseAnalysisTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.ts new file mode 100644 index 0000000000..5986c0df78 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.ts @@ -0,0 +1,41 @@ +import { Component } from '@angular/core'; +import { Subject } from 'rxjs'; + +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { AnalysisReport } from '../../../../store/kube.types'; +import { HelmReleaseHelperService } from '../helm-release-helper.service'; + +@Component({ + selector: 'app-helm-release-analysis-tab', + templateUrl: './helm-release-analysis-tab.component.html', + styleUrls: ['./helm-release-analysis-tab.component.scss'] +}) +export class HelmReleaseAnalysisTabComponent { + + public report$ = new Subject(); + + path: string; + + currentReport = null; + + noReportsAvailable = false; + + constructor( + public analaysisService: KubernetesAnalysisService, + public helmReleaseHelper: HelmReleaseHelperService + ) { + this.path = `${this.helmReleaseHelper.namespace}/${this.helmReleaseHelper.releaseTitle}`; + } + + public analysisChanged(report) { + if (report.id !== this.currentReport) { + this.currentReport = report.id; + this.analaysisService.getByID(this.helmReleaseHelper.endpointGuid, report.id).subscribe(r => this.report$.next(r)); + } + } + + public onReportCount(count: number) { + this.noReportsAvailable = count === 0; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.spec.ts new file mode 100644 index 0000000000..94a111d642 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.spec.ts @@ -0,0 +1,28 @@ +import { Version } from './helm-release-helper.service'; + +describe('HelmReleaseHelperService', () => { + + describe('Version', () => { + + const v10 = new Version('1.0.0'); + const v11 = new Version('1.1.0'); + const v11rc1 = new Version('1.0.0-rc.1'); + const v11rc2 = new Version('1.0.0-rc.2'); + const v201 = new Version('2.0.1'); + const v101 = new Version('1.0.1'); + + it('version comparisons', () => { + expect(v11.isNewer(v10)).toBe(true); + expect(v11rc1.isNewer(v11)).toBe(false); + expect(v11rc2.isNewer(v11rc1)).toBe(true); + expect(v201.isNewer(v11)).toBe(true); + expect(v201.isNewer(v11rc1)).toBe(true); + expect(v10.isNewer(v11)).toBe(false); + expect(v101.isNewer(v11)).toBe(false); + expect(v101.isNewer(v10)).toBe(true); + + expect(v11rc1.isNewer(v10)).toBe(false); + + }); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.ts new file mode 100644 index 0000000000..5b9c818e19 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helper.service.ts @@ -0,0 +1,297 @@ +import { Injectable } from '@angular/core'; +import { combineLatest, Observable } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +import { helmEntityCatalog } from '../../../../helm/helm-entity-catalog'; +import { ChartAttributes } from '../../../../helm/monocular/shared/models/chart'; +import { ChartMetadata } from '../../../../helm/store/helm.types'; +import { kubeEntityCatalog } from '../../../kubernetes-entity-catalog'; +import { ContainerStateCollection, KubernetesPod } from '../../../store/kube.types'; +import { getHelmReleaseDetailsFromGuid } from '../../store/workloads-entity-factory'; +import { + HelmRelease, + HelmReleaseChartData, + HelmReleaseGraph, + HelmReleaseGuid, + HelmReleaseResources, + HelmReleaseRevision, +} from '../../workload.types'; +import { workloadsEntityCatalog } from '../../workloads-entity-catalog'; + +// Simple class to represent MAJOR.MINOR.REVISION version +export class Version { + + public major: number; + public minor: number; + public revision: number; + + public prerelease: string; + + public valid: boolean; + + constructor(v: string) { + this.valid = false; + if (typeof v === 'string') { + let version = v; + const pre = v.split('-'); + if (pre.length > 1) { + version = pre[0]; + this.prerelease = pre[1]; + } + const parts = version.split('.'); + if (parts.length === 3) { + this.major = parseInt(parts[0], 10); + this.minor = parseInt(parts[1], 10); + this.revision = parseInt(parts[2], 10); + this.valid = true; + } + } + } + + // Is this version newer than the supplied other version? + public isNewer(other: Version): boolean { + if (!this.valid || !other.valid) { + return false; + } + + if (this.major > other.major) { + return true; + } + + if (this.major === other.major) { + if (this.minor > other.minor) { + return true; + } + if (this.minor === other.minor) { + if (this.revision === other.revision) { + // Same version numbers + if (this.prerelease && !other.prerelease) { + return false; + } + if(!this.prerelease && other.prerelease) { + return true; + } + if (this.prerelease && other.prerelease) { + return this.prerelease > other.prerelease; + } + return false; + } + return this.revision > other.revision; + } + } + return false; + } +} + +type InternalHelmUpgrade = { + release: HelmRelease, + upgrade: ChartAttributes, + version: string, + monocularEndpointId: string; +}; + +@Injectable() +export class HelmReleaseHelperService { + + public isFetching$: Observable; + + public release$: Observable; + + public guid: string; + public endpointGuid: string; + public namespace: string; + public releaseTitle: string; + + constructor( + helmReleaseGuid: HelmReleaseGuid, + ) { + this.guid = helmReleaseGuid.guid; + const { endpointId, namespace, releaseTitle } = getHelmReleaseDetailsFromGuid(this.guid); + this.releaseTitle = releaseTitle; + this.namespace = namespace; + this.endpointGuid = endpointId; + + const entityService = workloadsEntityCatalog.release.store.getEntityService( + this.releaseTitle, + this.endpointGuid, + { namespace: this.namespace } + ); + + this.release$ = entityService.waitForEntity$.pipe( + map((item) => item.entity), + map((item: HelmRelease) => { + if (!item.chart.metadata.icon) { + const copy = JSON.parse(JSON.stringify(item)); + copy.chart.metadata.icon = '/core/assets/custom/app_placeholder.svg'; + return copy; + } + return item; + }) + ); + + this.isFetching$ = entityService.isFetchingEntity$; + } + + public guidAsUrlFragment(): string { + return this.guid.replace(':', '/').replace(':', '/'); + } + + public fetchReleaseGraph(): Observable { + // Get helm release + const guid = workloadsEntityCatalog.graph.actions.get(this.releaseTitle, this.endpointGuid).guid; + return workloadsEntityCatalog.graph.store.getEntityMonitor(guid).entity$.pipe( + filter(graph => !!graph) + ); + } + + public fetchReleaseResources(): Observable { + // Get helm release + const guid = workloadsEntityCatalog.resource.actions.get(this.releaseTitle, this.endpointGuid).guid; + return workloadsEntityCatalog.resource.store.getEntityMonitor(guid).entity$.pipe( + filter(resources => !!resources) + ); + } + + public fetchReleaseChartStats(): Observable { + return kubeEntityCatalog.pod.store.getInWorkload.getPaginationMonitor( + this.endpointGuid, + this.releaseTitle + ).currentPage$.pipe( + filter(pods => !!pods), + map(pods => this.mapPods(pods)) + ); + } + + // Check to see if a workload has updates available + public getCharts() { + return helmEntityCatalog.chart.store.getPaginationService().entities$.pipe( + filter(charts => !!charts) + ); + } + + public fetchReleaseHistory(): Observable { + // Get the history for a Helm release + return workloadsEntityCatalog.history.store.getEntityService( + this.releaseTitle, + this.endpointGuid, + { namespace: this.namespace } + ).waitForEntity$.pipe( + map(historyEntity => historyEntity.entity.revisions) + ); + } + + private mapPods(pods: KubernetesPod[]): HelmReleaseChartData { + const podPhases: { [phase: string]: number, } = {}; + const containers = { + ready: { + name: 'Ready', + value: 0 + }, + notReady: { + name: 'Not Ready', + value: 0 + } + }; + + pods.forEach(pod => { + const status = pod.expandedStatus.status; + + if (!podPhases[status]) { + podPhases[status] = 1; + } else { + podPhases[status]++; + } + + if (pod.status.containerStatuses) { + pod.status.containerStatuses.forEach(containerStatus => { + const isReady = this.isContainerReady(containerStatus.state); + if (isReady === true) { + containers.ready.value++; + } else if (isReady === false) { + containers.notReady.value++; + } + }); + } + }); + + return { + podsChartData: Object.entries(podPhases).map(([phase, count]) => ({ + name: phase, + value: count + })), + containersChartData: Object.values(containers) + }; + } + + // tslint:disable-next-line:ban-types + private isContainerReady(state: ContainerStateCollection = {}): Boolean { + if (state.running) { + return true; + } else if (!!state.waiting) { + return false; + } else if (!!state.terminated) { + // Assume a failed state is not ready (covers completed init states), discard success state + return state.terminated.exitCode === 0 ? null : false; + } + return false; + } + + public hasUpgrade(returnLatest = false): Observable { + const updates = combineLatest(this.getCharts(), this.release$); + return updates.pipe( + map(([charts, release]) => { + for (const c of charts) { + if (this.isProbablySameChart(c.attributes, release.chart.metadata)) { + if (c.relationships && c.relationships.latestChartVersion && c.relationships.latestChartVersion.data) { + const latest = new Version(c.relationships.latestChartVersion.data.version); + const current = new Version(release.chart.metadata.version); + if (latest.isNewer(current)) { + return { + release, + upgrade: c.attributes, + version: c.relationships.latestChartVersion.data.version, + monocularEndpointId: c.monocularEndpointId + }; + } + } + } + } + // No newer release, so return the release itself if that is what was requested and we can find the chart + // NOTE: If the helm repository is removed that we installed from, we won't be able to find the chart + if (returnLatest) { + // Need to check that the chart is probably the same + const releaseChart = charts.find(c => this.isProbablySameChart(c.attributes, release.chart.metadata) && + c.relationships.latestChartVersion.data.version === release.chart.metadata.version); + if (releaseChart) { + return { + release, + upgrade: releaseChart.attributes, + version: releaseChart.relationships.latestChartVersion.data.version, + monocularEndpointId: releaseChart.monocularEndpointId + }; + } + } + return null; + }) + ); + } + + // We might have a chart with the same name in multiple repositories - we only have chart metadata + // We don't know which Helm repository it came from, so use the name and sources to match + private isProbablySameChart(a: ChartMetadata, b: ChartMetadata): boolean { + // Basic properties must be the same + if (a.name !== b.name) { + return false; + } + + // Must have at least one source in common + let count = 0; + a.sources.forEach(source => { + count += b.sources.findIndex((s) => s === source) === -1 ? 0 : 1; + }); + + return count > 0; + } + +} + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helpers.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helpers.scss new file mode 100644 index 0000000000..8b6c1eeb83 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-helpers.scss @@ -0,0 +1,8 @@ + +@mixin add-life-update-style { + app-workload-live-reload { + display: flex; + flex: 1; + justify-content: flex-end; + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.html new file mode 100644 index 0000000000..7e055c154c --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.html @@ -0,0 +1 @@ + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.spec.ts new file mode 100644 index 0000000000..f2b19dceeb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.spec.ts @@ -0,0 +1,31 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HelmReleaseGuidMock } from '../../../../../helm/helm-testing.module'; +import { HelmReleaseHelperService } from '../helm-release-helper.service'; +import { HelmReleaseHistoryTabComponent } from './helm-release-history-tab.component'; + +describe('HelmReleaseHistoryTabComponent', () => { + let component: HelmReleaseHistoryTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ HelmReleaseHistoryTabComponent ], + providers: [ + HelmReleaseHelperService, + HelmReleaseGuidMock + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseHistoryTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.ts new file mode 100644 index 0000000000..797e0bfcf7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-history-tab/helm-release-history-tab.component.ts @@ -0,0 +1,94 @@ +import { Component } from '@angular/core'; +import moment from 'moment'; +import { of } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; + +import { + ITableListDataSource, +} from '../../../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { ITableColumn } from '../../../../../../../core/src/shared/components/list/list-table/table.types'; +import { HelmReleaseHelperService } from './../helm-release-helper.service'; + +@Component({ + selector: 'app-helm-release-history-tab', + templateUrl: './helm-release-history-tab.component.html', + styleUrls: ['./helm-release-history-tab.component.scss'] +}) +export class HelmReleaseHistoryTabComponent { + + public columns: ITableColumn[] = []; + + public dataSource: ITableListDataSource; + + constructor(public helmReleaseHelper: HelmReleaseHelperService) { + + // Use the ame column layout as the Helm CLI + this.columns = [ + { + columnId: 'revision', + headerCell: () => 'Revision', + cellFlex: '1', + cellDefinition: { + valuePath: 'revision' + } + }, + { + columnId: 'updated', + headerCell: () => 'Updated', + cellFlex: '3', + cellDefinition: { + getValue: row => moment(row.last_deployed).format('LLL') + } + }, + { + columnId: 'status', + headerCell: () => 'Status', + cellFlex: '2', + cellDefinition: { + valuePath: 'status' + } + }, + { + columnId: 'chart', + headerCell: () => 'Chart', + cellFlex: '2', + cellDefinition: { + getValue: row => `${row.chart.name}-${row.chart.version}` + } + }, + { + columnId: 'app_version', + headerCell: () => 'App Version', + cellFlex: '1', + cellDefinition: { + valuePath: 'chart.appVersion' + } + }, + { + columnId: 'description', + headerCell: () => 'Description', + cellFlex: '2', + cellDefinition: { + valuePath: 'description' + } + }, + ]; + + const data$ = this.helmReleaseHelper.fetchReleaseHistory().pipe( + map(history => [...history].sort((a, b) => b.revision - a.revision)) + ); + this.dataSource = { + connect: () => data$, + disconnect: () => { }, + trackBy: (index, item) => item.revision, + isTableLoading$: data$.pipe( + map(revisions => !revisions), + startWith(true), + ), + getRowState: (row) => { + return of({}); + } + }; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.html new file mode 100644 index 0000000000..6866cf45dd --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.html @@ -0,0 +1,6 @@ +
+
+
+ + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.scss new file mode 100644 index 0000000000..db0219cb1e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.scss @@ -0,0 +1,6 @@ +.helm-release-notes { + display: block; + font-family: Source Code Pro; + font-size: 14px; + white-space: pre-wrap; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.spec.ts new file mode 100644 index 0000000000..1d3a3c6811 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.spec.ts @@ -0,0 +1,34 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HelmReleaseProviders, KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { HelmReleaseNotesTabComponent } from './helm-release-notes-tab.component'; + +describe('HelmReleaseNotesTabComponent', () => { + let component: HelmReleaseNotesTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [ + HelmReleaseNotesTabComponent + ], + providers: [ + ...HelmReleaseProviders + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseNotesTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.ts new file mode 100644 index 0000000000..35a2ed9039 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-notes-tab/helm-release-notes-tab.component.ts @@ -0,0 +1,31 @@ +import { Component } from '@angular/core'; +import { AnsiColors } from 'frontend/packages/core/src/shared/components/log-viewer/ansi-colors'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HelmReleaseHelperService } from '../helm-release-helper.service'; + +@Component({ + selector: 'app-helm-release-notes-tab', + templateUrl: './helm-release-notes-tab.component.html', + styleUrls: ['./helm-release-notes-tab.component.scss'] +}) +export class HelmReleaseNotesTabComponent { + + public notes$: Observable; + + private colorizer = new AnsiColors(); + + constructor(public helmReleaseHelper: HelmReleaseHelperService) { + + this.notes$ = helmReleaseHelper.release$.pipe( + map(release => { + if (release.info.notes) { + return this.colorizer.ansiColorsToHtml(release.info.notes); + } else { + return ''; + } + }) + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.html new file mode 100644 index 0000000000..0bb77b9833 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.html @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.scss new file mode 100644 index 0000000000..81703c5b2a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.scss @@ -0,0 +1,3 @@ +@import '../helm-release-helpers'; + +@include add-life-update-style diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.spec.ts new file mode 100644 index 0000000000..a312703b4e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HelmReleaseGuidMock } from '../../../../../helm/helm-testing.module'; +import { HelmReleaseProviders, KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { HelmReleaseSocketService } from '../../helm-release-tab-base/helm-release-socket-service'; +import { WorkloadLiveReloadComponent } from '../../workload-live-reload/workload-live-reload.component'; +import { HelmReleaseHelperService } from '../helm-release-helper.service'; +import { HelmReleasePodsTabComponent } from './helm-release-pods-tab.component'; + +describe('HelmReleasePodsTabComponent', () => { + let component: HelmReleasePodsTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [ + HelmReleasePodsTabComponent, + WorkloadLiveReloadComponent + ], + providers: [ + ...HelmReleaseProviders, + HelmReleaseSocketService, + HelmReleaseHelperService, + HelmReleaseGuidMock + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleasePodsTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.ts new file mode 100644 index 0000000000..3e03a8e982 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-pods/helm-release-pods-tab.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ListConfig } from 'frontend/packages/core/src/shared/components/list/list.component.types'; + +import { HelmReleasePodsListConfig } from '../../../list-types/helm-release-pods-list-config.service'; + +@Component({ + selector: 'app-helm-release-pods-tab', + templateUrl: './helm-release-pods-tab.component.html', + styleUrls: ['./helm-release-pods-tab.component.scss'], + providers: [{ + provide: ListConfig, + useClass: HelmReleasePodsListConfig, + }] +}) +export class HelmReleasePodsTabComponent { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.html new file mode 100644 index 0000000000..9087c4666a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + {{ node.data.icon.name }} + {{node.label}} + {{node.data.kind}} + + + + + + + + +
+
+ Loading resources + + +
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.scss new file mode 100644 index 0000000000..a41e57f918 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.scss @@ -0,0 +1,15 @@ +@import '../helm-release-helpers'; + +:host { + display: flex; + height: 100%; + width: 100%; +} + +@keyframes dash { + to { + stroke-dashoffset: 1000; + } +} + +@include add-life-update-style diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.spec.ts new file mode 100644 index 0000000000..6395c37110 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.spec.ts @@ -0,0 +1,47 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NgxGraphModule } from '@swimlane/ngx-graph'; +import { SidePanelService } from 'frontend/packages/core/src/shared/services/side-panel.service'; + +import { TabNavService } from '../../../../../../../core/src/tab-nav.service'; +import { HelmReleaseProviders, KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { + AnalysisReportSelectorComponent, +} from './../../../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { KubeBaseGuidMock } from './../../../../kubernetes.testing.module'; +import { HelmReleaseResourceGraphComponent } from './helm-release-resource-graph.component'; + +describe('HelmReleaseResourceGraphComponent', () => { + let component: HelmReleaseResourceGraphComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules, + NgxGraphModule + ], + declarations: [HelmReleaseResourceGraphComponent, AnalysisReportSelectorComponent], + providers: [ + ...HelmReleaseProviders, + SidePanelService, + TabNavService, + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseResourceGraphComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts new file mode 100644 index 0000000000..c6774f7980 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts @@ -0,0 +1,265 @@ +import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'; +import { Edge } from '@swimlane/ngx-graph'; +import { SidePanelService } from 'frontend/packages/core/src/shared/services/side-panel.service'; +import { BehaviorSubject, combineLatest, Observable, Subject, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, first, map, publishReplay, refCount, startWith } from 'rxjs/operators'; + +import { + KubernetesResourceViewerComponent, +} from '../../../../kubernetes-resource-viewer/kubernetes-resource-viewer.component'; +import { ResourceAlert, ResourceAlertLevel } from '../../../../services/analysis-report.types'; +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { + HelmReleaseGraphLink, + HelmReleaseGraphNode, + HelmReleaseGraphNodeData, + HelmReleaseResource, + HelmReleaseResources, +} from '../../../workload.types'; +import { getIcon } from '../../icon-helper'; +import { HelmReleaseHelperService } from '../helm-release-helper.service'; + + +interface Colors { + bg: string; + fg: string; +} + +const layouts = [ + 'dagre', + 'd3ForceDirected', + 'colaForceDirected' +]; + +interface CustomHelmReleaseGraphNode extends Omit { + data: CustomHelmReleaseGraphNodeData; +} + +interface CustomHelmReleaseGraphNode { + id: string; + label: string; + data: CustomHelmReleaseGraphNodeData; +} + +interface CustomHelmReleaseGraphNodeData extends HelmReleaseGraphNodeData { + missing: boolean, + dash: number, + fill: string, + text: string, + icon: any, + alerts: [], + alertSummary: {}; +} + +@Component({ + selector: 'app-helm-release-resource-graph', + templateUrl: './helm-release-resource-graph.component.html', + styleUrls: ['./helm-release-resource-graph.component.scss'] +}) +export class HelmReleaseResourceGraphComponent implements OnInit, OnDestroy { + + // see: https://swimlane.github.io/ngx-graph/#/#quick-start + + public nodes: CustomHelmReleaseGraphNode[] = []; + public links: Edge[] = []; + + update$: BehaviorSubject = new BehaviorSubject(false); + + fit$: BehaviorSubject = new BehaviorSubject(false); + + public layout = 'dagre'; + + public layoutIndex = 0; + + private graph: Subscription; + + private didInitialFit = false; + + public path: string; + + private analysisReportUpdated = new Subject(); + private analysisReportUpdated$ = this.analysisReportUpdated.pipe( + startWith(null), + distinctUntilChanged(), + publishReplay(1), + refCount() + ); + + constructor( + private componentFactoryResolver: ComponentFactoryResolver, + private helper: HelmReleaseHelperService, + public analyzerService: KubernetesAnalysisService, + private previewPanel: SidePanelService) { + this.path = `${this.helper.namespace}/${this.helper.releaseTitle}`; + } + + ngOnInit() { + + // Listen for the graph + this.graph = combineLatest( + this.helper.fetchReleaseGraph(), + this.analysisReportUpdated$ + ).subscribe(([g, report]) => { + const newNodes: CustomHelmReleaseGraphNode[] = []; + Object.values(g.nodes).forEach((node: HelmReleaseGraphNode) => { + const colors = this.getColor(node.data.status); + const icon = getIcon(node.data.kind); + const missing = node.data.status === 'missing'; + + const newNode: CustomHelmReleaseGraphNode = { + id: node.id, + label: node.label, + data: { + ...node.data, + missing: node.data.status === 'missing', + dash: missing ? 6 : 0, + fill: colors.bg, + text: colors.fg, + icon, + alerts: null, + alertSummary: {} + }, + }; + + // Does this node have any alerts? + this.applyAlertToNode(newNode, report); + + newNodes.push(newNode); + }); + this.nodes = newNodes; + + const newLinks: HelmReleaseGraphLink[] = []; + Object.values(g.links).forEach((link: any) => { + newLinks.push({ + id: link.id, + label: link.id, + source: link.source, + target: link.target + }); + }); + this.links = newLinks; + this.update$.next(true); + + if (!this.didInitialFit) { + this.didInitialFit = true; + setTimeout(() => this.fitGraph(), 10); + } + }); + } + + private applyAlertToNode(newNode, report) { + if (report && report.alerts) { + Object.values(report.alerts).forEach((group: ResourceAlert[]) => { + group.forEach(alert => { + if ( + newNode.data.kind.toLowerCase() === alert.kind && + newNode.data.metadata.name === alert.name + // namespace is undefined, however the only resources we have should be from the correct context + ) { + newNode.data.alerts = newNode.data.alerts || []; + newNode.data.alerts.push(alert); + newNode.data.alertSummary = newNode.data.alertSummary || {}; + if (alert.level > newNode.data.alertSummary.level || !newNode.data.alertSummary.level) { + newNode.data.alertSummary.color = this.alertLevelToColor(alert.level); + newNode.data.alertSummary.level = alert.level; + } + } + }); + }); + } + } + + private alertLevelToColor(level: ResourceAlertLevel) { + // These colours need to come from theme - #420 + switch (level) { + case ResourceAlertLevel.Info: + return '#42a5f5'; + case ResourceAlertLevel.Warning: + return '#ff9800'; + case ResourceAlertLevel.Error: + return '#f44336'; + } + } + + ngOnDestroy() { + if (this.graph) { + this.graph.unsubscribe(); + } + } + + // Open side panel when node is clicked + public onNodeClick(node: CustomHelmReleaseGraphNode) { + this.analysisReportUpdated$.pipe(first()).subscribe(analysis => { + this.previewPanel.show( + KubernetesResourceViewerComponent, + { + title: 'Helm Release Resource Preview', + resource$: this.getResource(node), + analysis, + resourceKind: node.data.kind + }, + this.componentFactoryResolver + ); + }); + + } + + public fitGraph() { + this.fit$.next(true); + } + + public toggleLayout() { + this.layoutIndex++; + if (this.layoutIndex === layouts.length) { + this.layoutIndex = 0; + } + + this.layout = layouts[this.layoutIndex]; + } + + private getColor(status: string): Colors { + switch (status) { + case 'error': + return { + bg: 'red', + fg: 'white' + }; + case 'ok': + return { + bg: 'green', + fg: 'white' + }; + case 'warn': + return { + bg: 'orange', + fg: 'white' + }; + default: + return { + bg: '#5a9cb0', + fg: 'white' + }; + } + } + + private getResource(node: CustomHelmReleaseGraphNode): Observable { + return this.helper.fetchReleaseResources().pipe( + filter(r => !!r), + map((r: HelmReleaseResources) => Object.values(r.data).find((res) => + res.metadata.name === node.label && res.kind === node.data.kind + )), + first(), + ); + } + + public analysisChanged(report) { + if (report === null) { + this.analysisReportUpdated.next(null); + } else { + this.analyzerService.getByID(this.helper.endpointGuid, report.id).subscribe(results => { + this.analysisReportUpdated.next(results); + }); + } + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.html new file mode 100644 index 0000000000..0bb77b9833 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.html @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.scss new file mode 100644 index 0000000000..81703c5b2a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.scss @@ -0,0 +1,3 @@ +@import '../helm-release-helpers'; + +@include add-life-update-style diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.spec.ts new file mode 100644 index 0000000000..b54d40a909 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.spec.ts @@ -0,0 +1,43 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HelmReleaseGuidMock } from '../../../../../helm/helm-testing.module'; +import { HelmReleaseProviders, KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { HelmReleaseSocketService } from '../../helm-release-tab-base/helm-release-socket-service'; +import { WorkloadLiveReloadComponent } from '../../workload-live-reload/workload-live-reload.component'; +import { HelmReleaseHelperService } from '../helm-release-helper.service'; +import { HelmReleaseServicesTabComponent } from './helm-release-services-tab.component'; + + +describe('HelmReleaseServicesTabComponent', () => { + let component: HelmReleaseServicesTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [ + HelmReleaseServicesTabComponent, + WorkloadLiveReloadComponent + ], + providers: [ + ...HelmReleaseProviders, + HelmReleaseSocketService, + HelmReleaseHelperService, + HelmReleaseGuidMock + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseServicesTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.ts new file mode 100644 index 0000000000..d2ea83b78b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-services/helm-release-services-tab.component.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { ListConfig } from 'frontend/packages/core/src/shared/components/list/list.component.types'; + +import { HelmReleaseServicesListConfig } from '../../../list-types/helm-release-services-list-config.service'; + +@Component({ + selector: 'app-helm-release-services-tab', + templateUrl: './helm-release-services-tab.component.html', + styleUrls: ['./helm-release-services-tab.component.scss'], + providers: [{ + provide: ListConfig, + useClass: HelmReleaseServicesListConfig, + }] +}) +export class HelmReleaseServicesTabComponent { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.html new file mode 100644 index 0000000000..ade8a8bf0a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.html @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + +
+
+ Upgrade available: {{ upgrade }} +
+ +
+
+ {{ release.chart.metadata.version }} + + + {{ release.chart.metadata.appVersion || '-' }} + + + {{ getClusterName() | async }} + + + {{ release.namespace }} + + {{ release.version }} + + {{ release.status | titlecase }} + + + {{ release.info.first_deployed | date:'medium' }} + + + {{ release.info.last_deployed | date:'medium' }} + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ +
+
+ Updating Resource Information + + +
+
+
+
+ +
+
+ Loading resources + + +
+
+
+
+
\ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.scss new file mode 100644 index 0000000000..9e24c2a39e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.scss @@ -0,0 +1,78 @@ + +@import '../../../../../../../core/sass/mixins'; +@import '../helm-release-helpers'; + +.resources { + padding: 0 10px; + + &__loading { + display: flex; + justify-content: center; + &__content { + margin-top: 50px; + mat-progress-bar { + margin-top: 15px; + } + } + } +} +.app-metadata { + display: flex; + flex-direction: row; + &__two-cols { + flex: 1; + app-metadata-item { + &:first-child { + margin-top: 0; + } + } + } +} +.chart-details { + display: flex; + flex-direction: row; + flex-flow: wrap; + padding: 0 10px 10px; + &__item { + margin-right: 40px; + } +} +.chart-description { + font-size: 15px; + max-width: 800px; + opacity: .6; +} + +.chart-upgrade { + border-radius: 8px; + float: right; + font-size: 12px; + margin-right: 10px; + padding: 2px 8px; +} + +.grid { + $bottom-space: 20px; + display: grid; + grid-column-gap: 10px; + grid-row-gap: 10px; + grid-template-columns: 100%; + min-height: 0; + min-width: 0; + + @include breakpoint(tablet) { + grid-column-gap: $bottom-space; + grid-row-gap: $bottom-space; + grid-template-columns: repeat(2, 1fr); + } + + @include breakpoint(laptop) { + grid-template-columns: repeat(4, 1fr); + } + + @include breakpoint(desktop) { + grid-template-columns: repeat(5, 1fr); + } +} + +@include add-life-update-style diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.spec.ts new file mode 100644 index 0000000000..d1779d79e7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.spec.ts @@ -0,0 +1,45 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SidePanelService } from '../../../../../../../core/src/shared/services/side-panel.service'; +import { TabNavService } from '../../../../../../../core/src/tab-nav.service'; +import { HelmReleaseProviders, KubeBaseGuidMock } from '../../../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { WorkloadsBaseTestingModule } from '../../../workloads.testing.module'; +import { + AnalysisReportSelectorComponent, +} from './../../../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { HelmReleaseSummaryTabComponent } from './helm-release-summary-tab.component'; + +describe('HelmReleaseSummaryTabComponent', () => { + let component: HelmReleaseSummaryTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...WorkloadsBaseTestingModule + ], + declarations: [HelmReleaseSummaryTabComponent, AnalysisReportSelectorComponent], + providers: [ + ...HelmReleaseProviders, + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + TabNavService, + SidePanelService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseSummaryTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.theme.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.theme.scss new file mode 100644 index 0000000000..30c0894af2 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.theme.scss @@ -0,0 +1,15 @@ +@mixin helm-release-summary-tab-theme($theme, $app-theme) { + + $status-colors: map-get($app-theme, status); + $status-success: map-get($status-colors, success); + $status-warning: map-get($status-colors, warning); + $status-danger: map-get($status-colors, danger); + $status-tentative: map-get($status-colors, tentative); + $status-info: map-get($status-colors, info); + + .chart-upgrade { + border: 1px solid $status-success; + color: $status-success; + } + +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts new file mode 100644 index 0000000000..d896843d89 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts @@ -0,0 +1,294 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, ComponentFactoryResolver, OnDestroy } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { ConfirmationDialogConfig } from 'frontend/packages/core/src/shared/components/confirmation-dialog.config'; +import { ConfirmationDialogService } from 'frontend/packages/core/src/shared/components/confirmation-dialog.service'; +import { SidePanelService } from 'frontend/packages/core/src/shared/services/side-panel.service'; +import { ClearPaginationOfType } from 'frontend/packages/store/src/actions/pagination.actions'; +import { RouterNav } from 'frontend/packages/store/src/actions/router.actions'; +import { AppState } from 'frontend/packages/store/src/app-state'; +import { combineLatest, Observable, ReplaySubject, Subject } from 'rxjs'; +import { distinctUntilChanged, filter, first, map, publishReplay, refCount, startWith } from 'rxjs/operators'; + +import { SnackBarService } from '../../../../../../../core/src/shared/services/snackbar.service'; +import { endpointsEntityRequestDataSelector } from '../../../../../../../store/src/selectors/endpoint.selectors'; +import { + ResourceAlertPreviewComponent, +} from '../../../../analysis-report-viewer/resource-alert-preview/resource-alert-preview.component'; +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { HelmReleaseChartData } from '../../../workload.types'; +import { workloadsEntityCatalog } from '../../../workloads-entity-catalog'; +import { getIcon } from '../../icon-helper'; +import { HelmReleaseHelperService } from '../helm-release-helper.service'; +import { ResourceAlert } from './../../../../services/analysis-report.types'; + +@Component({ + selector: 'app-helm-release-summary-tab', + templateUrl: './helm-release-summary-tab.component.html', + styleUrls: ['./helm-release-summary-tab.component.scss'], +}) +export class HelmReleaseSummaryTabComponent implements OnDestroy { + // Confirmation dialogs + deleteReleaseConfirmation: ConfirmationDialogConfig; + + private busyDeletingSubject = new ReplaySubject(); + public isBusy$: Observable; + public hasResources$: Observable; + public hasAllResources$: Observable; + private readonly DEFAULT_LOADING_MESSAGE = 'Retrieving Release Details'; + public loadingMessage = this.DEFAULT_LOADING_MESSAGE; + + public podsChartData = []; + public containersChartData = []; + + private successChartColor = '#4DD3A7'; + private completedChartColour = '#7aa3e5'; + + public path: string; + + public hasUpgrade$: Observable; + + // Can we upgrade? Yes as long as the Helm Chart can be found + public canUpgrade$: Observable; + + public podChartColors = [ + { + name: 'Running', + value: this.successChartColor + }, + { + name: 'Completed', + value: this.completedChartColour + }, + ]; + + public containersChartColors = [ + { + name: 'Ready', + value: this.successChartColor + }, + { + name: 'Not Ready', + value: '#E7727D' + } + ]; + + // Blue: #00B2E2 + // Yellow: #FFC107 + + private deleted = false; + public chartData$: Observable; + public resources$: Observable; + + // Cached analysis report + private analysisReport; + + private analysisReportUpdated = new Subject(); + private analysisReportUpdated$ = this.analysisReportUpdated.pipe(startWith(null), distinctUntilChanged()); + + constructor( + private componentFactoryResolver: ComponentFactoryResolver, + public helmReleaseHelper: HelmReleaseHelperService, + private store: Store, + private confirmDialog: ConfirmationDialogService, + private httpClient: HttpClient, + private snackbarService: SnackBarService, + public analyzerService: KubernetesAnalysisService, + private previewPanel: SidePanelService, + ) { + this.isBusy$ = combineLatest([ + this.helmReleaseHelper.isFetching$, + this.busyDeletingSubject.asObservable().pipe( + startWith(false) + ) + ]).pipe( + map(([isFetching, isDeleting]) => isFetching || isDeleting), + startWith(true) + ); + + this.path = `${this.helmReleaseHelper.namespace}/${this.helmReleaseHelper.releaseTitle}`; + + this.chartData$ = this.helmReleaseHelper.fetchReleaseChartStats().pipe( + distinctUntilChanged(), + map(chartData => ({ + ...chartData, + containersChartData: chartData.containersChartData.sort((a, b) => a.name.localeCompare(b.name)), + podsChartData: chartData.podsChartData.sort((a, b) => a.name.localeCompare(b.name)) + }) + ) + ); + + this.hasUpgrade$ = this.helmReleaseHelper.hasUpgrade().pipe(map(v => v ? v.version : null)); + + // Can upgrade if the Chart is available + this.canUpgrade$ = this.helmReleaseHelper.hasUpgrade(true).pipe(map(v => !!v)); + + this.resources$ = combineLatest( + this.helmReleaseHelper.fetchReleaseGraph(), + this.analysisReportUpdated$ + ).pipe( + map(([graph,]) => { + const resources = {}; + // Collect the resources + Object.values(graph.nodes).forEach((node: any) => { + if (!resources[node.data.kind]) { + resources[node.data.kind] = { + kind: node.data.kind, + label: `${node.data.kind}s`, + count: 0, + statuses: [], + icon: getIcon(node.data.kind) + }; + } + resources[node.data.kind].count++; + resources[node.data.kind].statuses.push(node.data.status); + }); + this.applyAnalysis(resources, this.analysisReport); + return Object.values(resources).sort((a: any, b: any) => a.kind.localeCompare(b.kind)); + }), + publishReplay(1), + refCount() + ); + + + this.hasResources$ = combineLatest([ + this.chartData$, + this.resources$ + ]).pipe( + map(([chartData, resources]) => !!chartData && !!resources) + ); + + this.hasAllResources$ = combineLatest([ + this.resources$, + this.hasResources$ + ]).pipe( + map(([resources, hasSome]) => hasSome && resources && resources.length > 0) + ); + + this.deleteReleaseConfirmation = new ConfirmationDialogConfig( + `Delete Workload`, + { + textToMatch: helmReleaseHelper.releaseTitle + }, + 'Delete' + ); + + this.hasAllResources$ = combineLatest([ + this.resources$, + this.hasResources$ + ]).pipe( + map(([resources, hasSome]) => hasSome && resources && resources.length > 0) + ); + } + + public analysisChanged(report) { + if (report === null) { + // No report selected + this.analysisReport = null; + this.analysisReportUpdated.next(''); + } else { + this.analyzerService.getByID(this.helmReleaseHelper.endpointGuid, report.id).subscribe(results => { + this.analysisReport = results; + this.analysisReportUpdated.next(report.id); + }); + } + } + + private startDelete() { + this.loadingMessage = 'Deleting Release'; + this.busyDeletingSubject.next(true); + } + + private endDelete() { + this.loadingMessage = this.DEFAULT_LOADING_MESSAGE; + this.busyDeletingSubject.next(false); + } + + private completeDelete() { + this.deleted = true; + this.endDelete(); + } + + + public deleteRelease() { + this.confirmDialog.open(this.deleteReleaseConfirmation, () => { + // Make the http request to delete the release + const endpointAndName = this.helmReleaseHelper.guid.replace(':', '/').replace(':', '/'); + this.startDelete(); + this.httpClient.delete(`/pp/v1/helm/releases/${endpointAndName}`).subscribe({ + error: (err: any) => { + this.endDelete(); + this.snackbarService.show('Failed to delete release', 'Close'); + console.error('Failed to delete release: ', err); + }, + complete: () => { + const action = workloadsEntityCatalog.release.actions.getMultiple(); + this.store.dispatch(new ClearPaginationOfType(action)); + this.completeDelete(); + this.store.dispatch(new RouterNav({ path: ['./workloads'] })); + } + }); + }); + } + + ngOnDestroy() { + if (this.deleted) { + this.snackbarService.hide(); + } + } + + public createNamespaceLink(namespace: string): string[] { + return [ + `/kubernetes`, + this.helmReleaseHelper.endpointGuid, + `namespaces`, + namespace + ]; + } + + public createClusterLink(): string[] { + return [ + `/kubernetes`, + this.helmReleaseHelper.endpointGuid, + ]; + } + + public getClusterName(): Observable { + return this.store.select(endpointsEntityRequestDataSelector(this.helmReleaseHelper.endpointGuid)).pipe( + filter(e => !!e), + map(e => e.name), + first() + ); + } + + private applyAnalysis(resources, report) { + // Clear out existing alerts for all resources + Object.values(resources).forEach((resource: any) => resource.alerts = []); + + if (report && Object.keys(resources).length > 0) { + Object.values(report.alerts).forEach((group: ResourceAlert[]) => { + group.forEach(alert => { + // Can we find a corresponding group in the resources? + const res = Object.keys(resources).find((i) => i.toLowerCase() === alert.kind); + if (res) { + const resItem = resources[res]; + if (resItem) { + resItem.alerts.push(alert); + } + } + }); + }); + } + } + + public showAlerts(alerts, resource) { + this.previewPanel.show( + ResourceAlertPreviewComponent, + { + resource, + alerts, + }, + this.componentFactoryResolver + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.html new file mode 100644 index 0000000000..49f9d181c6 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.html @@ -0,0 +1,26 @@ + + + + User + Chart + Combined + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.spec.ts new file mode 100644 index 0000000000..f7dc5b580b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.spec.ts @@ -0,0 +1,37 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../../../../core/src/tab-nav.service'; +import { HelmReleaseProviders, KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { HelmReleaseValuesTabComponent } from './helm-release-values-tab.component'; + +describe('HelmReleaseValuesTabComponent', () => { + let component: HelmReleaseValuesTabComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [ + HelmReleaseValuesTabComponent + ], + providers: [ + // ...HelmBaseTestProviders, + ...HelmReleaseProviders, + TabNavService + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HelmReleaseValuesTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.ts new file mode 100644 index 0000000000..331d484ea3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/tabs/helm-release-values-tab/helm-release-values-tab.component.ts @@ -0,0 +1,74 @@ +import { Component } from '@angular/core'; +import { combineLatest, Observable, Subject } from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; + +import { HelmReleaseHelperService } from '../helm-release-helper.service'; + +@Component({ + selector: 'app-helm-release-values-tab', + templateUrl: './helm-release-values-tab.component.html', + styleUrls: ['./helm-release-values-tab.component.scss'] +}) +export class HelmReleaseValuesTabComponent { + + public values$: Observable; + + private viewTypeSubject = new Subject(); + + public viewType$: Observable; + + constructor(public helmReleaseHelper: HelmReleaseHelperService) { + + this.viewType$ = this.viewTypeSubject.asObservable().pipe(startWith('user')); + + this.values$ = combineLatest( + this.viewType$, + helmReleaseHelper.release$ + ).pipe( + map(([vtype, release]) => { + switch (vtype) { + case 'user': + return release.config || {}; + case 'combined': + const chart = release.chart.values || {}; + const user = release.config || {}; + const target = {}; + return this.mergeDeep(target, chart, user); + default: + return release.chart.values || {}; + } + }) + ); + } + + public viewTypeChange(viewType: string) { + this.viewTypeSubject.next(viewType); + } + + private isObject(item) { + return (item && typeof item === 'object' && !Array.isArray(item)); + } + + private mergeDeep(target, ...sources) { + if (!sources.length) { + return target; + } + const source = sources.shift(); + + if (this.isObject(target) && this.isObject(source)) { + for (const key in source) { + if (this.isObject(source[key])) { + if (!target[key]) { + Object.assign(target, { [key]: {} }); + } + this.mergeDeep(target[key], source[key]); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return this.mergeDeep(target, ...sources); + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.html new file mode 100644 index 0000000000..70f1d6801e --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.html @@ -0,0 +1,3 @@ + + Live Updates + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.scss new file mode 100644 index 0000000000..dc5ff7f622 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.scss @@ -0,0 +1,3 @@ +mat-slide-toggle { + font-size: 15px; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.spec.ts new file mode 100644 index 0000000000..df86d31d29 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.spec.ts @@ -0,0 +1,37 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HelmReleaseGuidMock } from '../../../../helm/helm-testing.module'; +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { HelmReleaseSocketService } from '../helm-release-tab-base/helm-release-socket-service'; +import { HelmReleaseHelperService } from '../tabs/helm-release-helper.service'; +import { WorkloadLiveReloadComponent } from './workload-live-reload.component'; + +describe('WorkloadLiveReloadComponent', () => { + let component: WorkloadLiveReloadComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [WorkloadLiveReloadComponent], + providers: [ + HelmReleaseSocketService, + HelmReleaseHelperService, + HelmReleaseGuidMock + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WorkloadLiveReloadComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.ts new file mode 100644 index 0000000000..35dd56caad --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/release/workload-live-reload/workload-live-reload.component.ts @@ -0,0 +1,25 @@ +import { Component, OnInit } from '@angular/core'; + +import { HelmReleaseSocketService } from '../helm-release-tab-base/helm-release-socket-service'; + +@Component({ + selector: 'app-workload-live-reload', + templateUrl: './workload-live-reload.component.html', + styleUrls: ['./workload-live-reload.component.scss'] +}) +export class WorkloadLiveReloadComponent implements OnInit { + public checked = false; + + constructor( + private socketService: HelmReleaseSocketService + ) { } + + ngOnInit(): void { + this.checked = this.socketService.isStarted(); + } + + public onChange(event) { + this.socketService.pause(!event.checked) + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.html new file mode 100644 index 0000000000..bae593c115 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.html @@ -0,0 +1,17 @@ + +

Workloads

+
+ + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.spec.ts new file mode 100644 index 0000000000..ad908e0608 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.spec.ts @@ -0,0 +1,37 @@ +import { DatePipe } from '@angular/common'; +import { async, TestBed } from '@angular/core/testing'; + +import { TabNavService } from '../../../../../core/src/tab-nav.service'; +import { KubernetesBaseTestModules } from '../../kubernetes.testing.module'; +import { HelmReleaseHelperService } from '../release/tabs/helm-release-helper.service'; +import { HelmReleasesTabComponent } from './releases-tab.component'; + +describe('ReleasesTabComponent', () => { + let component: HelmReleasesTabComponent; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ...KubernetesBaseTestModules + ], + declarations: [HelmReleasesTabComponent], + providers: [ + DatePipe, + HelmReleaseHelperService, + TabNavService + ] + + }) + .compileComponents(); + })); + + beforeEach(() => { + const fixture = TestBed.createComponent(HelmReleasesTabComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.ts new file mode 100644 index 0000000000..45a52b3142 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/releases-tab/releases-tab.component.ts @@ -0,0 +1,35 @@ +import { Component, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from 'frontend/packages/store/src/app-state'; +import { endpointOfTypeSelector } from 'frontend/packages/store/src/selectors/endpoint.selectors'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { ListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { HELM_ENDPOINT_TYPE } from '../../../helm/helm-entity-factory'; +import { HelmReleasesListConfig } from '../list-types/helm-releases-list-config.service'; +import { KubernetesNamespacesFilterService } from '../list-types/kube-namespaces-filter-config.service'; + +@Component({ + selector: 'app-releases-tab', + templateUrl: './releases-tab.component.html', + styleUrls: ['./releases-tab.component.scss'], + providers: [ + { + provide: ListConfig, + useClass: HelmReleasesListConfig, + }, + KubernetesNamespacesFilterService, + ] +}) +export class HelmReleasesTabComponent implements OnInit { + public helmIds$: Observable; + + constructor(private store: Store) { } + + ngOnInit() { + this.helmIds$ = this.store.select(endpointOfTypeSelector(HELM_ENDPOINT_TYPE)).pipe( + map(endpoints => Object.keys(endpoints)), + ); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workload-action-builders.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workload-action-builders.ts new file mode 100644 index 0000000000..35ea3c8c30 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workload-action-builders.ts @@ -0,0 +1,72 @@ +import { OrchestratedActionBuilders } from '../../../../../store/src/entity-catalog/action-orchestrator/action-orchestrator'; +import { HelmUpgradeValues } from '../../../helm/store/helm.types'; +import { + GetHelmRelease, + GetHelmReleaseGraph, + GetHelmReleaseHistory, + GetHelmReleaseResource, + GetHelmReleases, + UpgradeHelmRelease, +} from './workloads.actions'; + +export interface WorkloadReleaseBuilders extends OrchestratedActionBuilders { + get: ( + title: string, + endpointGuid: string, + extraArgs: { namespace: string, } + ) => GetHelmRelease; + getMultiple: () => GetHelmReleases; + upgrade: ( + title: string, + endpointGuid: string, + namespace: string, + values: HelmUpgradeValues) => UpgradeHelmRelease; +} + +export const workloadReleaseBuilders: WorkloadReleaseBuilders = { + get: (title: string, endpointGuid: string, { namespace }: { namespace: string, }) => { + return new GetHelmRelease(endpointGuid, namespace, title); + }, + getMultiple: () => new GetHelmReleases(), + upgrade: ( + title: string, + endpointGuid: string, + namespace: string, + values: HelmUpgradeValues) => new UpgradeHelmRelease(title, endpointGuid, namespace, values) +}; + +export interface WorkloadGraphBuilders extends OrchestratedActionBuilders { + get: ( + releaseTitle: string, + endpointGuid: string + ) => GetHelmReleaseGraph; +} + +export const workloadGraphBuilders: WorkloadGraphBuilders = { + get: (releaseTitle: string, endpointGuid: string) => new GetHelmReleaseGraph(endpointGuid, releaseTitle) +}; + +export interface WorkloadResourceBuilders extends OrchestratedActionBuilders { + get: ( + releaseTitle: string, + endpointGuid: string, + ) => GetHelmReleaseResource; +} + +export const workloadResourceBuilders: WorkloadResourceBuilders = { + get: (releaseTitle: string, endpointGuid: string) => new GetHelmReleaseResource(endpointGuid, releaseTitle) +}; + +export interface WorkloadResourceHistoryBuilders extends OrchestratedActionBuilders { + get: ( + releaseTitle: string, + endpointGuid: string, + extraArgs: { namespace: string, } + ) => GetHelmReleaseHistory; +} + +export const workloadResourceHistoryBuilders: WorkloadResourceHistoryBuilders = { + get: (releaseTitle: string, endpointGuid: string, { namespace }: { namespace: string, }) => + new GetHelmReleaseHistory(endpointGuid, namespace, releaseTitle) +}; + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads-entity-factory.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads-entity-factory.ts new file mode 100644 index 0000000000..afd43b3e33 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads-entity-factory.ts @@ -0,0 +1,64 @@ +import { EntitySchema } from '../../../../../store/src/helpers/entity-schema'; +import { addKubernetesEntitySchema, KubernetesEntitySchema } from '../../kubernetes-entity-factory'; +import { HelmRelease, HelmReleaseGraph, HelmReleaseResources } from '../workload.types'; + +export const helmReleaseEntityKey = 'helmRelease'; +export const helmReleasePodEntityType = 'helmReleasePod'; +export const helmReleaseServiceEntityType = 'helmReleaseService'; +export const helmReleaseGraphEntityType = 'helmReleaseGraph'; +export const helmReleaseResourceEntityType = 'helmReleaseResource'; +export const helmReleaseHistoryEntityType = 'helmReleaseHistory'; + +const separator = ':'; +export const getHelmReleaseDetailsFromGuid = (guid: string) => { + const parts = guid.split(separator); + return { + endpointId: parts[0], + namespace: parts[1], + releaseTitle: parts[2] + }; +}; +export const getHelmReleaseId = (endpointId: string, namespace: string, name: string) => + `${endpointId}${separator}${namespace}${separator}${name}`; +export const getHelmReleaseIdByObj = (entity: HelmRelease) => getHelmReleaseId(entity.endpointId, entity.namespace, entity.name); +export const getHelmReleaseGraphId = (endpointId: string, releaseTitle: string) => `${endpointId}${separator}${releaseTitle}`; +export const getHelmReleaseGraphIdByObj = (entity: HelmReleaseGraph) => getHelmReleaseGraphId(entity.endpointId, entity.releaseTitle); +export const getHelmReleaseResourceId = (endpointId: string, releaseTitle: string) => `${endpointId}${separator}${releaseTitle}`; +export const getHelmReleaseResourceIdByObj = (entity: HelmReleaseResources) => + getHelmReleaseResourceId(entity.endpointId, entity.releaseTitle); + +const entityCache: { + [key: string]: EntitySchema, +} = {}; + +entityCache[helmReleaseEntityKey] = new KubernetesEntitySchema( + helmReleaseEntityKey, + {}, + { idAttribute: getHelmReleaseIdByObj } +); + +entityCache[helmReleaseGraphEntityType] = new KubernetesEntitySchema( + helmReleaseGraphEntityType, + {}, + { idAttribute: getHelmReleaseGraphIdByObj } +); + +entityCache[helmReleaseResourceEntityType] = new KubernetesEntitySchema( + helmReleaseResourceEntityType, + {}, + { idAttribute: getHelmReleaseResourceIdByObj } +); + +entityCache[helmReleaseHistoryEntityType] = new KubernetesEntitySchema( + helmReleaseHistoryEntityType, + {}, + { idAttribute: getHelmReleaseResourceIdByObj } +); + +Object.entries(entityCache).forEach(([key, workloadSchema]) => addKubernetesEntitySchema(key, workloadSchema)); + + +export const createHelmReleaseEntities = (): { [cacheName: string]: EntitySchema; } => { + return entityCache; +}; + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads-entity-generator.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads-entity-generator.ts new file mode 100644 index 0000000000..7bc1162555 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads-entity-generator.ts @@ -0,0 +1,98 @@ +import { + StratosBaseCatalogEntity, + StratosCatalogEntity, +} from '../../../../../store/src/entity-catalog/entity-catalog-entity/entity-catalog-entity'; +import { StratosEndpointExtensionDefinition } from '../../../../../store/src/entity-catalog/entity-catalog.types'; +import { IFavoriteMetadata } from '../../../../../store/src/types/user-favorites.types'; +import { kubernetesEntityFactory } from '../../kubernetes-entity-factory'; +import { HelmRelease, HelmReleaseGraph, HelmReleaseResources } from '../workload.types'; +import { workloadsEntityCatalog } from '../workloads-entity-catalog'; +import { HelmReleaseHistory } from './../workload.types'; +import { + WorkloadGraphBuilders, + workloadGraphBuilders, + WorkloadReleaseBuilders, + workloadReleaseBuilders, + WorkloadResourceBuilders, + workloadResourceBuilders, + WorkloadResourceHistoryBuilders, + workloadResourceHistoryBuilders, +} from './workload-action-builders'; +import { + helmReleaseEntityKey, + helmReleaseGraphEntityType, + helmReleaseHistoryEntityType, + helmReleaseResourceEntityType, +} from './workloads-entity-factory'; + + +export function generateWorkloadsEntities(endpointDefinition: StratosEndpointExtensionDefinition): StratosBaseCatalogEntity[] { + + return [ + generateReleaseEntity(endpointDefinition), + generateReleaseGraphEntity(endpointDefinition), + generateReleaseResourceEntity(endpointDefinition), + generateReleaseHistoryEntity(endpointDefinition) + ]; +} + +function generateReleaseEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition = { + type: helmReleaseEntityKey, + schema: kubernetesEntityFactory(helmReleaseEntityKey), + endpoint: endpointDefinition + }; + workloadsEntityCatalog.release = new StratosCatalogEntity( + definition, + { + actionBuilders: workloadReleaseBuilders + } + ); + return workloadsEntityCatalog.release; +} + +function generateReleaseGraphEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition = { + type: helmReleaseGraphEntityType, + schema: kubernetesEntityFactory(helmReleaseGraphEntityType), + endpoint: endpointDefinition + }; + workloadsEntityCatalog.graph = new StratosCatalogEntity( + definition, + { + actionBuilders: workloadGraphBuilders + } + ); + return workloadsEntityCatalog.graph; +} + +function generateReleaseResourceEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition = { + type: helmReleaseResourceEntityType, + schema: kubernetesEntityFactory(helmReleaseResourceEntityType), + endpoint: endpointDefinition + }; + workloadsEntityCatalog.resource = new StratosCatalogEntity( + definition, + { + actionBuilders: workloadResourceBuilders + } + ); + return workloadsEntityCatalog.resource; +} + +function generateReleaseHistoryEntity(endpointDefinition: StratosEndpointExtensionDefinition) { + const definition = { + type: helmReleaseHistoryEntityType, + schema: kubernetesEntityFactory(helmReleaseHistoryEntityType), + endpoint: endpointDefinition + }; + workloadsEntityCatalog.history = new StratosCatalogEntity( + definition, + { + actionBuilders: workloadResourceHistoryBuilders + } + ); + return workloadsEntityCatalog.history; +} + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.actions.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.actions.ts new file mode 100644 index 0000000000..d80ba31beb --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.actions.ts @@ -0,0 +1,220 @@ +import { EntityRequestAction } from 'frontend/packages/store/src/types/request.types'; + +import { MonocularPaginationAction } from '../../../helm/store/helm.actions'; +import { HelmUpgradeValues } from '../../../helm/store/helm.types'; +import { + KUBERNETES_ENDPOINT_TYPE, + kubernetesEntityFactory, + kubernetesPodsEntityType, + kubernetesServicesEntityType, +} from '../../kubernetes-entity-factory'; +import { KubePaginationAction } from '../../store/kubernetes.actions'; +import { + getHelmReleaseGraphId, + getHelmReleaseId, + getHelmReleaseResourceId, + helmReleaseEntityKey, + helmReleaseGraphEntityType, + helmReleaseHistoryEntityType, + helmReleaseResourceEntityType, +} from './workloads-entity-factory'; + +export const GET_HELM_RELEASES = '[Helm] Get Releases'; +export const GET_HELM_RELEASES_SUCCESS = '[Helm] Get Releases Success'; +export const GET_HELM_RELEASES_FAILURE = '[Helm] Get Releases Failure'; + +export const GET_HELM_RELEASE = '[Helm] Get Release Status'; +export const GET_HELM_RELEASE_SUCCESS = '[Helm] Get Release Status Success'; +export const GET_HELM_RELEASE_FAILURE = '[Helm] Get Release Status Failure'; + +export const GET_HELM_RELEASE_PODS = '[Helm] Get Release Pods'; +export const GET_HELM_RELEASE_PODS_SUCCESS = '[Helm] Get Release Pods Success'; +export const GET_HELM_RELEASE_PODS_FAILURE = '[Helm] Get Release Pods Failure'; + +export const GET_HELM_RELEASE_SERVICES = '[Helm] Get Release Services'; +export const GET_HELM_RELEASE_SERVICES_SUCCESS = '[Helm] Get Release Services Success'; +export const GET_HELM_RELEASE_SERVICES_FAILURE = '[Helm] Get Release Services Failure'; + +export const UPDATE_HELM_RELEASE = '[Helm] Update Release'; +export const UPDATE_HELM_RELEASE_SUCCESS = '[Helm] Update Release Success'; +export const UPDATE_HELM_RELEASE_FAILURE = '[Helm] Update Release Failure'; + +export const GET_HELM_RELEASE_HISTORY = '[Helm] Get Release History'; +export const GET_HELM_RELEASE_HISTORY_SUCCESS = '[Helm] Get Release History Success'; +export const GET_HELM_RELEASE_HISTORY_FAILURE = '[Helm] Get Release History Failure'; + +export const UPGRADE_HELM_RELEASE = '[Helm] Upgrade Release'; +export const UPGRADE_HELM_RELEASE_SUCCESS = '[Helm] Upgrade Release Success'; +export const UPGRADE_HELM_RELEASE_FAILURE = '[Helm] Upgrade Release Failure'; + +interface HelmReleaseSingleEntity extends EntityRequestAction { + guid: string; +} + +export class GetHelmReleases implements MonocularPaginationAction { + constructor() { + this.paginationKey = 'helm-releases'; + } + type = GET_HELM_RELEASES; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entityType = helmReleaseEntityKey; + entity = [kubernetesEntityFactory(helmReleaseEntityKey)]; + actions = [ + GET_HELM_RELEASES, + GET_HELM_RELEASES_SUCCESS, + GET_HELM_RELEASES_FAILURE + ]; + paginationKey: string; + initialParams = { + 'order-direction': 'desc', + 'order-direction-field': 'name', + }; +} + +export class GetHelmRelease implements HelmReleaseSingleEntity { + guid: string; + constructor( + public endpointGuid: string, + public namespace: string, + public releaseTitle: string + ) { + this.guid = getHelmReleaseId(endpointGuid, namespace, releaseTitle); + } + type = GET_HELM_RELEASE; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = kubernetesEntityFactory(helmReleaseEntityKey); + entityType = helmReleaseEntityKey; + actions = [ + GET_HELM_RELEASE, + GET_HELM_RELEASE_SUCCESS, + GET_HELM_RELEASE_FAILURE + ]; +} + +export class GetHelmReleaseGraph implements HelmReleaseSingleEntity { + guid: string; + constructor( + public endpointGuid: string, + public releaseTitle: string + ) { + this.guid = getHelmReleaseGraphId(endpointGuid, releaseTitle); + } + type = this.constructor.name; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = kubernetesEntityFactory(helmReleaseGraphEntityType); + entityType = helmReleaseGraphEntityType; + actions = [this.type]; +} + +export class GetHelmReleaseResource implements HelmReleaseSingleEntity { + constructor( + public endpointGuid: string, + public releaseTitle: string + ) { + this.guid = getHelmReleaseResourceId(this.endpointGuid, this.releaseTitle); + } + type = this.constructor.name; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = kubernetesEntityFactory(helmReleaseResourceEntityType); + entityType = helmReleaseResourceEntityType; + actions = [this.type]; + guid: string; +} + +/** + * Won't fetch pods, used to push/retrieve data from store + */ +export class GetHelmReleasePods implements KubePaginationAction { + constructor( + public kubeGuid: string, + public releaseTitle: string + ) { + this.paginationKey = `${kubeGuid}/${releaseTitle}/pods`; + } + type = GET_HELM_RELEASE_PODS; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entityType = kubernetesPodsEntityType; + entity = [kubernetesEntityFactory(kubernetesPodsEntityType)]; + actions = [ + GET_HELM_RELEASE_PODS, + GET_HELM_RELEASE_PODS_SUCCESS, + GET_HELM_RELEASE_PODS_FAILURE + ]; + paginationKey: string; + initialParams = { + 'order-direction': 'desc', + 'order-direction-field': 'name', + }; + flattenPagination = true; +} + + +/** + * Won't fetch services, used to push/retrieve data from store + */ +export class GetHelmReleaseServices implements KubePaginationAction { + constructor( + public kubeGuid: string, + public releaseTitle: string + ) { + this.paginationKey = `${kubeGuid}/${releaseTitle}/services`; + } + type = GET_HELM_RELEASE_SERVICES; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entityType = kubernetesServicesEntityType; + entity = [kubernetesEntityFactory(kubernetesServicesEntityType)]; + actions = [ + GET_HELM_RELEASE_SERVICES, + GET_HELM_RELEASE_SERVICES_SUCCESS, + GET_HELM_RELEASE_SERVICES_FAILURE + ]; + paginationKey: string; + initialParams = { + 'order-direction': 'desc', + 'order-direction-field': 'name', + }; + flattenPagination = true; +} + +export class GetHelmReleaseHistory implements HelmReleaseSingleEntity { + constructor( + public endpointGuid: string, + public namespace: string, + public releaseTitle: string + ) { + this.guid = getHelmReleaseId(this.endpointGuid, this.namespace, this.releaseTitle); + } + type = GET_HELM_RELEASE_HISTORY; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = kubernetesEntityFactory(helmReleaseHistoryEntityType); + entityType = helmReleaseHistoryEntityType; + actions = [ + GET_HELM_RELEASE_HISTORY, + GET_HELM_RELEASE_HISTORY_SUCCESS, + GET_HELM_RELEASE_HISTORY_FAILURE + ]; + + guid: string; +} + +export class UpgradeHelmRelease implements HelmReleaseSingleEntity { + guid: string; + type = UPGRADE_HELM_RELEASE; + endpointType = KUBERNETES_ENDPOINT_TYPE; + entity = kubernetesEntityFactory(helmReleaseEntityKey); + entityType = helmReleaseEntityKey; + constructor( + public releaseTitle: string, + public endpointGuid: string, + public namespace: string, + public values: HelmUpgradeValues + ) { + this.guid = getHelmReleaseId(this.endpointGuid, this.namespace, this.releaseTitle); + } + updatingKey = 'upgrading'; + actions = [ + UPGRADE_HELM_RELEASE, + UPGRADE_HELM_RELEASE_SUCCESS, + UPGRADE_HELM_RELEASE_FAILURE + ]; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.effects.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.effects.ts new file mode 100644 index 0000000000..58d77f01f7 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.effects.ts @@ -0,0 +1,216 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Action, Store } from '@ngrx/store'; +import { environment } from 'frontend/packages/core/src/environments/environment'; +import { Observable } from 'rxjs'; +import { catchError, flatMap, mergeMap } from 'rxjs/operators'; + +import { + AppState, + entityCatalog, + NormalizedResponse, + WrapperRequestActionSuccess, +} from '../../../../../store/src/public-api'; +import { ApiRequestTypes } from '../../../../../store/src/reducers/api-request-reducer/request-helpers'; +import { + EntityRequestAction, + StartRequestAction, + WrapperRequestActionFailed, +} from '../../../../../store/src/types/request.types'; +import { HelmEffects } from '../../../helm/store/helm.effects'; +import { HelmRelease, HelmReleaseHistory } from '../workload.types'; +import { workloadsEntityCatalog } from './../workloads-entity-catalog'; +import { getHelmReleaseId } from './workloads-entity-factory'; +import { + GET_HELM_RELEASE, + GET_HELM_RELEASE_HISTORY, + GET_HELM_RELEASES, + GetHelmRelease, + GetHelmReleaseHistory, + GetHelmReleases, + UPGRADE_HELM_RELEASE, + UpgradeHelmRelease, +} from './workloads.actions'; + +@Injectable() +export class WorkloadsEffects { + + constructor( + private httpClient: HttpClient, + private actions$: Actions, + private store: Store + ) { } + + proxyAPIVersion = environment.proxyAPIVersion; + + + @Effect() + fetchReleases$ = this.actions$.pipe( + ofType(GET_HELM_RELEASES), + flatMap(action => { + const entityKey = entityCatalog.getEntityKey(action); + return this.makeRequest(action, `/pp/${this.proxyAPIVersion}/helm/releases`, (response) => { + const processedData = { + entities: { [entityKey]: {} }, + result: [] + } as NormalizedResponse; + + // Go through each endpoint ID + Object.keys(response).forEach(endpointId => { + const endpointData = response[endpointId]; + if (!endpointData) { + return; + } + endpointData.forEach((data) => { + const helmRelease = this.mapHelmRelease(data, endpointId, getHelmReleaseId(endpointId, data.namespace, data.name)); + processedData.entities[entityKey][helmRelease.guid] = helmRelease; + processedData.result.push(helmRelease.guid); + }); + }); + return processedData; + }, []); + }) + ); + + @Effect() + fetchHelmRelease$ = this.actions$.pipe( + ofType(GET_HELM_RELEASE), + flatMap(action => { + const entityKey = entityCatalog.getEntityKey(action); + return this.makeRequest( + action, + `/pp/${this.proxyAPIVersion}/helm/releases/${action.endpointGuid}/${action.namespace}/${action.releaseTitle}`, + (response) => { + const processedData = { + entities: { [entityKey]: {} }, + result: [] + } as NormalizedResponse; + + // Go through each endpoint ID + processedData.entities[entityKey][action.guid] = this.mapHelmRelease(response, action.endpointGuid, action.guid); + processedData.result.push(action.guid); + return processedData; + }, [action.endpointGuid]); + }) + ); + + @Effect() + fetchHelmReleaseHistory$ = this.actions$.pipe( + ofType(GET_HELM_RELEASE_HISTORY), + flatMap(action => { + const entityKey = entityCatalog.getEntityKey(action); + return this.makeRequest( + action, + `/pp/${this.proxyAPIVersion}/helm/releases/${action.endpointGuid}/${action.namespace}/${action.releaseTitle}/history`, + (response) => { + const processedData = { + entities: { [entityKey]: {} }, + result: [] + } as NormalizedResponse; + + const data: HelmReleaseHistory = { + endpointId: action.endpointGuid, + releaseTitle: action.releaseTitle, + revisions: [] + }; + + for (const version of response) { + data.revisions.push({ + ...version.info, + revision: version.version, + chart: version.chart.metadata, + values: version.chart.values + }); + } + // Store the data against the release guid + processedData.entities[entityKey][action.guid] = data; + processedData.result.push(action.guid); + return processedData; + }, [action.endpointGuid]); + }) + ); + + @Effect() + helmUpgrade$ = this.actions$.pipe( + ofType(UPGRADE_HELM_RELEASE), + flatMap(action => { + const requestType: ApiRequestTypes = 'update'; + const url = `/pp/v1//helm/releases/${action.endpointGuid}/${action.namespace}/${action.releaseTitle}`; + this.store.dispatch(new StartRequestAction(action, requestType)); + // Refresh the workload after upgrade + const fetchAction = workloadsEntityCatalog.release.actions.get(action.releaseTitle, action.endpointGuid, + { namespace: action.namespace }); + return this.httpClient.post(url, action.values).pipe( + mergeMap(() => { + return [ + fetchAction, + new WrapperRequestActionSuccess(null, action) + ]; + }), + catchError(error => { + const { status, message } = HelmEffects.createHelmError(error); + const errorMessage = `Failed to upgrade helm release: ${message}`; + return [ + new WrapperRequestActionFailed(errorMessage, action, requestType, { + endpointIds: [action.endpointGuid], + url: error.url || url, + eventCode: status, + message: errorMessage, + error + }) + ]; + }) + ); + }) + ); + + private mapHelmRelease(data, endpointId, guid: string) { + const helmRelease: HelmRelease = { + ...data, + endpointId + }; + // Release name is unique for an endpoint - for Helm 3, include the namespace + helmRelease.guid = guid; + // Make a note of the guid of the endpoint for the release + helmRelease.status = data.info.status; + helmRelease.lastDeployed = this.mapHelmModifiedDate(data.info.last_deployed); + helmRelease.firstDeployed = this.mapHelmModifiedDate(data.info.first_deployed); + return helmRelease; + } + + private makeRequest( + action: EntityRequestAction, + url: string, + mapResult: (response: any) => NormalizedResponse, + endpointIds: string[] + ): Observable { + this.store.dispatch(new StartRequestAction(action)); + const requestArgs = { + headers: null, + params: null + }; + return this.httpClient.get(url, requestArgs).pipe( + mergeMap((response: any) => [new WrapperRequestActionSuccess(mapResult(response), action)]), + catchError(error => { + const { status, message } = HelmEffects.createHelmError(error); + const errorMessage = `Failed to fetch helm data: ${message}`; + return [ + new WrapperRequestActionFailed(errorMessage, action, 'fetch', { + endpointIds, + url: error.url || url, + eventCode: status, + message: errorMessage, + error + }) + ]; + }) + ); + } + + private mapHelmModifiedDate(date: any) { + let unix = date.seconds * 1000 + date.nanos / 1000; + unix = Math.floor(unix); + return new Date(unix); + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.reducers.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.reducers.ts new file mode 100644 index 0000000000..790325ad9a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.reducers.ts @@ -0,0 +1,13 @@ +import { UPDATE_HELM_RELEASE } from './workloads.actions'; + +const defaultState = {}; + +export function helmReleaseReducer(state = defaultState, action) { + switch (action.type) { + case UPDATE_HELM_RELEASE: + return { + ...state, + }; + + } +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.store.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.store.module.ts new file mode 100644 index 0000000000..5a66223f7b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/store/workloads.store.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { StoreModule } from '@ngrx/store'; + +import { WorkloadsEffects } from './workloads.effects'; +import { helmReleaseReducer } from './workloads.reducers'; + +@NgModule({ + imports: [ + EffectsModule.forFeature([ + WorkloadsEffects + ]), + StoreModule.forFeature('helmRelease', helmReleaseReducer), + ] +}) +export class WorkloadsStoreModule { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/release-version-data-source.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/release-version-data-source.ts new file mode 100644 index 0000000000..6f51797181 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/release-version-data-source.ts @@ -0,0 +1,63 @@ +import { Store } from '@ngrx/store'; +import { Observable, of } from 'rxjs'; + +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { RowState } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source-types'; +import { IListConfig } from '../../../../../core/src/shared/components/list/list.component.types'; +import { PaginationEntityState } from '../../../../../store/src/types/pagination.types'; +import { helmEntityCatalog } from '../../../helm/helm-entity-catalog'; +import { MonocularVersion } from './../../../helm/store/helm.types'; + + +const typeFilterKey = 'versionType'; + + +export class HelmReleaseVersionsDataSource extends ListDataSource { + + private currentVersion: string; + + constructor( + store: Store, + listConfig: IListConfig, + repoName: string, + chartName: string, + version: string, + monocularEndpoint: string, + ) { + const action = helmEntityCatalog.chartVersions.actions.getMultiple(null, null, { + repoName, + chartName, + monocularEndpoint + }); + super({ + store, + action, + schema: action.entity[0], + getRowUniqueId: (object: MonocularVersion) => action.entity[0].getId(object), + paginationKey: action.paginationKey, + isLocal: true, + transformEntities: [ + (entities: MonocularVersion[], paginationState: PaginationEntityState) => this.endpointTypeFilter(entities, paginationState) + ], + listConfig, + }); + + this.currentVersion = version; + this.getRowState = (row: any): Observable => of({ highlighted: row.attributes.version === this.currentVersion }); + } + + + public endpointTypeFilter(entities: MonocularVersion[], paginationState: PaginationEntityState): MonocularVersion[] { + if ( + !paginationState.clientPagination || + !paginationState.clientPagination.filter || + !paginationState.clientPagination.filter.items[typeFilterKey]) { + return entities; + } + + // Filter out development versions if configured + const showAll = paginationState.clientPagination.filter.items[typeFilterKey] === 'all'; + return showAll ? entities : entities.filter(e => e.attributes.version.indexOf('-') === -1); + } +} + diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/release-version-list-config.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/release-version-list-config.ts new file mode 100644 index 0000000000..5ea00c36ca --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/release-version-list-config.ts @@ -0,0 +1,132 @@ +import { Store } from '@ngrx/store'; +import moment from 'moment'; +import { BehaviorSubject, of } from 'rxjs'; +import { first } from 'rxjs/operators'; + +import { ListDataSource } from '../../../../../core/src/shared/components/list/data-sources-controllers/list-data-source'; +import { + TableCellRadioComponent, +} from '../../../../../core/src/shared/components/list/list-table/table-cell-radio/table-cell-radio.component'; +import { ITableColumn } from '../../../../../core/src/shared/components/list/list-table/table.types'; +import { + defaultPaginationPageSizeOptionsTable, + IGlobalListAction, + IListAction, + IListConfig, + IListMultiFilterConfig, + IMultiListAction, + ListViewTypes, +} from '../../../../../core/src/shared/components/list/list.component.types'; +import { MonocularVersion } from '../../../helm/store/helm.types'; +import { HelmReleaseVersionsDataSource } from './release-version-data-source'; + +const typeFilterKey = 'versionType'; + +export class ReleaseUpgradeVersionsListConfig implements IListConfig { + + public versionsDataSource: ListDataSource; + + private multiFiltersConfigs: IListMultiFilterConfig[]; + + getGlobalActions: () => IGlobalListAction[]; + getMultiActions: () => IMultiListAction[]; + getSingleActions: () => IListAction[]; + + columns: Array> = [ + { + columnId: 'radio', + headerCell: () => '', + cellComponent: TableCellRadioComponent, + class: 'table-column-select', + cellFlex: '0 0 60px' + }, + { + columnId: 'version', + headerCell: () => 'Version', + cellFlex: '2', + cellDefinition: { + valuePath: 'attributes.version' + } + }, + { + columnId: 'created', + headerCell: () => 'Created', + cellFlex: '3', + cellDefinition: { + getValue: row => moment(row.attributes.created).format('LLL') + } + }, + { + columnId: 'age', + headerCell: () => 'Age', + cellFlex: '2', + cellDefinition: { + getValue: row => moment(row.attributes.created).fromNow(true) + } + }, + ]; + pageSizeOptions = defaultPaginationPageSizeOptionsTable; + viewType = ListViewTypes.TABLE_ONLY; + + hideRefresh = true; + + getColumns = () => this.columns; + getMultiFiltersConfigs = (): IListMultiFilterConfig[] => this.multiFiltersConfigs; + + getDataSource = () => this.versionsDataSource; + + constructor( + store: Store, + repoName: string, + chartName: string, + version: string, + monocularEndpoint: string + ) { + this.getGlobalActions = () => []; + this.getMultiActions = () => []; + this.getSingleActions = () => []; + + this.versionsDataSource = new HelmReleaseVersionsDataSource(store, this, repoName, chartName, version, monocularEndpoint); + + this.multiFiltersConfigs = [{ + hideAllOption: true, + autoSelectFirst: true, + key: typeFilterKey, + label: 'Endpoint Type', + list$: of([ + { + label: 'Release Versions', + item: {}, + value: 'release' + }, + { + label: 'All Versions', + item: {}, + value: 'all' + } + ]), + loading$: of(false), + select: new BehaviorSubject(undefined) + }]; + + // Auto-select first non-development version + setTimeout(() => { + this.versionsDataSource.page$.pipe(first()).subscribe(rs => { + if (rs && rs.length > 0) { + this.versionsDataSource.selectedRowToggle(this.getFirstNonDevelopmentVersion(rs), false); + } + }); + }, 0); + } + + // Get the first version that is a non-development version (no hypen in the version number) + private getFirstNonDevelopmentVersion(rows: MonocularVersion[]): MonocularVersion { + for (const mv of rows) { + if (mv.attributes.version.indexOf('-') === -1) { + return mv; + } + } + return rows[0]; + } + +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.html b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.html new file mode 100644 index 0000000000..4a3ab48abc --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.html @@ -0,0 +1,18 @@ + + Upgrade Workload + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.scss b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.scss new file mode 100644 index 0000000000..8d5007efa3 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.scss @@ -0,0 +1,59 @@ +:host { + flex: 1; +} +.workload-upgrade { + + &__list { + width: 100%; + } + + &__form { + display: flex; + flex: 1; + flex-direction: column; + } + &__heading { + align-items: center; + display: flex; + } + + &__title { + flex: 1; + font-size: 14px; + } + &__button { + height: 36px; + } +} + +form { + flex: 1; + + mat-checkbox { + display: flex; + height: 23px; + margin-top: 10px; + } +} + +.overrides { + &__yaml { + background-color: rgba(0, 0, 0, .1); + font-family: 'Source Code Pro', monospace; + height: 400px; + } + + &_form { + max-width: 100%; + } + + &_form-field { + flex: 1; + height: 100%; + width: 100%; + } +} + +form.overrides_form { + max-width: 100%; +} \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.spec.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.spec.ts new file mode 100644 index 0000000000..c3151ef19b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MockChartService } from '../../../helm/monocular/shared/services/chart.service.mock'; +import { ChartsService } from '../../../helm/monocular/shared/services/charts.service'; +import { ConfigService } from '../../../helm/monocular/shared/services/config.service'; +import { HelmReleaseProviders, KubeBaseGuidMock } from '../../kubernetes.testing.module'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { WorkloadsBaseTestingModule } from '../workloads.testing.module'; +import { UpgradeReleaseComponent } from './upgrade-release.component'; + + +describe('UpgradeReleaseComponent', () => { + let component: UpgradeReleaseComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ UpgradeReleaseComponent ], + imports: [ + ...WorkloadsBaseTestingModule + ], + providers: [ + KubernetesEndpointService, + KubeBaseGuidMock, + ...HelmReleaseProviders, + { provide: ChartsService, useValue: new MockChartService() }, + { provide: ConfigService, useValue: { appName: 'appName' } }, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(UpgradeReleaseComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.ts new file mode 100644 index 0000000000..5b05e01a51 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/upgrade-release/upgrade-release.component.ts @@ -0,0 +1,161 @@ +import { Component, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { combineLatest, Observable, of } from 'rxjs'; +import { filter, first, map, pairwise, tap } from 'rxjs/operators'; + +import { + StepComponent, + StepOnNextFunction, + StepOnNextResult, +} from '../../../../../core/src/shared/components/stepper/step/step.component'; +import { ActionState } from '../../../../../store/src/reducers/api-request-reducer/types'; +import { ChartsService } from '../../../helm/monocular/shared/services/charts.service'; +import { createMonocularProviders } from '../../../helm/monocular/stratos-monocular-providers.helpers'; +import { stratosMonocularEndpointGuid } from '../../../helm/monocular/stratos-monocular.helper'; +import { HelmUpgradeValues, MonocularVersion } from '../../../helm/store/helm.types'; +import { ChartValuesConfig, ChartValuesEditorComponent } from '../chart-values-editor/chart-values-editor.component'; +import { HelmReleaseHelperService } from '../release/tabs/helm-release-helper.service'; +import { HelmReleaseGuid } from '../workload.types'; +import { workloadsEntityCatalog } from './../workloads-entity-catalog'; +import { ReleaseUpgradeVersionsListConfig } from './release-version-list-config'; + +@Component({ + selector: 'app-upgrade-release', + templateUrl: './upgrade-release.component.html', + styleUrls: ['./upgrade-release.component.scss'], + providers: [ + HelmReleaseHelperService, + { + provide: HelmReleaseGuid, + useFactory: (activatedRoute: ActivatedRoute) => ({ + guid: activatedRoute.snapshot.params.guid + }), + deps: [ + ActivatedRoute + ] + }, + ...createMonocularProviders() + ] +}) +export class UpgradeReleaseComponent { + + @ViewChild('editor', { static: true }) editor: ChartValuesEditorComponent; + + public cancelUrl; + public listConfig: ReleaseUpgradeVersionsListConfig; + public validate$: Observable; + private version: MonocularVersion; + + public config: ChartValuesConfig; + + private monocularEndpointId: string; + + // Future + public showAdvancedOptions = false; + + constructor( + store: Store, + public helper: HelmReleaseHelperService, + private chartsService: ChartsService, + ) { + + this.cancelUrl = `/workloads/${this.helper.guid}`; + + this.helper.hasUpgrade(true).pipe( + filter(c => !!c), + first() + ).subscribe(chart => { + const name = chart.upgrade.name; + const repoName = chart.upgrade.repo.name; + const version = chart.release.chart.metadata.version; + this.listConfig = new ReleaseUpgradeVersionsListConfig(store, repoName, name, version, chart.monocularEndpointId); + this.monocularEndpointId = chart.monocularEndpointId; + + // First step is valid when a version has been selected + this.validate$ = this.listConfig.versionsDataSource.selectedRows$.pipe( + map((rows: Map) => { + if (rows && rows.size === 1) { + this.version = rows.values().next().value; + return true; + } + return false; + }) + ); + }); + } + + // Ensure the editor is resized when the overrides step becomes visible + onEnterOverrides = () => { + this.editor.resizeEditor(); + }; + + // Update the editor with the chosen version when the user moves to the next step + onNext = (): Observable => { + const chart = this.version.relationships.chart.data; + const version = this.version.attributes.version; + const endpointID = this.monocularEndpointId || stratosMonocularEndpointGuid; + + + // Fetch the release metadata so that we have the values used to install the current release + return combineLatest( + [this.helper.release$, this.chartsService.getVersionFromEndpoint(endpointID, chart.repo.name, chart.name, version)] + ).pipe( + first(), + tap(([release, chartVersionDetail]) => { + const schemaUrl = this.chartsService.getChartSchemaURL(chartVersionDetail, chart.name, chart.repo); + this.config = { + schemaUrl, + valuesUrl: `/pp/v1/monocular/values/${endpointID}/${chart.repo.name}/${chart.name}/${version}`, + releaseValues: release.config + }; + }), + map(() => { + return { success: true }; + }) + ); + }; + + // Hide/show the advanced options step + toggleAdvancedOptions() { + this.showAdvancedOptions = !this.showAdvancedOptions; + } + + doUpgrade: StepOnNextFunction = (index: number, step: StepComponent) => { + // If we are showing the advanced options, don't upgrade if we aer not on the last step + if (this.showAdvancedOptions && index === 1) { + return of({ success: true }); + } + + // Add the chart url into the values + const values: HelmUpgradeValues = { + values: JSON.stringify(this.editor.getValues()), + restartPods: false, + chart: { + name: this.version.relationships.chart.data.name, + repo: this.version.relationships.chart.data.repo.name, + version: this.version.attributes.version, + }, + monocularEndpoint: this.monocularEndpointId === stratosMonocularEndpointGuid ? null : this.monocularEndpointId, + chartUrl: this.chartsService.getChartURL(this.version) + }; + + // Make the request + return workloadsEntityCatalog.release.api.upgrade(this.helper.releaseTitle, + this.helper.endpointGuid, this.helper.namespace, values).pipe( + // Wait for result of request + filter(state => !!state), + pairwise(), + filter(([oldVal, newVal]) => (oldVal.busy && !newVal.busy)), + map(([, newVal]) => newVal), + map(result => ({ + success: !result.error, + redirect: !result.error, + redirectPayload: { + path: !result.error ? this.cancelUrl : '' + }, + message: !result.error ? '' : result.message + })) + ); + }; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workload.types.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workload.types.ts new file mode 100644 index 0000000000..321e86b734 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workload.types.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; + +import { KubeAPIResource, KubernetesPod, KubeService, KubeStatus } from '../store/kube.types'; + +export interface HelmRelease { + endpointId: string; + guid: string; + name: string; + namespace: string; + version: string; + status: string; + lastDeployed: Date; + firstDeployed: Date; + info: { + last_deployed: Date; + first_deployed: Date; + notes: string; + status: string; + }; + config: any; + chart: { + values: any; + metadata: { + name: string; + version: string; + icon?: string; + description: string; + sources: string[]; + }; + }; +} + +export interface HelmReleaseEntity { + endpointId: string; + releaseTitle: string; +} + +export interface HelmReleasePod extends HelmReleaseEntity, KubernetesPod { } + +export interface HelmReleaseService extends HelmReleaseEntity, KubeService { } + +export interface HelmReleaseGraph extends HelmReleaseEntity { + nodes: { [key: string]: HelmReleaseGraphNode }; + links: { [key: string]: HelmReleaseGraphLink }; +} + +export interface HelmReleaseGraphNode { + id: string; + label: string; + data: HelmReleaseGraphNodeData +} + +export interface HelmReleaseGraphNodeData { + kind: string, + status: string, + metadata: { + name: string, + namespace: string + } +} + +export interface HelmReleaseGraphLink { + id: string; + label?: string; + source: string; + target: string; +} + +export interface HelmReleaseResources extends HelmReleaseEntity { + data: HelmReleaseResource[], + kind: string +}; + +export interface HelmReleaseRevision { + first_deployed: string; + last_deployed: string; + deleted: boolean; + description: string; + status: string; + revision: number; +} + +export interface HelmReleaseHistory extends HelmReleaseEntity { + revisions: HelmReleaseRevision[], +}; + +export interface HelmReleaseKubeAPIResource extends KubeAPIResource { + apiVersion: string; + kind: string; +} + +export type HelmReleaseResource = HelmReleaseKubeAPIResource | KubeStatus; + +@Injectable() +export class HelmReleaseGuid { + guid: string; +} + +export interface HelmReleaseChartData { + podsChartData: { name: string; value: any; }[]; + containersChartData: { name: string; value: any; }[]; +} diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads-entity-catalog.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads-entity-catalog.ts new file mode 100644 index 0000000000..d202d00092 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads-entity-catalog.ts @@ -0,0 +1,26 @@ +import { StratosCatalogEntity } from '../../../../store/src/entity-catalog/entity-catalog-entity/entity-catalog-entity'; +import { IFavoriteMetadata } from '../../../../store/src/types/user-favorites.types'; +import { + WorkloadGraphBuilders, + WorkloadReleaseBuilders, + WorkloadResourceBuilders, + WorkloadResourceHistoryBuilders, +} from './store/workload-action-builders'; +import { HelmRelease, HelmReleaseGraph, HelmReleaseHistory, HelmReleaseResources } from './workload.types'; + +/** + * A strongly typed collection of Workload Catalog Entities. + * This can be used to access functionality exposed by each specific type, such as get, update, delete, etc + */ +export class WorkloadsEntityCatalog { + release: StratosCatalogEntity; + graph: StratosCatalogEntity; + resource: StratosCatalogEntity; + history: StratosCatalogEntity; +} + +/** + * A strongly typed collection of Workload Catalog Entities. + * This can be used to access functionality exposed by each specific type, such as get, update, delete, etc + */ +export const workloadsEntityCatalog: WorkloadsEntityCatalog = new WorkloadsEntityCatalog(); diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.module.ts new file mode 100644 index 0000000000..1c96bf2606 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.module.ts @@ -0,0 +1,74 @@ +import { CommonModule, DatePipe } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MaterialDesignFrameworkModule } from '@cfstratos/ajsf-material'; +import { NgxGraphModule } from '@swimlane/ngx-graph'; +import { MonacoEditorModule, NgxMonacoEditorConfig } from 'ngx-monaco-editor'; + +import { CoreModule } from '../../../../core/src/core/core.module'; +import { SharedModule } from '../../../../core/src/shared/shared.module'; +import { KubernetesModule } from '../kubernetes.module'; +import { ChartValuesEditorComponent } from './chart-values-editor/chart-values-editor.component'; +import { CreateReleaseComponent } from './create-release/create-release.component'; +import { HelmReleaseCardComponent } from './list-types/helm-release-card/helm-release-card.component'; +import { HelmReleaseTabBaseComponent } from './release/helm-release-tab-base/helm-release-tab-base.component'; +import { + HelmReleaseAnalysisTabComponent, +} from './release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component'; +import { HelmReleaseHistoryTabComponent } from './release/tabs/helm-release-history-tab/helm-release-history-tab.component'; +import { HelmReleaseNotesTabComponent } from './release/tabs/helm-release-notes-tab/helm-release-notes-tab.component'; +import { HelmReleasePodsTabComponent } from './release/tabs/helm-release-pods/helm-release-pods-tab.component'; +import { + HelmReleaseResourceGraphComponent, +} from './release/tabs/helm-release-resource-graph/helm-release-resource-graph.component'; +import { HelmReleaseServicesTabComponent } from './release/tabs/helm-release-services/helm-release-services-tab.component'; +import { HelmReleaseSummaryTabComponent } from './release/tabs/helm-release-summary-tab/helm-release-summary-tab.component'; +import { HelmReleaseValuesTabComponent } from './release/tabs/helm-release-values-tab/helm-release-values-tab.component'; +import { WorkloadLiveReloadComponent } from './release/workload-live-reload/workload-live-reload.component'; +import { HelmReleasesTabComponent } from './releases-tab/releases-tab.component'; +import { WorkloadsStoreModule } from './store/workloads.store.module'; +import { UpgradeReleaseComponent } from './upgrade-release/upgrade-release.component'; +import { WorkloadsRouting } from './workloads.routing'; + +// Default config for the Monaco edfior +const monacoConfig: NgxMonacoEditorConfig = { + baseUrl: '/core/assets', // configure base path for monaco editor + defaultOptions: { scrollBeyondLastLine: false } +}; + +@NgModule({ + imports: [ + CoreModule, + CommonModule, + SharedModule, + WorkloadsStoreModule, + WorkloadsRouting, + NgxGraphModule, + KubernetesModule, + MaterialDesignFrameworkModule, + MonacoEditorModule.forRoot(monacoConfig), + ], + declarations: [ + HelmReleasesTabComponent, + HelmReleaseTabBaseComponent, + HelmReleaseSummaryTabComponent, + HelmReleaseNotesTabComponent, + HelmReleaseValuesTabComponent, + HelmReleasePodsTabComponent, + HelmReleaseServicesTabComponent, + HelmReleaseResourceGraphComponent, + HelmReleaseCardComponent, + HelmReleaseAnalysisTabComponent, + ChartValuesEditorComponent, + CreateReleaseComponent, + WorkloadLiveReloadComponent, + UpgradeReleaseComponent, + HelmReleaseHistoryTabComponent, + ], + entryComponents: [ + HelmReleaseCardComponent + ], + providers: [ + DatePipe, + ] +}) +export class WorkloadsModule { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.routing.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.routing.ts new file mode 100644 index 0000000000..5b91cda06a --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.routing.ts @@ -0,0 +1,68 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { NgxChartsModule } from '@swimlane/ngx-charts'; + +import { CreateReleaseComponent } from './create-release/create-release.component'; +import { HelmReleaseTabBaseComponent } from './release/helm-release-tab-base/helm-release-tab-base.component'; +import { + HelmReleaseAnalysisTabComponent, +} from './release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component'; +import { HelmReleaseHistoryTabComponent } from './release/tabs/helm-release-history-tab/helm-release-history-tab.component'; +import { HelmReleaseNotesTabComponent } from './release/tabs/helm-release-notes-tab/helm-release-notes-tab.component'; +import { HelmReleasePodsTabComponent } from './release/tabs/helm-release-pods/helm-release-pods-tab.component'; +import { + HelmReleaseResourceGraphComponent, +} from './release/tabs/helm-release-resource-graph/helm-release-resource-graph.component'; +import { HelmReleaseServicesTabComponent } from './release/tabs/helm-release-services/helm-release-services-tab.component'; +import { HelmReleaseSummaryTabComponent } from './release/tabs/helm-release-summary-tab/helm-release-summary-tab.component'; +import { HelmReleaseValuesTabComponent } from './release/tabs/helm-release-values-tab/helm-release-values-tab.component'; +import { HelmReleasesTabComponent } from './releases-tab/releases-tab.component'; +import { UpgradeReleaseComponent } from './upgrade-release/upgrade-release.component'; + +const routes: Routes = [ + { + path: '', + children: [ + { + path: '', + component: HelmReleasesTabComponent, + pathMatch: 'full', + }, + { pathMatch: 'full', path: 'install/:endpoint/:repo/:name/:version', component: CreateReleaseComponent }, + { pathMatch: 'full', path: 'install/:endpoint/:repo/:name', component: CreateReleaseComponent }, + { + // guid = kube endpoint + path: ':guid/upgrade', + component: UpgradeReleaseComponent, + pathMatch: 'full', + }, + { + // Helm Release Views + path: ':guid', + component: HelmReleaseTabBaseComponent, + data: { + reuseRoute: HelmReleaseTabBaseComponent, + }, + children: [ + { path: '', redirectTo: 'summary', pathMatch: 'full' }, + { path: 'summary', component: HelmReleaseSummaryTabComponent }, + { path: 'notes', component: HelmReleaseNotesTabComponent }, + { path: 'values', component: HelmReleaseValuesTabComponent }, + { path: 'history', component: HelmReleaseHistoryTabComponent }, + { path: 'pods', component: HelmReleasePodsTabComponent }, + { path: 'services', component: HelmReleaseServicesTabComponent }, + { path: 'graph', component: HelmReleaseResourceGraphComponent }, + { path: 'analysis', component: HelmReleaseAnalysisTabComponent }, + ] + }, + ] + }, +]; + +@NgModule({ + imports: [ + NgxChartsModule, + RouterModule.forChild(routes) + ] +}) +export class WorkloadsRouting { } diff --git a/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts new file mode 100644 index 0000000000..5d1b9d4b87 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/kubernetes/workloads/workloads.testing.module.ts @@ -0,0 +1,50 @@ +import { HttpClientModule } from '@angular/common/http'; +import { NgModule } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CoreModule, SharedModule } from '../../../../core/src/public-api'; +import { AppTestModule } from '../../../../core/test-framework/core-test.helper'; +import { + CATALOGUE_ENTITIES, + entityCatalog, + EntityCatalogFeatureModule, + TestEntityCatalog, +} from '../../../../store/src/public-api'; +import { generateStratosEntities } from '../../../../store/src/stratos-entity-generator'; +import { createBasicStoreModule } from '../../../../store/testing/public-api'; +import { generateHelmEntities } from '../../helm/helm-entity-generator'; +import { HelmTestingModule } from '../../helm/helm-testing.module'; +import { generateKubernetesEntities } from '../kubernetes-entity-generator'; + +@NgModule({ + imports: [{ + ngModule: EntityCatalogFeatureModule, + providers: [ + { + provide: CATALOGUE_ENTITIES, useFactory: () => { + const testEntityCatalog = entityCatalog as TestEntityCatalog; + testEntityCatalog.clear(); + return [ + ...generateStratosEntities(), + ...generateKubernetesEntities(), + ...generateHelmEntities(), + ]; + } + } + ] + }] +}) +export class WorkloadsTestingModule { } + +export const WorkloadsBaseTestingModule = [ + AppTestModule, + RouterTestingModule, + CoreModule, + createBasicStoreModule(), + NoopAnimationsModule, + HttpClientModule, + SharedModule, + HelmTestingModule, + WorkloadsTestingModule +]; diff --git a/src/frontend/packages/kubernetes/src/public-api.ts b/src/frontend/packages/kubernetes/src/public-api.ts new file mode 100644 index 0000000000..4da9d3ad7b --- /dev/null +++ b/src/frontend/packages/kubernetes/src/public-api.ts @@ -0,0 +1,4 @@ +// Custom Extensions + +export * from './kube-package.module'; +export * from './kube-package-routing.module'; \ No newline at end of file diff --git a/src/frontend/packages/kubernetes/src/test.ts b/src/frontend/packages/kubernetes/src/test.ts new file mode 100644 index 0000000000..f205020844 --- /dev/null +++ b/src/frontend/packages/kubernetes/src/test.ts @@ -0,0 +1,36 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files +import 'core-js/es/reflect'; +import 'zone.js/dist/zone'; +import 'zone.js/dist/zone-testing'; + +import { APP_BASE_HREF } from '@angular/common'; +import { getTestBed } from '@angular/core/testing'; +import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; + + +declare const require: any; + +// First, initialize the Angular testing environment. +const testBed = getTestBed(); +testBed.initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting() +); + +beforeEach(() => { + testBed.configureTestingModule({ + providers: [{ provide: APP_BASE_HREF, useValue: '/' }] + }); +}); + +/** + * Bump up the Jasmine timeout from 5 seconds + */ +beforeAll(() => { + jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; +}); + +// Then we find all the tests. +const context = require.context('./', true, /\.spec\.ts$/); +// And load the modules. +context.keys().map(context); diff --git a/src/frontend/packages/kubernetes/tsconfig.spec.json b/src/frontend/packages/kubernetes/tsconfig.spec.json new file mode 100644 index 0000000000..cb4a7be918 --- /dev/null +++ b/src/frontend/packages/kubernetes/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.spec.json", + "compilerOptions": { + "outDir": "../../../../out-tsc/spec", + "types": [ + "jasmine", + "node" + ] + }, + "files": [ + "src/test.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/src/frontend/packages/kubernetes/tslint.json b/src/frontend/packages/kubernetes/tslint.json new file mode 100644 index 0000000000..9da788b6cb --- /dev/null +++ b/src/frontend/packages/kubernetes/tslint.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../../tslint.json" +} diff --git a/src/jetstream/cnsi.go b/src/jetstream/cnsi.go index 38a37b2542..5527db1149 100644 --- a/src/jetstream/cnsi.go +++ b/src/jetstream/cnsi.go @@ -681,5 +681,12 @@ func (p *portalProxy) updateEndpoint(ec echo.Context) error { } } + // Notify plugins if they support the notification interface + for _, plugin := range p.Plugins { + if notifier, ok := plugin.(interfaces.EndpointNotificationPlugin); ok { + notifier.OnEndpointNotification(interfaces.EndpointUpdateAction, &endpoint) + } + } + return nil } diff --git a/src/jetstream/config.dev b/src/jetstream/config.dev index d6f6b930b3..fa8c377f29 100644 --- a/src/jetstream/config.dev +++ b/src/jetstream/config.dev @@ -55,4 +55,16 @@ LOCAL_USER_PASSWORD=admin LOCAL_USER_SCOPE=stratos.admin # Enable/disable API key-based access to Stratos API (disabled, admin_only, all_users). Default is admin_only -#API_KEYS_ENABLED=admin_only \ No newline at end of file +#API_KEYS_ENABLED=admin_only + +# Cache folder for Helm Charts +HELM_CACHE_FOLDER=./.helm-cache + +# MariaDB database for local dev +# DATABASE_PROVIDER=mysql +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_USER=stratos +# DB_PASSWORD=strat0s +# DB_DATABASE_NAME=stratosdb +# DB_SSL_MODE=disable diff --git a/src/jetstream/config.example b/src/jetstream/config.example index 51a55a1443..701192d521 100644 --- a/src/jetstream/config.example +++ b/src/jetstream/config.example @@ -58,6 +58,7 @@ INVITE_USER_CLIENT_SECRET= # DB_USER=stratos # DB_PASSWORD=strat0s # DB_DATABASE_NAME=stratosdb +# DB_SSL_MODE=disable # Postgresql database for local dev # DATABASE_PROVIDER=pgsql @@ -67,3 +68,12 @@ INVITE_USER_CLIENT_SECRET= # DB_PASSWORD=strat0s # DB_DATABASE_NAME=stratosdb # DB_SSL_MODE=disable + +# Analysis services API +#ANALYSIS_SERVICES_API= + +# Download link when installing the Kubernetes Dashboard in a targetted Kube Endpoint +# STRATOS_KUBERNETES_DASHBOARD_IMAGE= + +# Cache folder for Helm Charts +# HELM_CACHE_FOLDER=./helm-cache \ No newline at end of file diff --git a/src/jetstream/datastore/20200902162200_HelmSubtype.go b/src/jetstream/datastore/20200902162200_HelmSubtype.go new file mode 100644 index 0000000000..ebb97be983 --- /dev/null +++ b/src/jetstream/datastore/20200902162200_HelmSubtype.go @@ -0,0 +1,21 @@ +package datastore + +import ( + "database/sql" + + "bitbucket.org/liamstask/goose/lib/goose" +) + +func init() { + RegisterMigration(20200902162200, "HelmSubtype", func(txn *sql.Tx, conf *goose.DBConf) error { + + // Make sure all previous helm endpoints type shave the correct 'repo' sub type + updateHelmRepoSubtype := "UPDATE cnsis SET sub_type='repo' WHERE cnsi_type='helm';" + _, err := txn.Exec(updateHelmRepoSubtype) + if err != nil { + return err + } + + return nil + }) +} diff --git a/src/jetstream/go.mod b/src/jetstream/go.mod index ecfbfd098e..5a094cc018 100644 --- a/src/jetstream/go.mod +++ b/src/jetstream/go.mod @@ -12,8 +12,6 @@ require ( code.cloudfoundry.org/inigo v0.0.0-20200318144131-597cd5dbfe8b // indirect code.cloudfoundry.org/lager v2.0.0+incompatible // indirect code.cloudfoundry.org/ykk v0.0.0-20170424192843-e4df4ce2fd4d // indirect - github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect - github.com/SermoDigital/jose v0.9.1 // indirect github.com/Sirupsen/logrus v0.0.0-00010101000000-000000000000 // indirect github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 github.com/antonlindstrom/pgstore v0.0.0-20170604072116-a407030ba6d0 @@ -24,60 +22,54 @@ require ( github.com/cf-stratos/mysqlstore v0.0.0-20170822100912-304308519d13 github.com/charlievieth/fs v0.0.0-20170613215519-7dc373669fa1 // indirect github.com/cloudfoundry-community/go-cfenv v1.17.0 + github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes v0.0.0-00010101000000-000000000000 + github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/monocular v0.0.0-00010101000000-000000000000 github.com/cloudfoundry/bosh-cli v5.4.0+incompatible // indirect github.com/cloudfoundry/bosh-utils v0.0.0-20190206192830-9a0affed2bf1 // indirect github.com/cloudfoundry/cli-plugin-repo v0.0.0-20190220174354-ecf721ef3813 // indirect github.com/cloudfoundry/noaa v2.1.0+incompatible github.com/cloudfoundry/sonde-go v0.0.0-20171206171820-b33733203bb4 github.com/cppforlife/go-patch v0.2.0 // indirect - github.com/cyphar/filepath-securejoin v0.2.2 // indirect - github.com/docker/distribution v2.7.1+incompatible // indirect - github.com/docker/docker v1.13.1 // indirect github.com/domodwyer/mailyak v3.1.1+incompatible github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 // indirect github.com/elazarl/goproxy v0.0.0-20200315184450-1f3cb6622dad // indirect github.com/elazarl/goproxy/ext v0.0.0-20200315184450-1f3cb6622dad // indirect github.com/fatih/color v1.7.0 // indirect - github.com/go-openapi/spec v0.19.9 // indirect - github.com/go-openapi/swag v0.19.9 // indirect - github.com/go-sql-driver/mysql v1.4.1 - github.com/gogo/protobuf v1.2.1 // indirect - github.com/golang/mock v1.4.4 + github.com/go-sql-driver/mysql v1.5.0 + github.com/golang/mock v1.2.0 github.com/golang/snappy v0.0.1 // indirect github.com/google/go-querystring v1.0.0 // indirect + github.com/google/martian v2.1.0+incompatible github.com/gopherjs/gopherjs v0.0.0-20190411002643-bd77b112433e // indirect github.com/gorilla/context v1.1.1 github.com/gorilla/securecookie v1.1.1 github.com/gorilla/sessions v1.1.3 github.com/gorilla/websocket v1.4.2 github.com/govau/cf-common v0.0.7 + github.com/helm/monocular v1.10.0 // indirect github.com/jessevdk/go-flags v1.4.0 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/kat-co/vala v0.0.0-20170210184112-42e1d8b61f12 github.com/kr/pty v1.1.8 // indirect - github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 // indirect + github.com/labstack/echo v3.3.10+incompatible github.com/labstack/echo/v4 v4.1.17 - github.com/lib/pq v1.1.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect - github.com/mailru/easyjson v0.7.6 // indirect - github.com/mattn/go-runewidth v0.0.4 // indirect github.com/mattn/go-sqlite3 v1.13.0 github.com/mholt/archiver v3.1.1+incompatible - github.com/mitchellh/mapstructure v1.1.2 // indirect + github.com/miekg/dns v1.1.4 // indirect github.com/moby/moby v1.13.1 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/nwaples/rardecode v1.0.0 // indirect github.com/nwmac/sqlitestore v0.0.0-20180824125213-7d2ab221fb3f github.com/onsi/ginkgo v1.11.0 // indirect github.com/onsi/gomega v1.8.1 // indirect - github.com/opencontainers/go-digest v1.0.0-rc1 // indirect github.com/pierrec/lz4 v2.0.5+incompatible // indirect github.com/poy/eachers v0.0.0-20181020210610-23942921fe77 // indirect github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 // indirect github.com/satori/go.uuid v1.2.0 - github.com/sirupsen/logrus v1.3.0 + github.com/sirupsen/logrus v1.4.2 github.com/smartystreets/assertions v0.0.0-20190401211740-f487f9de1cd3 // indirect - github.com/smartystreets/goconvey v0.0.0-20190222223459-a17d461953aa + github.com/smartystreets/goconvey v1.6.4 github.com/swaggo/echo-swagger v1.0.0 github.com/swaggo/swag v1.6.7 github.com/tedsuo/ifrit v0.0.0-20191009134036-9a97d0632f00 // indirect @@ -85,22 +77,51 @@ require ( github.com/ulikunitz/xz v0.5.6 // indirect github.com/vito/go-interact v0.0.0-20171111012221-fa338ed9e9ec // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect - github.com/ziutek/mymysql v1.5.4 // indirect golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a - golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a // indirect - golang.org/x/tools v0.0.0-20200903153655-76a6aac657c7 // indirect - google.golang.org/appengine v1.5.0 // indirect - gopkg.in/DATA-DOG/go-sqlmock.v1 v1.0.0-00010101000000-000000000000 + gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 gopkg.in/cheggaaa/pb.v1 v1.0.27 // indirect gopkg.in/yaml.v2 v2.3.0 + k8s.io/apiextensions-apiserver v0.0.0 // indirect + k8s.io/helm v2.16.10+incompatible // indirect + k8s.io/kubectl v0.0.0 // indirect ) +replace github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes => ./plugins/kubernetes + +replace github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/monocular => ./plugins/monocular + replace ( github.com/SermoDigital/jose => github.com/SermoDigital/jose v0.9.2-0.20180104203859-803625baeddc github.com/Sirupsen/logrus => github.com/sirupsen/logrus v1.4.1 + github.com/docker/docker => github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309 + github.com/kubernetes-sigs/aws-iam-authenticator => github.com/kubernetes-sigs/aws-iam-authenticator v0.3.1-0.20190111160901-390d9087a4bc github.com/russross/blackfriday v2.0.0+incompatible => github.com/russross/blackfriday v1.5.2 github.com/sergi/go-diff => github.com/sergi/go-diff v1.0.0 github.com/smartystreets/goconvey => github.com/smartystreets/goconvey v0.0.0-20160503033757-d4c757aa9afd github.com/spf13/cobra => github.com/spf13/cobra v0.0.3 gopkg.in/DATA-DOG/go-sqlmock.v1 => github.com/DATA-DOG/go-sqlmock v1.1.3 + k8s.io/api => k8s.io/kubernetes/staging/src/k8s.io/api v0.0.0-20191001043732-d647ddbd755f + k8s.io/apiextensions-apiserver => k8s.io/kubernetes/staging/src/k8s.io/apiextensions-apiserver v0.0.0-20191001043732-d647ddbd755f + k8s.io/apimachinery => k8s.io/kubernetes/staging/src/k8s.io/apimachinery v0.0.0-20191001043732-d647ddbd755f + k8s.io/apiserver => k8s.io/kubernetes/staging/src/k8s.io/apiserver v0.0.0-20191001043732-d647ddbd755f + k8s.io/cli-runtime => k8s.io/kubernetes/staging/src/k8s.io/cli-runtime v0.0.0-20191001043732-d647ddbd755f + k8s.io/client-go => k8s.io/kubernetes/staging/src/k8s.io/client-go v0.0.0-20191001043732-d647ddbd755f + //k8s.io/kubernetes => k8s.io/kubernetes/staging/src/k8s.io/kubernetes v1.13.3 + k8s.io/cloud-provider => k8s.io/kubernetes/staging/src/k8s.io/cloud-provider v0.0.0-20191001043732-d647ddbd755f + k8s.io/cluster-bootstrap => k8s.io/kubernetes/staging/src/k8s.io/cluster-bootstrap v0.0.0-20191001043732-d647ddbd755f + k8s.io/code-generator => k8s.io/kubernetes/staging/src/k8s.io/code-generator v0.0.0-20191001043732-d647ddbd755f + k8s.io/component-base => k8s.io/kubernetes/staging/src/k8s.io/component-base v0.0.0-20191001043732-d647ddbd755f + k8s.io/cri-api => k8s.io/kubernetes/staging/src/k8s.io/cri-api v0.0.0-20191001043732-d647ddbd755f + k8s.io/csi-translation-lib => k8s.io/kubernetes/staging/src/k8s.io/csi-translation-lib v0.0.0-20191001043732-d647ddbd755f + k8s.io/kube-aggregator => k8s.io/kubernetes/staging/src/k8s.io/kube-aggregator v0.0.0-20191001043732-d647ddbd755f + k8s.io/kube-controller-manager => k8s.io/kubernetes/staging/src/k8s.io/kube-controller-manager v0.0.0-20191001043732-d647ddbd755f + k8s.io/kube-proxy => k8s.io/kubernetes/staging/src/k8s.io/kube-proxy v0.0.0-20191001043732-d647ddbd755f + k8s.io/kube-scheduler => k8s.io/kubernetes/staging/src/k8s.io/kube-scheduler v0.0.0-20191001043732-d647ddbd755f + //k8s.io/kube-openapi => k8s.io/kubernetes/staging/src/k8s.io/kube-openapi v0.0.0-20180509051136-39cb288412c4 + k8s.io/kubectl => k8s.io/kubernetes/staging/src/k8s.io/kubectl v0.0.0-20191001043732-d647ddbd755f + k8s.io/kubelet => k8s.io/kubernetes/staging/src/k8s.io/kubelet v0.0.0-20191001043732-d647ddbd755f + k8s.io/legacy-cloud-providers => k8s.io/kubernetes/staging/src/k8s.io/legacy-cloud-providers v0.0.0-20191001043732-d647ddbd755f + k8s.io/metrics => k8s.io/kubernetes/staging/src/k8s.io/metrics v0.0.0-20191001043732-d647ddbd755f + k8s.io/sample-apiserver => k8s.io/kubernetes/staging/src/k8s.io/sample-apiserver v0.0.0-20191001043732-d647ddbd755f + ) diff --git a/src/jetstream/go.sum b/src/jetstream/go.sum index 49a036c2d7..ef4ca06d3b 100644 --- a/src/jetstream/go.sum +++ b/src/jetstream/go.sum @@ -1,53 +1,110 @@ bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c h1:bkb2NMGo3/Du52wvYj9Whth5KZfMV6d3O0Vbr3nz/UE= bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c/go.mod h1:hSVuE3qU7grINVSwrmzHfpg9k87ALBk+XaualNyUzI4= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= code.cloudfoundry.org/bytefmt v0.0.0-20180906201452-2aa6f33b730c h1:VzwteSWGbW9mxXTEkH+kpnao5jbgLynw3hq742juQh8= code.cloudfoundry.org/bytefmt v0.0.0-20180906201452-2aa6f33b730c/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc= code.cloudfoundry.org/cfnetworking-cli-api v0.0.0-20190103195135-4b04f26287a6 h1:Yc9r1p21kEpni9WlG4mwOZw87TB2QlyS9sAEebZ3+ak= code.cloudfoundry.org/cfnetworking-cli-api v0.0.0-20190103195135-4b04f26287a6/go.mod h1:u5FovqC5GGAEbFPz+IdjycDA+gIjhUwqxnu0vbHwVeM= code.cloudfoundry.org/cli v6.49.0+incompatible h1:lUuYux9EXLe8EBzlvckJLpHKhc8szJfWiEc3SXdM8+o= code.cloudfoundry.org/cli v6.49.0+incompatible/go.mod h1:e4d+EpbwevNhyTZKybrLlyTvpH+W22vMsmdmcTxs/Fo= -code.cloudfoundry.org/diego-ssh v0.0.0-20200312183824-517d22c5d890 h1:sr3sHuZSH6puBqQgatzM3hYRrfOc+D8eVv0ykLYwd6o= code.cloudfoundry.org/diego-ssh v0.0.0-20200312183824-517d22c5d890/go.mod h1:L2/glHnSK+wKnsG8oZZqdV2sgYY9NDo/I1aDJGhcWaM= code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f h1:UrKzEwTgeiff9vxdrfdqxibzpWjxLnuXDI5m6z3GJAk= code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f/go.mod h1:sk5LnIjB/nIEU7yP5sDQExVm62wu0pBh3yrElngUisI= -code.cloudfoundry.org/inigo v0.0.0-20200318144131-597cd5dbfe8b h1:TSoj8216L9LEU1MKAI9GSpOpLesalhitn8R4fEn38P8= code.cloudfoundry.org/inigo v0.0.0-20200318144131-597cd5dbfe8b/go.mod h1:1ZB1JCh2FAp+SqX79ve6dc8YREvbsziULEOncAilX4Q= -code.cloudfoundry.org/lager v2.0.0+incompatible h1:WZwDKDB2PLd/oL+USK4b4aEjUymIej9My2nUQ9oWEwQ= code.cloudfoundry.org/lager v2.0.0+incompatible/go.mod h1:O2sS7gKP3HM2iemG+EnwvyNQK7pTSC6Foi4QiMp9sSk= code.cloudfoundry.org/ykk v0.0.0-20170424192843-e4df4ce2fd4d h1:M+zXqtXJqcsmpL76aU0tdl1ho23eYa4axYoM4gD62UA= code.cloudfoundry.org/ykk v0.0.0-20170424192843-e4df4ce2fd4d/go.mod h1:YUJiVOr5xl0N/RjMxM1tHmgSpBbi5UM+KoVR5AoejO0= -github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= +github.com/BurntSushi/toml v0.3.0/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.1.3 h1:sVIxMSTMnnVzl9Bn6BMjW6p5lSbpjHL80mqnxMWJ/oE= github.com/DATA-DOG/go-sqlmock v1.1.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/PuerkitoBio/purell v1.1.0 h1:rmGxhojJlM0tuKtfdvliR84CFHljx9ag64t2xmVkjK4= +github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= +github.com/MakeNowJust/heredoc v0.0.0-20171113091838-e9091a26100e h1:eb0Pzkt15Bm7f2FFYv7sjY7NPFi3cPkS3tv1CcrFBWA= +github.com/MakeNowJust/heredoc v0.0.0-20171113091838-e9091a26100e/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= +github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg= +github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.3.1/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc= +github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.0.1 h1:2kKm5lb7dKVrt5TYUiAavE6oFc1cFT0057UVGT+JqLk= +github.com/Masterminds/semver/v3 v3.0.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/semver/v3 v3.1.0 h1:Y2lUDsFKVRSYGojLJ1yLxSXdMmMYTYls0rCvoqmMUQk= +github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/sprig v2.18.0+incompatible h1:QoGhlbC6pter1jxKnjMFxT8EqsLuDE6FEcNbWEpw+lI= +github.com/Masterminds/sprig v2.18.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/Masterminds/sprig/v3 v3.0.0 h1:KSQz7Nb08/3VU9E4ns29dDxcczhOD1q7O1UfM4G3t3g= +github.com/Masterminds/sprig/v3 v3.0.0/go.mod h1:NEUY/Qq8Gdm2xgYA+NwJM6wmfdRV9xkh8h/Rld20R0U= +github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/SermoDigital/jose v0.9.2-0.20180104203859-803625baeddc h1:MhBvG7RLaLqlyjxMR6of35vt6MVQ+eXMcgn9X/sy0FE= github.com/SermoDigital/jose v0.9.2-0.20180104203859-803625baeddc/go.mod h1:ARgCUhI1MHQH+ONky/PAtmVHQrP5JlGY0F3poXOp/fA= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/antonlindstrom/pgstore v0.0.0-20170604072116-a407030ba6d0 h1:e6PEaXbztY0ViaKotCICNnBQDUeNEJgrQ5UAHWlloh4= github.com/antonlindstrom/pgstore v0.0.0-20170604072116-a407030ba6d0/go.mod h1:2Ti6VUHVxpC0VSmTZzEvpzysnaGAfGBOoMIz5ykPyyw= -github.com/apoydence/eachers v0.0.0-20181020210610-23942921fe77 h1:afT88tB6u9JCKQZVAAaa9ICz/uGn5Uw9ekn6P22mYKM= github.com/apoydence/eachers v0.0.0-20181020210610-23942921fe77/go.mod h1:bXvGk6IkT1Agy7qzJ+DjIw/SJ1AaB3AvAuMDVV+Vkoo= +github.com/arschles/assert v1.0.0 h1:NofQbRhtxcLgP+XoKunA7J6UMJNTqX7xR/19tej8UsA= +github.com/arschles/assert v1.0.0/go.mod h1:m/u69zW43x0h8dTHcv3JJZljINyEYgBuf5fYJP6WikI= +github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/aws/aws-sdk-go v1.17.5 h1:WW9Hm3KYo48iZHpmBc+b7sgyS0h32zgCvya28SLW4BU= +github.com/aws/aws-sdk-go v1.17.5/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar v1.1.1 h1:YroD6BJCZBYx06yYFEWvUuKVWQn3vLLQAVmDmvTSaiQ= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40 h1:y4B3+GPxKlrigF1ha5FFErxK+sr6sWxQovRMzwMhejo= github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40/go.mod h1:8rLXio+WjiTceGBHIoTvn60HIbs7Hm7bcHjyrSqYB9c= +github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bugsnag/bugsnag-go v1.5.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/cespare/xxhash/v2 v2.1.0 h1:yTUvW7Vhb89inJ+8irsUqiWjh8iT6sQPZiQzI6ReGkA= +github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= github.com/cf-stratos/mysqlstore v0.0.0-20170822100912-304308519d13 h1:WwIvjUUodNoZduhdhotbKnrLSFoIn5vD3QgNZv0hjvo= github.com/cf-stratos/mysqlstore v0.0.0-20170822100912-304308519d13/go.mod h1:GgQT0ToC+7JLnMKdDB5d434WwCLC2dpNR2AgTJj/08o= +github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw= +github.com/chai2010/gettext-go v0.0.0-20170215093142-bf70f2a70fb1/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw= github.com/charlievieth/fs v0.0.0-20170613215519-7dc373669fa1 h1:vTlpHKxJqykyKdW9bkrDJNWeKNuSIAJ0TP/K4lRsz/Q= github.com/charlievieth/fs v0.0.0-20170613215519-7dc373669fa1/go.mod h1:sAoA1zHCH4FJPE2gne5iBiiVG66U7Nyp6JqlOo+FEyg= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudfoundry-community/go-cfenv v1.17.0 h1:qfxEfn8qKkaHY3ZEk/Y2noY79HBASvNgmtHK9x4+6GY= github.com/cloudfoundry-community/go-cfenv v1.17.0/go.mod h1:2UgWvQTRXUuIZ/x3KnW6fk6CgPBhcV4UQb/UGIrUyyI= github.com/cloudfoundry/bosh-cli v5.4.0+incompatible h1:KpT2PBB7nP1QnK8guXeZ/D2k7FZYAOxcveKgYTDEDBI= @@ -60,36 +117,81 @@ github.com/cloudfoundry/noaa v2.1.0+incompatible h1:hr6VnM5VlYRN3YD+NmAedQLW8686 github.com/cloudfoundry/noaa v2.1.0+incompatible/go.mod h1:5LmacnptvxzrTvMfL9+EJhgkUfIgcwI61BVSTh47ECo= github.com/cloudfoundry/sonde-go v0.0.0-20171206171820-b33733203bb4 h1:cWfya7mo/zbnwYVio6eWGsFJHqYw4/k/uhwIJ1eqRPI= github.com/cloudfoundry/sonde-go v0.0.0-20171206171820-b33733203bb4/go.mod h1:GS0pCHd7onIsewbw8Ue9qa9pZPv2V88cUZDttK6KzgI= +github.com/containerd/containerd v1.3.0-beta.2.0.20190823190603-4a2f61c4f2b4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.0 h1:xjvXQWABwS2uiv3TWgQt5Uth60Gu86LTGZXMJkjc7rY= +github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 h1:4BX8f882bXEDKfWIf0wa8HRvpnBoPszJJXL+TVbBw4M= +github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/coreos/bbolt v1.3.1-coreos.6/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.15+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cppforlife/go-patch v0.2.0 h1:Y14MnCQjDlbw7WXT4k+u6DPAA9XnygN4BfrSpI/19RU= github.com/cppforlife/go-patch v0.2.0/go.mod h1:67a7aIi94FHDZdoeGSJRRFDp66l9MhaAG1yGxpUoFD8= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.7 h1:6pwm8kMQKCmgUg0ZHTm5+/YvRK0s3THD/28+T6/kk4A= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrPujOBSCj8xRg= github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE= +github.com/deislabs/oras v0.7.0 h1:RnDoFd3tQYODMiUqxgQ8JxlrlWL0/VMKIKRD01MmNYk= +github.com/deislabs/oras v0.7.0/go.mod h1:sqMKPG3tMyIX9xwXUBRLhZ24o+uT4y6jgBD2RzUTKDM= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/disintegration/imaging v1.5.0/go.mod h1:9B/deIUIrliYkyMTuXJd6OUFLcrZ2tf+3Qlwnaf/CjU= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/docker/cli v0.0.0-20190506213505-d88565df0c2d h1:qdD+BtyCE1XXpDyhvn0yZVcZOLILdj9Cw4pKu0kQbPQ= +github.com/docker/cli v0.0.0-20190506213505-d88565df0c2d/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= -github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.6.1 h1:Dq4iIfcM7cNtddhLVWe9h4QDjsi4OER3Z8voPu/I52g= +github.com/docker/docker-credential-helpers v0.6.1/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 h1:X0fj836zx99zFu83v/M79DuBn84IL/Syx1SY6Y5ZEMA= +github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c h1:ZfSZ3P3BedhKGUhzj7BQlPSU4OvT6tfOKe3DVHzOA7s= +github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/domodwyer/mailyak v3.1.1+incompatible h1:oPtXn3+56LEFbdqH0bpuPRsqtijW9l2POpQe9sTUsSI= github.com/domodwyer/mailyak v3.1.1+incompatible/go.mod h1:5NNYkn9hxcdNEOmmMx0yultN5VLorZQ+AWQo9iya+UY= github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 h1:eX+pdPPlD279OWgdx7f6KqIRSONuK7egk+jDx7OM3Ac= github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76/go.mod h1:KjxHHirfLaw19iGT70HvVjHQsL1vq1SRQB4yOsAfy2s= -github.com/elazarl/goproxy v0.0.0-20200315184450-1f3cb6622dad h1:zPs0fNF2Io1Qytf92EI2CDJ9oCXZr+NmjEVexrUEdq4= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy v0.0.0-20200315184450-1f3cb6622dad/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= -github.com/elazarl/goproxy/ext v0.0.0-20200315184450-1f3cb6622dad h1:3OG8xVCbdeebrE5IsoWl0TP25DWiHDbLUy+EKif7hDE= github.com/elazarl/goproxy/ext v0.0.0-20200315184450-1f3cb6622dad/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.11.1+incompatible h1:CjKsv3uWcCMvySPQYKxO8XX3f9zD4FeZRsW4G0B4ffE= +github.com/emicklei/go-restful v2.11.1+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.5.0+incompatible h1:ouOWdg56aJriqS0huScTkVXPC5IcNrDCXZ6OoTAWu7M= +github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM= +github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= +github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v0.0.0-20180820084758-c7ce16629ff4/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/gzip v0.0.1/go.mod h1:fGBJBCdt6qCZuCAOwWuFhBB4OOq9EFqlo5dEaFhhu5w= @@ -98,170 +200,368 @@ github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NB github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= -github.com/go-openapi/jsonpointer v0.17.0 h1:nH6xp8XdXHx8dqveo0ZuJBluCO2qGrPbDNZ0dwoRHP0= +github.com/globalsign/mgo v0.0.0-20180615134936-113d3961e731/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 h1:DujepqpGd1hyOd7aW59XpK7Qymp8iy83xq74fLr21is= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= +github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= github.com/go-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.17.0 h1:yJW3HCkTHg7NOA+gZ83IPHzUSnUzGXhGmsdiCcMexbA= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.19.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= github.com/go-openapi/jsonreference v0.19.3 h1:5cxNfTy0UVC3X8JL5ymxzyoUZmo8iZb+jeTWn7tUa8o= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= -github.com/go-openapi/jsonreference v0.19.4 h1:3Vw+rh13uq2JFNxgnMTGE1rnoieU9FmyE1gvnyylsYg= -github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= +github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= +github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= +github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= github.com/go-openapi/spec v0.19.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= github.com/go-openapi/spec v0.19.4 h1:ixzUSnHTd6hCemgtAJgluaTSGYpLNpJY4mA2DIkdOAo= github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= -github.com/go-openapi/spec v0.19.9 h1:9z9cbFuZJ7AcvOHKIY+f6Aevb4vObNDkTEyoMfO7rAc= -github.com/go-openapi/spec v0.19.9/go.mod h1:vqK/dIdLGCosfvYsQV3WfC7N3TiZSnGY2RZKoFK7X28= -github.com/go-openapi/swag v0.17.0 h1:iqrgMg7Q7SvtbWLlltPrkMs0UBJI6oTSs79JFRUi880= +github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= -github.com/go-openapi/swag v0.19.9 h1:1IxuqvBUU3S2Bi4YC7tlP9SJF1gVpCvqN0T2Qof4azE= -github.com/go-openapi/swag v0.19.9/go.mod h1:ao+8BpOPyKdpQz3AOJfbeEVpLmWAvlT1IfTe5McPyhY= +github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450/go.mod h1:Bk6SMAONeMXrxql8uvOKuAZSu8aM5RUGv+1C6IJaEho= +github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995/go.mod h1:lJgMEyOkYFkPcDKwRXegd+iM6E7matEszMG5HhwytU8= +github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/gopherjs/gopherjs v0.0.0-20190411002643-bd77b112433e h1:XWcjeEtTFTOVA9Fs1w7n2XBftk5ib4oZrhzWk0B+3eA= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.3.1 h1:WeAefnSUHlBb0iJKwxFDZdbfGwkd7xRNuV+IpXMJhYk= +github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= +github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= github.com/gopherjs/gopherjs v0.0.0-20190411002643-bd77b112433e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/handlers v1.4.0/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= +github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gosuri/uitable v0.0.1 h1:M9sMNgSZPyAu1FJZJLpJ16ofL8q5ko2EDUkICsynvlY= +github.com/gosuri/uitable v0.0.1/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/govau/cf-common v0.0.7 h1:uhp1P6XM6GGzu1+A4C7LELLX/9mCmH6W5DpJZC0kWmo= github.com/govau/cf-common v0.0.7/go.mod h1:5xL/OfE7wxeyHlXb7iei0rAbdQ/5v6dF18BZknPv7NQ= -github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/gregjones/httpcache v0.0.0-20170728041850-787624de3eb7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/gregjones/httpcache v0.0.0-20181110185634-c63ab54fda8f/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc h1:f8eY6cV/x1x+HLjOp4r72s/31/V2aTUtg5oKRRPf8/Q= +github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v0.0.0-20190222133341-cfaf5686ec79/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.3.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.3 h1:YPkqC67at8FYaadspW/6uE0COsBxS2656RLEr8Bppgk= +github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/helm/monocular v1.4.0 h1:g0sOpuMe+9u+aPfd9ZO8mWV+c8W0dfGyBG9Wl23nwec= +github.com/helm/monocular v1.4.0/go.mod h1:PpkCN0v4zVVigsIHnsQdJytKFmaUkwfhxB7z33a9/gE= +github.com/helm/monocular v1.10.0 h1:/tkbVH0+7GR2C4W2ODJGiVGXyHmYCa3vaBb5S+pz6bE= +github.com/helm/monocular v1.10.0/go.mod h1:2pJcZSWeUPTtw1QSje6c/qCrSuSUujoNPgUoFc8ySMQ= +github.com/heptio/authenticator v0.3.0/go.mod h1:Q86X8hc61JXhE5XxYLKmrSRWby/Oe8IIYZIBgmGVkTA= +github.com/heptiolabs/healthcheck v0.0.0-20180807145615-6ff867650f40 h1:GT4RsKmHh1uZyhmTkWJTDALRjSHYQp6FRKrotf0zhAs= +github.com/heptiolabs/healthcheck v0.0.0-20180807145615-6ff867650f40/go.mod h1:NtmN9h8vrTveVQRLHcX2HQ5wIPBDCsZ351TGbZWgg38= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= +github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.5 h1:gL2yXlmiIo4+t+y32d4WGwOjKGYcGOuyrg46vadswDE= +github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= +github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o= +github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/kat-co/vala v0.0.0-20170210184112-42e1d8b61f12 h1:DQVOxR9qdYEybJUr/c7ku34r3PfajaMYXZwgDM7KuSk= github.com/kat-co/vala v0.0.0-20170210184112-42e1d8b61f12/go.mod h1:u9MdXq/QageOOSGp7qG4XAQsYUMP+V5zEel/Vrl6OOc= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v0.0.0-20180402223658-b729f2633dfe/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= -github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kubeapps/common v0.0.0-20190307100129-fcd6537ca4e3/go.mod h1:TsgmjeDpbftqhwPKInJ3v+l+xbHs4goiB6DFb2WqY9c= +github.com/kubeapps/common v0.0.0-20190508164739-10b110436c1a h1:VqeX/fehAB6FtBox0TVYcjOMXGE56INQIfbXegditX4= +github.com/kubeapps/common v0.0.0-20190508164739-10b110436c1a/go.mod h1:TsgmjeDpbftqhwPKInJ3v+l+xbHs4goiB6DFb2WqY9c= +github.com/kubernetes-sigs/aws-iam-authenticator v0.3.1-0.20190111160901-390d9087a4bc h1:Ttr4Z3ZrMv4rAXn10UAqOC8ACx+F1omvcyV1a3hRArE= +github.com/kubernetes-sigs/aws-iam-authenticator v0.3.1-0.20190111160901-390d9087a4bc/go.mod h1:ItxiN33Ho7Di8wiC4S4XqbH1NLF0DNdDWOd/5MI9gJU= github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 h1:mkl3tvPHIuPaWsLtmHTybJeoVEW7cbePK73Ir8VtruA= github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28/go.mod h1:T/T7jsxVqf9k/zYOqbgNAsANsjxTd1Yq3htjDhQ1H0c= -github.com/labstack/echo/v4 v4.0.0 h1:q1GH+caIXPP7H2StPIdzy/ez9CO0EepqYeUg6vi9SWM= +github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= +github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= github.com/labstack/echo/v4 v4.0.0/go.mod h1:tZv7nai5buKSg5h/8E6zz4LsD/Dqh9/91Mvs7Z5Zyno= github.com/labstack/echo/v4 v4.1.17 h1:PQIBaRplyRy3OjwILGkPg89JRtH2x5bssi59G2EL3fo= github.com/labstack/echo/v4 v4.1.17/go.mod h1:Tn2yRQL/UclUalpb5rPdXDevbkJ+lp/2svdyFBg6CHQ= -github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0= github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= -github.com/lib/pq v1.1.0 h1:/5u4a+KGJptBRqGzPvYQL9p0d/tPR4S31+Tnzj9lEO4= -github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM= github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= -github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= -github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.13.0 h1:LnJI81JidiW9r7pS/hXe6cFeO5EXNq7KbfvoJLRI69c= github.com/mattn/go-sqlite3 v1.13.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mholt/archiver v3.1.1+incompatible h1:1dCVxuqs0dJseYEhi5pl7MYPH9zDa1wBi7mF09cbNkU= github.com/mholt/archiver v3.1.1+incompatible/go.mod h1:Dh2dOXnSdiLxRiPoVfIr/fI1TwETms9B8CTWfeh7ROU= +github.com/miekg/dns v0.0.0-20181005163659-0d29b283ac0f/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309 h1:cvy4lBOYN3gKfKj8Lzz5Q9TfviP+L7koMHY7SvkyTKs= +github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= github.com/moby/moby v1.13.1 h1:mC5WwQwCXt/dYxZ1cIrRsnJAWw7VdtcTZUIGr4tXzOM= github.com/moby/moby v1.13.1/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/nwaples/rardecode v1.0.0 h1:r7vGuS5akxOnR4JQSkko62RJ1ReCMXxQRPtxsiFMBOs= github.com/nwaples/rardecode v1.0.0/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= github.com/nwmac/sqlitestore v0.0.0-20180824125213-7d2ab221fb3f h1:0U8+7akQEpWd5oaEgSKryzEEeI2oChQNc0ealKppMrk= github.com/nwmac/sqlitestore v0.0.0-20180824125213-7d2ab221fb3f/go.mod h1:GVvWHloj3TN6Mb3PH286FnNmEWPnn9VGEM8AhUUbdlw= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.11.0 h1:JAKSXpt1YjtLA7YpPiqO9ss6sNXEsPfSGdwN0UHqzrw= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v1.8.1 h1:C5Dqfs/LeauYDX0jJXIe2SWmwCbGzx9yF8C8xy3Lh34= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.8.1/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= +github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pierrec/lz4 v2.0.5+incompatible h1:2xWsjqPFWcplujydGg4WmhC/6fZqK42wMM8aXeqhl0I= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/poy/eachers v0.0.0-20181020210610-23942921fe77 h1:SNdqPRvRsVmYR0gKqFvrUKhFizPJ6yDiGQ++VAJIoDg= github.com/poy/eachers v0.0.0-20181020210610-23942921fe77/go.mod h1:x1vqpbcMW9T/KRcQ4b48diSiSVtYgvwQ5xzDByEg4WE= +github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= +github.com/prometheus/client_golang v0.0.0-20181001174001-0a8115f42e03/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.2.1 h1:JnMpQc6ppsNgw9QPAGF6Dod479itz7lvlsMzzNayLOI= +github.com/prometheus/client_golang v1.2.1/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0 h1:L+1lyG48J1zAQXA3RBX/nG/B3gjlHq0zTt2tlbJLyCY= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/procfs v0.0.0-20180920065004-418d78d0b9a7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190129233650-316cf8ccfec5/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.5 h1:3+auTFlqw+ZaQYJARz6ArODtkaIwtvBTx3N2NehQlL8= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94 h1:G04eS0JkAIVZfaJLjla9dNxkJCPiKIGZlw9AfOhzOD0= github.com/sabhiram/go-gitignore v0.0.0-20180611051255-d3107576ba94/go.mod h1:b18R55ulyQ/h3RaWyloPyER7fWQVZvimKKhnI5OfrJQ= +github.com/sajari/fuzzy v1.0.0/go.mod h1:OjYR6KxoWOe9+dOlXeiCJd4dIbED4Oo8wpS89o0pwOo= github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= -github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k= +github.com/sirupsen/logrus v1.1.0/go.mod h1:zrgwTnHtNr00buQ1vSptGe8m1f/BbgsPukg8qsT7A+A= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/smartystreets/assertions v0.0.0-20190401211740-f487f9de1cd3 h1:hBSHahWMEgzwRyS6dRpxY0XyjZsHyQ61s084wo5PJe0= github.com/smartystreets/assertions v0.0.0-20190401211740-f487f9de1cd3/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20160503033757-d4c757aa9afd h1:ZDVcuZGxvWB1ooKj1e31P/ktQK4A2WumM+LucMENpds= github.com/smartystreets/goconvey v0.0.0-20160503033757-d4c757aa9afd/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/soheilhy/cmux v0.1.3/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= @@ -270,132 +570,284 @@ github.com/swaggo/echo-swagger v1.0.0 h1:ppQFt6Am3/MHIUmTpZOwi4gggMZ/W9zmKP4Z9ah github.com/swaggo/echo-swagger v1.0.0/go.mod h1:Vnz3c2TGeFpoZPSV3CkWCrvyfU0016Gq/S0j4JspQnM= github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 h1:PyYN9JH5jY9j6av01SpfRMb+1DWg/i3MbGOKPxJ2wjM= github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E= -github.com/swaggo/gin-swagger v1.2.0 h1:YskZXEiv51fjOMTsXrOetAjrMDfFaXD79PEoQBOe2W0= github.com/swaggo/gin-swagger v1.2.0/go.mod h1:qlH2+W7zXGZkczuL+r2nEBR2JTT+/lX05Nn6vPhc7OI= github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+tv8Y= -github.com/swaggo/swag v1.6.3 h1:N+uVPGP4H2hXoss2pt5dctoSUPKKRInr6qcTMOm0usI= github.com/swaggo/swag v1.6.3/go.mod h1:wcc83tB4Mb2aNiL/HP4MFeQdpHUrca+Rp/DRNgWAUio= github.com/swaggo/swag v1.6.7 h1:e8GC2xDllJZr3omJkm9YfmK0Y56+rMO3cg0JBKNz09s= github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc= -github.com/tedsuo/ifrit v0.0.0-20191009134036-9a97d0632f00 h1:mujcChM89zOHwgZBBNr5WZ77mBXP1yR+gLThGCYZgAg= +github.com/technosophos/moniker v0.0.0-20180509230615-a5dbd03a2245/go.mod h1:O1c8HleITsZqzNZDjSNzirUGsMT0oGu9LhHKoJrqO+A= github.com/tedsuo/ifrit v0.0.0-20191009134036-9a97d0632f00/go.mod h1:eyZnKCc955uh98WQvzOm0dgAeLnf2O0Rz0LPoC5ze+0= github.com/tedsuo/rata v1.0.0 h1:Sf9aZrYy6ElSTncjnGkyC2yuVvz5YJetBIUKJ4CmeKE= github.com/tedsuo/rata v1.0.0/go.mod h1:X47ELzhOoLbfFIY0Cql9P6yo3Cdwf2CMX3FVZxRzJPc= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0= github.com/ugorji/go/codec v0.0.0-20181022190402-e5e69e061d4f/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.1.5-pre/go.mod h1:tULtS6Gy1AE1yCENaw4Vb//HLH5njI2tfCQDUqRd8fI= github.com/ulikunitz/xz v0.5.6 h1:jGHAfXawEGZQ3blwU5wnWKQJvAraT7Ftq9EXjnXYgt8= github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= +github.com/unrolled/render v0.0.0-20180914162206-b9786414de4d/go.mod h1:tu82oB5W2ykJRVioYsB+IQKcft7ryBr7w12qMBUPyXg= +github.com/unrolled/render v1.0.1 h1:VDDnQQVfBMsOsp3VaCJszSO0nkBIVEYoPWeRThk9spY= +github.com/unrolled/render v1.0.1/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM= github.com/urfave/cli v1.20.0 h1:fDqGv3UG/4jbVl/QkFwEdddtEDjh/5Ov6X+0B/3bPaw= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli/v2 v2.1.1 h1:Qt8FeAtxE/vfdrLmR3rxR6JRE0RoVmbXu8+6kZtYU4k= github.com/urfave/cli/v2 v2.1.1/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8= github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/vito/go-interact v0.0.0-20171111012221-fa338ed9e9ec h1:Klu98tQ9Z1t23gvC7p7sCmvxkZxLhBHLNyrUPsWsYFg= github.com/vito/go-interact v0.0.0-20171111012221-fa338ed9e9ec/go.mod h1:wPlfmglZmRWMYv/qJy3P+fK/UnoQB5ISk4txfNd9tDo= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v1.0.0 h1:d9X0esnoa3dFsV0FG35rAT0RIhYFlPq7MiP+DW89La0= +github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.1.0 h1:ngVtJC9TY/lg0AA/1k48FYhBrhRoFlEmWzsehpNAaZg= +github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +github.com/xenolf/lego v0.0.0-20160613233155-a9d8cec0e656/go.mod h1:fwiGnfsIjG7OHPfOvgK7Y/Qo6+2Ox0iozjNTkZICKbY= +github.com/xenolf/lego v0.3.2-0.20160613233155-a9d8cec0e656/go.mod h1:fwiGnfsIjG7OHPfOvgK7Y/Qo6+2Ox0iozjNTkZICKbY= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yvasiyarov/go-metrics v0.0.0-20150112132944-c25f46c4b940/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= +github.com/yvasiyarov/gorelic v0.0.6/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.mongodb.org/mongo-driver v1.1.3 h1:++7u8r9adKhGR+I79NfEtYrk2ktjenErXM99PSufIoI= +go.mongodb.org/mongo-driver v1.1.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.uber.org/atomic v0.0.0-20181018215023-8dc6146f7569/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v0.0.0-20180122172545-ddea229ff1df/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v0.0.0-20180814183419-67bc79d13d15/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190130090550-b01c7a725664/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8 h1:1wopBVtVdWnn03fZelqdXTqk7U7zPQCb+T4rbU9ZEoU= +golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191028145041-f83a4685e152/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191205161847-0a08dada0ff9 h1:abxekknhS/Drh3uoQDk5Hc7BgeiyI39Crb7vhf/1j5s= +golang.org/x/crypto v0.0.0-20191205161847-0a08dada0ff9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/image v0.0.0-20180926015637-991ec62608f3/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190225153610-fe579d43d832/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190611141213-3f473d35a33a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190812203447-cdfb69ac37fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191204025024-5ee1b9f4859a h1:+HHJiFUXVOIS9mr1ThqkQD1N8vpFCfCShqADBM12KTc= +golang.org/x/net v0.0.0-20191028085509-fe3aa8a45271 h1:N66aaryRB3Ax92gH0v3hp1QYZ3zWWCCUR/j8Ifh45Ss= +golang.org/x/net v0.0.0-20191028085509-fe3aa8a45271/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191204025024-5ee1b9f4859a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180928133829-e4b3c5e90611/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181228144115-9a3f9b0469bb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190610200419-93c9922d18ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191028164358-195ce5e7f934 h1:u/E0NqCIWRDAo9WCFo6Ko49njPFDLSd3z+X1HgWDMpE= +golang.org/x/sys v0.0.0-20191028164358-195ce5e7f934/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8= golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a h1:i47hUS795cOydZI4AwJQCKXOr4BvxzvikwDoDtHhP2Y= -golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606050223-4d9ae51c2468/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190611222205-d73e1c7e250b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac h1:MQEvx39qSf8vyrx3XRaOe+j1UDIzKwkYOVObRgGPVqI= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191205060818-73c7173a9f7d h1:HjXQhd1u/svlhQb0V71w0I7RKZAI5Vd1lp/4FscZcJ4= golang.org/x/tools v0.0.0-20191205060818-73c7173a9f7d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200903153655-76a6aac657c7 h1:Qfjfs7m85z50QOyvTljbTKwabUec5fklxtRYO6lDRAs= -golang.org/x/tools v0.0.0-20200903153655-76a6aac657c7/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= +gonum.org/v1/gonum v0.0.0-20190331200053-3d26580ed485/go.mod h1:2ltnJ7xHfj0zHS40VVPYEAAMTa3ZGguvHGBSJeRWqE0= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/netlib v0.0.0-20190331212654-76723241ea4e/go.mod h1:kS+toOQn6AQKjmKJ7gzohV1XkqsFehRA2FbsbkopSuQ= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190128161407-8ac453e89fca/go.mod h1:L3J43x8/uS+qIUoksaLKe6OS3nUKxOKuIFz1sl2/jx4= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20191028173616-919d9bdd9fe6 h1:UXl+Zk3jqqcbEVV7ace5lrt4YdA4tXiz3f/KbmD29Vo= +google.golang.org/genproto v0.0.0-20191028173616-919d9bdd9fe6/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.24.0 h1:vb/1TCsVn3DcJlQ0Gs1yB1pKI6Do2/QNwxdKqmc/b0s= +google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/cheggaaa/pb.v1 v1.0.27 h1:kJdccidYzt3CaHD1crCFTS1hxyhSi059NhOFUf03YFo= gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/square/go-jose.v1 v1.1.2/go.mod h1:QpYS+a4WhS+DTlyQIi6Ka7MS3SuR9a055rgXNEe6EiA= +gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +helm.sh/helm/v3 v3.0.0 h1:or/9cs1GgfcTQeEnR2CVJNw893/rmqIG1KsNHmUiSFw= +helm.sh/helm/v3 v3.0.0/go.mod h1:sI7B9yfvMgxtTPMWdk1jSKJ2aa59UyP9qhPydqW6mgo= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20190822140433-26a664648505/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/helm v2.13.1+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= +k8s.io/helm v2.16.1+incompatible h1:L+k810plJlaGWEw1EszeT4deK8XVaKxac1oGcuB+WDc= +k8s.io/helm v2.16.1+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= +k8s.io/helm v2.16.10+incompatible h1:eFksERw3joHEL62TrcDX8I5fgEQJvit4qxxPXAkYTyQ= +k8s.io/helm v2.16.10+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.4.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/kube-openapi v0.0.0-20190816220812-743ec37842bf/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kube-openapi v0.0.0-20190918143330-0270cf2f1c1d h1:Xpe6sK+RY4ZgCTyZ3y273UmFmURhjtoJiwOMbQsXitY= +k8s.io/kube-openapi v0.0.0-20190918143330-0270cf2f1c1d/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kubernetes/staging/src/k8s.io/api v0.0.0-20191001043732-d647ddbd755f h1:qnPdWj5mRMsfvP85N8J2ogqiu8Aq1T6MPsJdxL3g6Ds= +k8s.io/kubernetes/staging/src/k8s.io/api v0.0.0-20191001043732-d647ddbd755f/go.mod h1:cHpnPcbNeE90PrTRnTu13OM+FN+ROt82odVbEh++81o= +k8s.io/kubernetes/staging/src/k8s.io/apiextensions-apiserver v0.0.0-20191001043732-d647ddbd755f h1:bpyOu4+qNIFZRKRtSXGv/iJ7YzqwXrAOoaKxUaYKrV4= +k8s.io/kubernetes/staging/src/k8s.io/apiextensions-apiserver v0.0.0-20191001043732-d647ddbd755f/go.mod h1:f1tFT2pOqPzfckbG1GjHIzy3G+T2LW7rchcruNoLaiM= +k8s.io/kubernetes/staging/src/k8s.io/apimachinery v0.0.0-20191001043732-d647ddbd755f h1:X3br+JCtf40mnzQsKAnHnezd1CvCENgG5uLJTbAspZ4= +k8s.io/kubernetes/staging/src/k8s.io/apimachinery v0.0.0-20191001043732-d647ddbd755f/go.mod h1:PNw+FbGH4/s3zK9V3rAeMiHTbQz2CU/yqAkfQ2UgLVs= +k8s.io/kubernetes/staging/src/k8s.io/apiserver v0.0.0-20191001043732-d647ddbd755f/go.mod h1:WmFoxjELD2xtWb77Yj9RPibT5ACkQYEW9lPQtNkGtbE= +k8s.io/kubernetes/staging/src/k8s.io/cli-runtime v0.0.0-20191001043732-d647ddbd755f h1:6CkT409OUoX4ZiP++1N3id3PCcOoktBvclNsDKPKrfc= +k8s.io/kubernetes/staging/src/k8s.io/cli-runtime v0.0.0-20191001043732-d647ddbd755f/go.mod h1:nBogvbgjMgo7AeVA6CuqVO13LVIfmlQ11t6xzAJdBN8= +k8s.io/kubernetes/staging/src/k8s.io/client-go v0.0.0-20191001043732-d647ddbd755f h1:ksJC2cpBqkCP8bzmfDYXr65JRpt9JmANvaKIR3qggt4= +k8s.io/kubernetes/staging/src/k8s.io/client-go v0.0.0-20191001043732-d647ddbd755f/go.mod h1:GiGfbsjtP4tOW6zgpL8/vCUoyXAV5+9X2onLursPi08= +k8s.io/kubernetes/staging/src/k8s.io/code-generator v0.0.0-20191001043732-d647ddbd755f/go.mod h1:L8deZCu6NpzgKzY91TOGKJ1JtAoHd8WyJ/HdoxqZCGo= +k8s.io/kubernetes/staging/src/k8s.io/component-base v0.0.0-20191001043732-d647ddbd755f/go.mod h1:spPP+vRNS8EsnNNIhFCZTTuRO3XhV1WoF18HJySoZn8= +k8s.io/kubernetes/staging/src/k8s.io/kubectl v0.0.0-20191001043732-d647ddbd755f h1:vH4+rTRLDI8z9dQCZ6cJcIi3RMGZ6JwJWyLbrSNHBCE= +k8s.io/kubernetes/staging/src/k8s.io/kubectl v0.0.0-20191001043732-d647ddbd755f/go.mod h1:ellVfoCz8MlDjTnkqsTkU5svJOIjcK3XNx/onmixgDk= +k8s.io/kubernetes/staging/src/k8s.io/metrics v0.0.0-20191001043732-d647ddbd755f/go.mod h1:vQHTmz0IaEb7/OXPSor1uga8Er0V+2M5aSdXG832NbU= +k8s.io/utils v0.0.0-20190801114015-581e00157fb1/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20191010214722-8d271d903fe4 h1:Gi+/O1saihwDqnlmC8Vhv1M5Sp4+rbOmK9TbsLn8ZEA= +k8s.io/utils v0.0.0-20191010214722-8d271d903fe4/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +modernc.org/cc v1.0.0/go.mod h1:1Sk4//wdnYJiUIxnW8ddKpaOJCF37yAdqYnkxUpaYxw= +modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk= +modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k= +modernc.org/strutil v1.0.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= +modernc.org/xc v1.0.0/go.mod h1:mRNCo0bvLjGhHO9WsyuKVU4q0ceiDDDoEeWDJHrNx8I= +rsc.io/letsencrypt v0.0.1/go.mod h1:buyQKZ6IXrRnB7TdkHP0RyEybLx18HHyOSoTyoOLqNY= +sigs.k8s.io/kustomize v2.0.3+incompatible h1:JUufWFNlI44MdtnjUqVnvh29rR37PQFzPbLXqhyOyX0= +sigs.k8s.io/kustomize v2.0.3+incompatible/go.mod h1:MkjgH3RdOWrievjo6c9T245dYlB5QeXV4WCbnt/PEpU= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/structured-merge-diff v0.0.0-20190817042607-6149e4549fca/go.mod h1:IIgPezJWb76P0hotTxzDbWsMYB8APh18qZnxkomBpxA= +sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/src/jetstream/passthrough.go b/src/jetstream/passthrough.go index f711c63b43..ca1bd2f567 100644 --- a/src/jetstream/passthrough.go +++ b/src/jetstream/passthrough.go @@ -221,7 +221,7 @@ func (p *portalProxy) proxy(c echo.Context) error { } func (p *portalProxy) ProxyRequest(c echo.Context, uri *url.URL) (map[string]*interfaces.CNSIRequest, error) { - log.Debug("proxy") + log.Debug("ProxyRequest") cnsiList := strings.Split(c.Request().Header.Get("x-cap-cnsi-list"), ",") shouldPassthrough := "true" == c.Request().Header.Get("x-cap-passthrough") longRunning := "true" == c.Request().Header.Get(longRunningTimeoutHeader) diff --git a/src/jetstream/plugins/analysis/20200210105400_Analysis.go b/src/jetstream/plugins/analysis/20200210105400_Analysis.go new file mode 100644 index 0000000000..f27d6bf873 --- /dev/null +++ b/src/jetstream/plugins/analysis/20200210105400_Analysis.go @@ -0,0 +1,43 @@ +package analysis + +import ( + "database/sql" + + "bitbucket.org/liamstask/goose/lib/goose" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore" +) + +func init() { + datastore.RegisterMigration(20200210105400, "Analysis", func(txn *sql.Tx, conf *goose.DBConf) error { + + createAnalysisTabls := "CREATE TABLE IF NOT EXISTS analysis (" + createAnalysisTabls += "id VARCHAR(255) NOT NULL," + createAnalysisTabls += "endpoint VARCHAR(36) NOT NULL," + createAnalysisTabls += "endpoint_type VARCHAR(36) NOT NULL," + createAnalysisTabls += "name VARCHAR(255) NOT NULL," + createAnalysisTabls += "user VARCHAR(36) NOT NULL," + createAnalysisTabls += "path VARCHAR(255) NOT NULL," + createAnalysisTabls += "type VARCHAR(64) NOT NULL," + createAnalysisTabls += "format VARCHAR(64) NOT NULL," + createAnalysisTabls += "created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," + createAnalysisTabls += "acknowledged BOOLEAN NOT NULL DEFAULT FALSE," + createAnalysisTabls += "status VARCHAR(16) NOT NULL," + createAnalysisTabls += "duration INT NOT NULL DEFAULT 0," + createAnalysisTabls += "result VARCHAR(255) NOT NULL," + createAnalysisTabls += "PRIMARY KEY (id) );" + + _, err := txn.Exec(createAnalysisTabls) + if err != nil { + return err + } + + // createIndex := "CREATE INDEX charts_id ON charts (id);" + // _, err = txn.Exec(createIndex) + // if err != nil { + // return err + // } + + return nil + }) +} diff --git a/src/jetstream/plugins/analysis/container/Dockerfile b/src/jetstream/plugins/analysis/container/Dockerfile new file mode 100644 index 0000000000..b9863a323a --- /dev/null +++ b/src/jetstream/plugins/analysis/container/Dockerfile @@ -0,0 +1,61 @@ +FROM splatform/stratos-bk-build-base:leap15_1 as builder + +# Build the API Server for the analysis engines + +RUN mkdir -p /home/stratos/go/src +WORKDIR /home/stratos/go/src +COPY --chown=stratos:users . /home/stratos/go/src +ARG VERSION=1.0.0 +RUN GO111MODULE=on go build -o stratos-analyzers -ldflags -X=main.appVersion=${VERSION} + +# Download the Analysis tools +WORKDIR /home/stratos/analysis +WORKDIR /home/stratos/tmp +USER root + +# Analyzers ==================================================================================================================== + + +# Popeye +ARG POPEYE_VERSION=0.6.2 +# Download archive - popeye executable is in main dir - move it to the analysis folder +RUN wget https://github.com/derailed/popeye/releases/download/v${POPEYE_VERSION}/popeye_${POPEYE_VERSION}_Linux_x86_64.tar.gz && \ + tar -xvf popeye_${POPEYE_VERSION}_Linux_x86_64.tar.gz && \ + mv popeye ../analysis + +# Kube-score +ARG KUBESCORE_VERSION=1.5.0 +RUN wget https://github.com/zegl/kube-score/releases/download/v${KUBESCORE_VERSION}/kube-score_${KUBESCORE_VERSION}_linux_amd64.tar.gz && \ + tar -xvf kube-score_${KUBESCORE_VERSION}_linux_amd64.tar.gz && \ + mv kube-score ../analysis + +# Sonobuoy +# ARG SONOBUOY_VERSION=0.17.2 +# RUN wget https://github.com/vmware-tanzu/sonobuoy/releases/download/v${SONOBUOY_VERSION}/sonobuoy_${SONOBUOY_VERSION}_linux_amd64.tar.gz && \ +# tar -xvf sonobuoy_${SONOBUOY_VERSION}_linux_amd64.tar.gz && \ +# mv sonobuoy ../analysis + +# Need kubectl for Kubescore - TODO: Use correct version depending on cluster +ARG KUBECTL_VERSION=1.16.2 +RUN wget https://storage.googleapis.com/kubernetes-release/release/v${KUBECTL_VERSION}/bin/linux/amd64/kubectl && \ + chmod +x kubectl && \ + mv kubectl ../analysis + +# klar +# ARG KLAR_VERSION=2.4.0 +# RUN wget https://github.com/optiopay/klar/releases/download/v${KLAR_VERSION}/klar-${KLAR_VERSION}-linux-amd64 && \ +# mv klar-${KLAR_VERSION}-linux-amd64 klar && \ +# chmod +x klar && \ +# mv klar ../analysis + +# Final Container ============================================================================================================= + +FROM splatform/stratos-bk-base:leap15_1 + +# Copy tools to the /usr/bin folder so that they are in the path +COPY --from=builder /home/stratos/analysis /usr/bin +COPY --from=builder /home/stratos/go/src/stratos-analyzers /stratos-analyzers +COPY ./scripts /scripts +RUN mkdir /reports + +CMD ["/stratos-analyzers"] diff --git a/src/jetstream/plugins/analysis/container/go.mod b/src/jetstream/plugins/analysis/container/go.mod new file mode 100644 index 0000000000..d9101c6c6c --- /dev/null +++ b/src/jetstream/plugins/analysis/container/go.mod @@ -0,0 +1,12 @@ +module analyzers + +go 1.13 + +require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect + github.com/labstack/echo v3.3.10+incompatible + github.com/labstack/gommon v0.3.0 // indirect + github.com/sirupsen/logrus v1.4.2 + github.com/valyala/fasttemplate v1.1.0 // indirect + golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d // indirect +) diff --git a/src/jetstream/plugins/analysis/container/go.sum b/src/jetstream/plugins/analysis/container/go.sum new file mode 100644 index 0000000000..ae076adf3a --- /dev/null +++ b/src/jetstream/plugins/analysis/container/go.sum @@ -0,0 +1,48 @@ +github.com/cloudfoundry-incubator/stratos v2.0.0-beta-001+incompatible h1:UUxNbLjhv2cfymub5yNN1tjjqYkteHBBagb4jcbXEIQ= +github.com/cloudfoundry-incubator/stratos/src/jetstream v0.0.0-20200222120421-390cf0f6670b h1:52Py09Cmdnyxr750Tj5InffbWJpCDTWie0RCbxxoUAA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= +github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= +github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.1.0 h1:RZqt0yGBsps8NGvLSGW804QQqCUYYLsaOjTVHy1Ocw4= +github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/src/jetstream/plugins/analysis/container/kubescore.go b/src/jetstream/plugins/analysis/container/kubescore.go new file mode 100644 index 0000000000..959e15964b --- /dev/null +++ b/src/jetstream/plugins/analysis/container/kubescore.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "time" + + log "github.com/sirupsen/logrus" +) + +func runKubeScore(job *AnalysisJob) error { + + log.Debug("Running kube-score job") + + job.Busy = true + job.Type = "kubescore" + job.Format = "kubescore" + setJobNameAndPath(job, "Kube-score") + + scriptPath := filepath.Join(getScriptFolder(), "kubescore-runner.sh") + args := []string{scriptPath, job.KubeConfigPath, job.Config.Namespace} + + log.Infof("Running kube score job: %s", job.Path) + + go func() { + // Use our custom script which is a wrapper around kubescore + cmd := exec.Command("bash", args...) + cmd.Dir = job.Folder + cmd.Env = make([]string, 0) + cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", job.KubeConfigPath)) + + start := time.Now() + out, err := cmd.Output() + end := time.Now() + + log.Infof("Completed kube score job: %s", job.Path) + + // Remove any config files when done + job.RemoveTempFiles() + + job.Duration = int(end.Sub(start).Seconds()) + + if err != nil { + // There was an error + // Remove the folder + os.Remove(job.Folder) + job.Status = "error" + } else { + reportFile := filepath.Join(job.Folder, "report.log") + ioutil.WriteFile(reportFile, out, os.ModePerm) + job.Status = "completed" + } + }() + + return nil +} diff --git a/src/jetstream/plugins/analysis/container/main.go b/src/jetstream/plugins/analysis/container/main.go new file mode 100644 index 0000000000..d14bed2ece --- /dev/null +++ b/src/jetstream/plugins/analysis/container/main.go @@ -0,0 +1,171 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/labstack/echo/middleware" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +const ( + defaultPort = 8090 + defaultAddress = "0.0.0.0" + reportsDirEnvVar = "ANALYSIS_REPORTS_DIR" + scriptsDirEnvVar = "ANALYSIS_SCRIPTS_DIR" +) + +type Analyzer struct { + reportsDir string + jobs map[string]*AnalysisJob +} + +func main() { + log.SetFormatter(&log.TextFormatter{ForceColors: true, FullTimestamp: true, TimestampFormat: time.UnixDate}) + + log.SetOutput(os.Stdout) + + log.Info("========================================") + log.Info("=== Stratos Analysis API Server ===") + log.Info("========================================") + log.Info("") + log.Info("Initialization started.") + + analyzer := Analyzer{} + analyzer.jobs = make(map[string]*AnalysisJob) + + analyzer.Start() +} + +func (a *Analyzer) Start() { + + // Reports folder + + // Init reports directory + if reportsDir, ok := os.LookupEnv(reportsDirEnvVar); ok { + dir, err := filepath.Abs(reportsDir) + if err != nil { + log.Fatal("Can not get absolute path for reports folder") + } + a.reportsDir = dir + } else { + a.reportsDir = filepath.Join(os.TempDir(), "stratos-analysis") + } + log.Infof("Using reports folder: %s", a.reportsDir) + + // Make the directory if it does not exit + if _, err := os.Stat(a.reportsDir); os.IsNotExist(err) { + if os.MkdirAll(a.reportsDir, os.ModePerm) != nil { + log.Fatal("Could not create folder for analysis reports") + } + } + + // Start a simple web server + e := echo.New() + e.HideBanner = true + e.HidePort = true + customLoggerConfig := middleware.LoggerConfig{ + Format: `Request: [${time_rfc3339}] Remote-IP:"${remote_ip}" ` + + `Method:"${method}" Path:"${path}" Status:${status} Latency:${latency_human} ` + + `Bytes-In:${bytes_in} Bytes-Out:${bytes_out}` + "\n", + } + e.Use(middleware.LoggerWithConfig(customLoggerConfig)) + e.Use(middleware.Recover()) + + a.registerRoutes(e) + + var engineErr error + address := fmt.Sprintf("%s:%d", defaultAddress, defaultPort) + log.Infof("Starting HTTP Server at address: %s", address) + engineErr = e.Start(address) + + if engineErr != nil { + engineErrStr := fmt.Sprintf("%s", engineErr) + if !strings.Contains(engineErrStr, "Server closed") { + log.Warnf("Failed to start HTTP/S server: %+v", engineErr) + } + } +} + +func (a *Analyzer) registerRoutes(e *echo.Echo) { + api := e.Group("/api") + api.Use(setSecureCacheContentMiddleware) + + // Liveness check + api.GET("/v1/ping", a.ping) + // Run the given analyzer + api.POST("/v1/run/:analyzer", a.run) + // Get status + api.POST("/v1/status", a.status) + // Get a report + api.GET("/v1/report/:user/:endpoint/:id/:file", a.report) + // Delete a report + api.DELETE("/v1/report/:user/:endpoint/:id", a.delete) + // Delete all reports for an endpoint + api.DELETE("/v1/report/:endpoint", a.deleteEndpoint) +} + +func setSecureCacheContentMiddleware(h echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Response().Header().Set("cache-control", "no-store") + c.Response().Header().Set("pragma", "no-cache") + return h(c) + } +} + +// Set the name of the job +func setJobNameAndPath(job *AnalysisJob, title string) { + job.Name = fmt.Sprintf("%s cluster analysis", title) + job.Path = "" + + log.Info("setJobNameAndPath") + log.Infof("%+v", job.Config) + + if job.Config != nil { + if len(job.Config.Namespace) > 0 { + if len(job.Config.App) > 0 { + job.Name = fmt.Sprintf("%s workload analysis: %s in %s", title, job.Config.App, job.Config.Namespace) + job.Path = fmt.Sprintf("%s/%s", job.Config.Namespace, job.Config.App) + } else { + job.Name = fmt.Sprintf("%s namespace analysis: %s", title, job.Config.Namespace) + job.Path = job.Config.Namespace + } + } + } +} + +func getScriptFolder() string { + fallbackPath, err := os.Getwd() + if err != nil { + fallbackPath = "." + } + + // Look first at the env var, then at a relative path to the executable + if dir, ok := os.LookupEnv(scriptsDirEnvVar); ok { + return dir + } + + // Relative to the executable + dir, err := filepath.Abs(filepath.Dir(os.Args[0])) + if err != nil { + log.Error("Could not get folder of the running program") + return fallbackPath + } + + scripts := filepath.Join(dir, "scripts") + if _, err := os.Stat(scripts); !os.IsNotExist(err) { + return scripts + } + + scripts = filepath.Join(dir, "plugins±", "analysis", "container", "scripts") + if _, err := os.Stat(scripts); !os.IsNotExist(err) { + return scripts + } + + log.Error("Unable to locate scripts folder") + return fallbackPath +} diff --git a/src/jetstream/plugins/analysis/container/popeye.go b/src/jetstream/plugins/analysis/container/popeye.go new file mode 100644 index 0000000000..209224a92e --- /dev/null +++ b/src/jetstream/plugins/analysis/container/popeye.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "errors" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "time" + + log "github.com/sirupsen/logrus" +) + +type popEyeSummary struct { + Score int `json:"score"` + Grade string `json:"grade"` +} + +type popEyeResult struct { + PopEye popEyeSummary `json:"popeye"` +} + +func runPopeye(job *AnalysisJob) error { + + log.Debug("Running popeye job") + + job.Busy = true + job.Type = "popeye" + job.Format = "popeye" + setJobNameAndPath(job, "Popeye") + + log.Infof("Running popeye job: %s", job.Path) + + args := []string{"--kubeconfig", job.KubeConfigPath, "-o", "json", "--insecure-skip-tls-verify"} + if len(job.Config.Namespace) > 0 { + args = append(args, "-n") + args = append(args, job.Config.Namespace) + } else { + args = append(args, "-A") + } + + go func() { + cmd := exec.Command("popeye", args...) + cmd.Dir = job.Folder + + start := time.Now() + out, err := cmd.Output() + end := time.Now() + job.EndTime = end + + job.Busy = false + + log.Infof("Completed kube score job: %s", job.Path) + + // Remove any config files when done + job.RemoveTempFiles() + + job.Duration = int(end.Sub(start).Seconds()) + + if err != nil { + // There was an error + // Remove the folder + os.Remove(job.Folder) + job.Status = "error" + } else { + reportFile := filepath.Join(job.Folder, "report.json") + ioutil.WriteFile(reportFile, out, os.ModePerm) + job.Status = "completed" + + // Parse the report + if summary, err := parsePopeyeReport(reportFile); err == nil { + job.Result = serializePopeyeReport(summary) + } + } + }() + + return nil +} + +func parsePopeyeReport(file string) (*popEyeSummary, error) { + jsonFile, err := os.Open(file) + if err != nil { + return nil, err + } + defer jsonFile.Close() + + data, err := ioutil.ReadAll(jsonFile) + if err != nil { + return nil, err + } + + result := popEyeResult{} + if err = json.Unmarshal(data, &result); err != nil { + return nil, errors.New("Failed to parse Popeye report") + } + + return &result.PopEye, nil +} + +func serializePopeyeReport(summary *popEyeSummary) string { + jsonString, err := json.Marshal(summary) + if err != nil { + return "" + } + + return string(jsonString) +} diff --git a/src/jetstream/plugins/analysis/container/routes.go b/src/jetstream/plugins/analysis/container/routes.go new file mode 100644 index 0000000000..95ed7b77d4 --- /dev/null +++ b/src/jetstream/plugins/analysis/container/routes.go @@ -0,0 +1,90 @@ +package main + +import ( + "errors" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +// Ping endpoint +func (a *Analyzer) ping(ec echo.Context) error { + return nil +} + +// Get a given report +func (a *Analyzer) report(ec echo.Context) error { + + user := ec.Param("user") + endpoint := ec.Param("endpoint") + id := ec.Param("id") + name := ec.Param("file") + + // Name must end in json - we only serve json files + if !strings.HasSuffix(name, ".json") { + return errors.New("Can't serve that file") + } + + file := filepath.Join(a.reportsDir, user, endpoint, id, name) + _, err := os.Stat(file) + if os.IsNotExist(err) { + return echo.NewHTTPError(404, "No such file") + } + + return ec.File(file) +} + +// Delete a given report +func (a *Analyzer) delete(ec echo.Context) error { + log.Debug("delete report") + + user := ec.Param("user") + endpoint := ec.Param("endpoint") + id := ec.Param("id") + folder := filepath.Join(a.reportsDir, user, endpoint, id) + if err := os.RemoveAll(folder); err != nil { + log.Warnf("Could not delete Analysis report folder: %s", folder) + return echo.NewHTTPError(http.StatusInternalServerError, "Could not delete report") + } + + return nil +} + +// Delete all reports for a given endpoint +func (a *Analyzer) deleteEndpoint(ec echo.Context) error { + log.Debug("delete reports for endpoint") + + endpoint := ec.Param("endpoint") + + // Iterate over all user folders + if items, err := ioutil.ReadDir(a.reportsDir); err == nil { + for _, item := range items { + if item.IsDir() { + // This is a user's folder - see if they have a folder for the endpoint + folder := filepath.Join(a.reportsDir, item.Name(), endpoint) + if folderExists(folder) { + if err := os.RemoveAll(folder); err != nil { + log.Warnf("Could not delete Analysis report endpoint folder: %s", folder) + } + } + } + } + } else { + return echo.NewHTTPError(http.StatusInternalServerError, "Error deleteing reports") + } + + return nil +} + +func folderExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return info.IsDir() +} diff --git a/src/jetstream/plugins/analysis/container/run.go b/src/jetstream/plugins/analysis/container/run.go new file mode 100644 index 0000000000..38f33e5f24 --- /dev/null +++ b/src/jetstream/plugins/analysis/container/run.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +const idHeaderName = "X-Stratos-Analaysis-ID" + +func (a *Analyzer) run(ec echo.Context) error { + err := a.doRun(ec) + if err != nil { + log.Error(err) + } + return err +} + +func (a *Analyzer) doRun(ec echo.Context) error { + + log.Debug("Run analyzer!") + + engine := ec.Param("analyzer") + if len(engine) == 0 { + log.Warn("No analyzer") + return errors.New("No analyzer specified") + } + + // ID is username/endpoint/id + id := ec.Request().Header.Get(idHeaderName) + if len(id) == 0 { + return errors.New("Mising ID header") + } + + folder := filepath.Join(a.reportsDir, id) + if os.MkdirAll(folder, os.ModePerm) != nil { + return errors.New("Could not create folder for analysis report") + } + + tempFiles := make([]string, 0) + reader, err := ec.Request().MultipartReader() + if err != nil { + log.Error("Could not parse request") + log.Error(err) + return errors.New("Failed to parse request payload") + } + + job := AnalysisJob{} + params := kubeAnalyzerConfig{} + + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + log.Error("Unexpected error when retrieving a part of the message") + return errors.New("Unexpected error when retrieving a part of the message") + } + defer part.Close() + fileBytes, err := ioutil.ReadAll(part) + if err != nil { + log.Error("Failed to read content of the part") + return errors.New("Failed to read content of the part") + } + filename := part.Header.Get("Content-ID") + + // Decide what to do with the part + switch filename { + case "job": + if err = json.Unmarshal(fileBytes, &job); err != nil { + return fmt.Errorf("Can not parse Job: %v", err) + } + case "body": + if err = json.Unmarshal(fileBytes, ¶ms); err != nil { + return fmt.Errorf("Can not parse parameters: %v", err) + } + job.Config = ¶ms + default: + fullpath := filepath.Join(folder, filename) + if err = ioutil.WriteFile(fullpath, fileBytes, os.ModePerm); err != nil { + log.Error("Could not write data for: %s", filename) + return fmt.Errorf("Could not write file data for: %s", filename) + } + if filename == "kubeconfig" { + job.KubeConfigPath = fullpath + } + tempFiles = append(tempFiles, fullpath) + } + } + + if len(job.ID) == 0 { + return errors.New("Invalid Job metadata supplied") + } + + job.Folder = folder + job.TempFiles = tempFiles + + // Store the job so we track which jobs are running + a.jobs[job.ID] = &job + + job.Status = "running" + + switch engine { + case "popeye": + err = runPopeye(&job) + case "kube-score": + err = runKubeScore(&job) + // case "sonobuoy": + // runSonobuoy(dbStore, file, folder, report, requestBody) + default: + job.Status = "error" + return fmt.Errorf("Unkown analyzer: %s", engine) + } + + if err != nil { + job.Status = "error" + log.Error("Error running analyzer: %s", err) + } + + return ec.JSON(http.StatusOK, job) +} diff --git a/src/jetstream/plugins/analysis/container/scripts/kubescore-runner.sh b/src/jetstream/plugins/analysis/container/scripts/kubescore-runner.sh new file mode 100755 index 0000000000..2763b3008f --- /dev/null +++ b/src/jetstream/plugins/analysis/container/scripts/kubescore-runner.sh @@ -0,0 +1,16 @@ +ARGS="--all-namespaces" + +if [ -n "$2" ]; then + ARGS="-n ${2}" +fi + +# $1 is the kubeconfig file + +echo "Kubescore runner..." +echo "Running report..." + +kubectl api-resources --verbs=list --namespaced -o name \ + | xargs -n1 -I{} bash -c "kubectl get {} $ARGS -oyaml && echo ---" \ + | kube-score score -o json - > report.json + +exit 0 diff --git a/src/jetstream/plugins/analysis/container/scripts/sonobuoy-runner.sh b/src/jetstream/plugins/analysis/container/scripts/sonobuoy-runner.sh new file mode 100755 index 0000000000..8565beed6f --- /dev/null +++ b/src/jetstream/plugins/analysis/container/scripts/sonobuoy-runner.sh @@ -0,0 +1,19 @@ +# $1 is the kubeconfig file + +echo "Sonobuoy runner..." +env +echo "Args" +echo $@ + +echo "Running report..." + +# Run the report and wait +sonobuoy run --wait + +# Retrieve the report + +# Teardown sonobuoy + +# Unpack the report and copy the junit report to report.json at the top-level + +exit 0 diff --git a/src/jetstream/plugins/analysis/container/sonobuoy.go_ b/src/jetstream/plugins/analysis/container/sonobuoy.go_ new file mode 100644 index 0000000000..80be589427 --- /dev/null +++ b/src/jetstream/plugins/analysis/container/sonobuoy.go_ @@ -0,0 +1,92 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/analysis/store" + log "github.com/sirupsen/logrus" +) + +func runSonobuoy(dbStore store.AnalysisStore, kubeconfig, folder string, report store.AnalysisRecord, body []byte) error { + path := "" + namespace := "" + options := &popeyeConfig{} + if err := json.Unmarshal(body, options); err == nil { + namespace = options.Namespace + path = namespace + + if len(options.App) > 0 { + path = fmt.Sprintf("%s/%s", path, options.App) + } + } + report.Name = "Sonobuoy cluster analysis" + report.Type = "sonobuoy" + report.Format = "junit" + + scriptPath := filepath.Join(getScriptFolder(), "sonobuoy-runner.sh") + args := []string{scriptPath, kubeconfig, namespace} + log.Error(scriptPath) + + report.Path = path + parts := len(strings.Split(path, "/")) + if parts == 2 { + report.Name = fmt.Sprintf("Sonobuoy workload analysis: %s in %s", options.App, namespace) + } else if parts == 1 && len(namespace) > 0 { + report.Name = fmt.Sprintf("Sonobuoy namespace analysis: %s", namespace) + } + + _, err := dbStore.Save(report) + if err != nil { + return err + } + + go func() { + // Use our custom script which is a wrapper around kubescore + cmd := exec.Command("bash", args...) + cmd.Dir = folder + cmd.Env = make([]string, 0) + cmd.Env = append(cmd.Env, fmt.Sprintf("KUBECONFIG=%s", kubeconfig)) + log.Info(kubeconfig) + + start := time.Now() + out, err := cmd.Output() + end := time.Now() + + // Remove the config file when we are done + //os.Remove(kubeconfig) + + if err != nil { + // There was an error + // Remove the folder + os.Remove(folder) + log.Error(">>>>>>>>> ERROR <<<<<<<<<") + log.Error(string(out)) + log.Error(err) + report.Status = "error" + } else { + report.Status = "completed" + + // Parse the report + // if summary, err := parsePopeyeReport(reportFile); err == nil { + // report.Result = serializePopeyeReport(summary) + // } + + // Write stdout to log file + reportFile := filepath.Join(folder, "report.log") + ioutil.WriteFile(reportFile, out, os.ModePerm) + } + + report.Duration = int(end.Sub(start).Seconds()) + + dbStore.UpdateReport(report.UserID, &report) + }() + + return nil +} diff --git a/src/jetstream/plugins/analysis/container/status.go b/src/jetstream/plugins/analysis/container/status.go new file mode 100644 index 0000000000..9e5553c464 --- /dev/null +++ b/src/jetstream/plugins/analysis/container/status.go @@ -0,0 +1,74 @@ +package main + +import ( + "encoding/json" + "errors" + "io/ioutil" + + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +func (a *Analyzer) status(ec echo.Context) error { + err := a.doStatus(ec) + if err != nil { + log.Error(err) + } + return err +} + +func (a *Analyzer) doStatus(ec echo.Context) error { + log.Debug("Status") + req := ec.Request() + + // Body contains an array of IDs that the client thinks are running + // We send back updated status for each + + // Get the list of IDs + defer req.Body.Close() + body, err := ioutil.ReadAll(req.Body) + if err != nil { + return errors.New("Could not read body") + } + + ids := make([]string, 0) + if err := json.Unmarshal(body, &ids); err != nil { + return errors.New("Failed to parse body") + } + + response := make(map[string]AnalysisJob) + for _, id := range ids { + if a.jobs[id] == nil { + // Client has a running job that we know nothing about - so must be an error + job := AnalysisJob{ + ID: id, + Status: "error", + } + response[id] = job + } else { + response[id] = *a.jobs[id] + } + } + + // Go through all of the jobs we have and increment the cleanup counter of those that are finished + // Assume after 5 requests to the status API that the caller has the info they need for the completed job + // and remove it + cleanup := make([]string, 0) + for id, job := range a.jobs { + // If the job has finished, increment the cleanup counter + // We will remove it from our cache once we are pretty sure Jetstream has the status + if !job.Busy { + job.CleanupCounter = job.CleanupCounter + 1 + if job.CleanupCounter > 5 { + cleanup = append(cleanup, id) + } + } + } + + for _, id := range cleanup { + delete(a.jobs, id) + } + + ec.JSON(200, response) + return nil +} diff --git a/src/jetstream/plugins/analysis/container/types.go b/src/jetstream/plugins/analysis/container/types.go new file mode 100644 index 0000000000..fb9de49c8c --- /dev/null +++ b/src/jetstream/plugins/analysis/container/types.go @@ -0,0 +1,48 @@ +package main + +import ( + "encoding/json" + "os" + "time" + + log "github.com/sirupsen/logrus" +) + +type kubeAnalyzerConfig struct { + Namespace string `json:"namespace"` + App string `json:"app"` +} + +// AnalysisJob is the metadata format sent to and from the analyzer +type AnalysisJob struct { + ID string `json:"id"` + UserID string `json:"-"` + EndpointType string `json:"endpointType"` + EndpointID string `json:"endpoint"` + Type string `json:"type"` + Path string `json:"path"` + Format string `json:"format"` + Name string `json:"name"` + Status string `json:"status"` + Duration int `json:"duration"` + Result string `json:"-"` + Summary *json.RawMessage `json:"summary"` + Config *kubeAnalyzerConfig `json:"-"` + Folder string `json:"-"` + KubeConfigPath string `json:"-"` + TempFiles []string `json:"-"` + Busy bool `json:"-"` + EndTime time.Time `json:"-"` + CleanupCounter int `json:"-"` +} + +// RemoveTempFiles will remove any temporary files +func (job *AnalysisJob) RemoveTempFiles() { + log.Debug("Removing temporary files") + for _, name := range job.TempFiles { + err := os.Remove(name) + if err != nil { + log.Error("Could not delete file: %s", name) + } + } +} diff --git a/src/jetstream/plugins/analysis/list.go b/src/jetstream/plugins/analysis/list.go new file mode 100644 index 0000000000..3c8b763109 --- /dev/null +++ b/src/jetstream/plugins/analysis/list.go @@ -0,0 +1,228 @@ +package analysis + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/analysis/store" + + "github.com/labstack/echo/v4" + + log "github.com/sirupsen/logrus" +) + +const mainReportFile = "report.json" + +// listReports will list the analysis repotrs that have run +func (c *Analysis) listReports(ec echo.Context) error { + log.Debug("listReports") + var p = c.portalProxy + + // Need to get a config object for the target endpoint + // endpointGUID := ec.Param("endpoint") + userID := ec.Get("user_id").(string) + endpointID := ec.Param("endpoint") + + // Create a record in the reports datastore + dbStore, err := store.NewAnalysisDBStore(p.GetDatabaseConnection()) + if err != nil { + return err + } + + reports, err := dbStore.List(userID, endpointID) + if err != nil { + return err + } + + for _, report := range reports { + populateSummary(report) + } + + return ec.JSON(200, reports) +} + +// getReportsByPath will list the completed analysis repotrs that have run for the specified path +func (c *Analysis) getReportsByPath(ec echo.Context) error { + log.Debug("getReportsByPath") + var p = c.portalProxy + + // Need to get a config object for the target endpoint + userID := ec.Get("user_id").(string) + endpointID := ec.Param("endpoint") + + pathPrefix := fmt.Sprintf("completed/%s/", endpointID) + index := strings.Index(ec.Request().RequestURI, pathPrefix) + if index < 0 { + return errors.New("Invalid request") + } + path := ec.Request().RequestURI[index+len(pathPrefix):] + + // Create a record in the reports datastore + dbStore, err := store.NewAnalysisDBStore(p.GetDatabaseConnection()) + if err != nil { + return err + } + + reports, err := dbStore.ListCompletedByPath(userID, endpointID, path) + if err != nil { + return err + } + + for _, report := range reports { + populateSummary(report) + } + + return ec.JSON(200, reports) +} + +func populateSummary(report *store.AnalysisRecord) { + if report.Status == "error" { + report.Error = report.Result + } else if len(report.Result) > 0 { + data := []byte(report.Result) + report.Summary = (*json.RawMessage)(&data) + } +} + +func (c *Analysis) getLatestReport(ec echo.Context) error { + log.Debug("getLatestReport") + var p = c.portalProxy + + // Need to get a config object for the target endpoint + userID := ec.Get("user_id").(string) + endpointID := ec.Param("endpoint") + + pathPrefix := fmt.Sprintf("latest/%s/", endpointID) + index := strings.Index(ec.Request().RequestURI, pathPrefix) + if index < 0 { + return errors.New("Invalid request") + } + path := ec.Request().RequestURI[index+len(pathPrefix):] + + // Create a record in the reports datastore + dbStore, err := store.NewAnalysisDBStore(p.GetDatabaseConnection()) + if err != nil { + return err + } + + report, err := dbStore.GetLatestCompleted(userID, endpointID, path) + if err != nil { + return echo.NewHTTPError(404, "No Analysis Report found") + } + + if ec.Request().Method == "HEAD" { + ec.Response().Status = 200 + return nil + } + + // Get the report contents from the analysis server + bytes, err := c.getReportFile(report.UserID, report.EndpointID, report.ID, mainReportFile) + if err != nil { + return err + } + + report.Report = (*json.RawMessage)(&bytes) + return ec.JSON(200, report) +} + +func (c *Analysis) getReport(ec echo.Context) error { + log.Debug("getReport") + var p = c.portalProxy + + // Need to get a config object for the target endpoint + userID := ec.Get("user_id").(string) + ID := ec.Param("id") + file := ec.Param("file") + if len(file) == 0 { + file = mainReportFile + } + + // Create a record in the reports datastore + dbStore, err := store.NewAnalysisDBStore(p.GetDatabaseConnection()) + if err != nil { + return err + } + + report, err := dbStore.Get(userID, ID) + if err != nil { + return err + } + + // Get the report contents from the analysis server + bytes, err := c.getReportFile(report.UserID, report.EndpointID, report.ID, file) + if err != nil { + return err + } + + report.Report = (*json.RawMessage)(&bytes) + return ec.JSON(200, report) +} + +func (c *Analysis) deleteReports(ec echo.Context) error { + log.Debug("deleteReports") + var p = c.portalProxy + + // Need to get a config object for the target endpoint + userID := ec.Get("user_id").(string) + + defer ec.Request().Body.Close() + body, err := ioutil.ReadAll(ec.Request().Body) + if err != nil { + return err + } + + var ids []string + ids = make([]string, 0) + if err = json.Unmarshal(body, &ids); err != nil { + return err + } + + dbStore, err := store.NewAnalysisDBStore(p.GetDatabaseConnection()) + if err != nil { + return err + } + + for _, id := range ids { + // Look up the report to get the endpoint ID + if job, err := dbStore.Get(userID, id); err == nil { + deleteURL := fmt.Sprintf("%s/api/v1/report/%s/%s/%s", c.analysisServer, job.UserID, job.EndpointID, job.ID) + r, _ := http.NewRequest(http.MethodDelete, deleteURL, nil) + client := &http.Client{Timeout: 30 * time.Second} + rsp, err := client.Do(r) + if err != nil { + log.Warnf("Could not delete analysis report for: %s", job.ID) + } else if rsp.StatusCode != http.StatusOK { + log.Warnf("Could not delete analysis report for: %s", job.ID) + } + } + dbStore.Delete(userID, id) + } + + return ec.JSON(200, ids) +} + +func (c *Analysis) getReportFile(userID, endpointID, ID, name string) ([]byte, error) { + // Make request to get report + statusURL := fmt.Sprintf("%s/api/v1/report/%s/%s/%s/%s", c.analysisServer, userID, endpointID, ID, name) + r, _ := http.NewRequest(http.MethodGet, statusURL, nil) + client := &http.Client{Timeout: 30 * time.Second} + rsp, err := client.Do(r) + if err != nil { + return nil, fmt.Errorf("Failed getting report from Analyzer service: %v", err) + } else if rsp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Failed getting report from Analyzer service: %d", rsp.StatusCode) + } + + defer rsp.Body.Close() + response, err := ioutil.ReadAll(rsp.Body) + if err != nil { + return nil, fmt.Errorf("Could not read response: %v", err) + } + + return response, nil +} diff --git a/src/jetstream/plugins/analysis/main.go b/src/jetstream/plugins/analysis/main.go new file mode 100644 index 0000000000..066a246476 --- /dev/null +++ b/src/jetstream/plugins/analysis/main.go @@ -0,0 +1,143 @@ +package analysis + +import ( + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/analysis/store" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +const ( + analsyisServicesAPIEnvVar = "ANALYSIS_SERVICES_API" + + // Allow specific engines to be enabled + analysisEnginesAPIEnvVar = "ANALYSIS_ENGINES" + + // Names used to communicate settings info back to the front-end client + analysisEnabledPluginConfigSetting = "analysisEnabled" + analysisEnginesPluginConfigSetting = "analysisEngines" + + defaultEngines = "popeye" +) + +// Analysis - Plugin to allow analysers to run over an endpoint cluster +type Analysis struct { + portalProxy interfaces.PortalProxy + analysisServer string +} + +func init() { + interfaces.AddPlugin("analysis", []string{"kubernetes"}, Init) +} + +// Init creates a new Analysis +func Init(portalProxy interfaces.PortalProxy) (interfaces.StratosPlugin, error) { + store.InitRepositoryProvider(portalProxy.GetConfig().DatabaseProviderName) + return &Analysis{portalProxy: portalProxy}, nil +} + +// GetMiddlewarePlugin gets the middleware plugin for this plugin +func (analysis *Analysis) GetMiddlewarePlugin() (interfaces.MiddlewarePlugin, error) { + return nil, errors.New("Not implemented") +} + +// GetEndpointPlugin gets the endpoint plugin for this plugin +func (analysis *Analysis) GetEndpointPlugin() (interfaces.EndpointPlugin, error) { + return nil, errors.New("Not implemented") +} + +// GetRoutePlugin gets the route plugin for this plugin +func (analysis *Analysis) GetRoutePlugin() (interfaces.RoutePlugin, error) { + return analysis, nil +} + +// AddAdminGroupRoutes adds the admin routes for this plugin to the Echo server +func (analysis *Analysis) AddAdminGroupRoutes(echoGroup *echo.Group) { + // no-op +} + +// AddSessionGroupRoutes adds the session routes for this plugin to the Echo server +func (analysis *Analysis) AddSessionGroupRoutes(echoGroup *echo.Group) { + echoGroup.GET("/analysis/reports/:endpoint", analysis.listReports) + echoGroup.GET("/analysis/reports/:endpoint/:id", analysis.getReport) + echoGroup.GET("/analysis/reports/:endpoint/:id/:file", analysis.getReport) + + // Get completed reports for the given path + echoGroup.GET("/analysis/completed/:endpoint/*", analysis.getReportsByPath) + + // Get latest report + echoGroup.GET("/analysis/latest/:endpoint/*", analysis.getLatestReport) + echoGroup.HEAD("/analysis/latest/:endpoint/*", analysis.getLatestReport) + + echoGroup.DELETE("/analysis/reports", analysis.deleteReports) + + // Run report + echoGroup.POST("/analysis/run/:analyzer/:endpoint", analysis.runReport) +} + +// Init performs plugin initialization +func (analysis *Analysis) Init() error { + // Only enabled in tech preview + if !analysis.portalProxy.GetConfig().EnableTechPreview { + // This will set PluginsStatus[name] = false, which results in plugins[name] in the FE + return errors.New("Requires tech preview") + } + + // Check env var + if url, ok := analysis.portalProxy.Env().Lookup(analsyisServicesAPIEnvVar); ok { + analysis.analysisServer = url + + // Start background status check + analysis.initStatusCheck() + + if engines, ok := analysis.portalProxy.Env().Lookup(analysisEnginesAPIEnvVar); ok { + analysis.portalProxy.GetConfig().PluginConfig[analysisEnginesPluginConfigSetting] = engines + } else { + analysis.portalProxy.GetConfig().PluginConfig[analysisEnginesPluginConfigSetting] = defaultEngines + } + + return nil + } + + return errors.New("Analysis services API Server not configured") +} + +// OnEndpointNotification called when for endpoint events +func (analysis *Analysis) OnEndpointNotification(action interfaces.EndpointAction, endpoint *interfaces.CNSIRecord) { + if action == interfaces.EndpointUnregisterAction { + // An endpoint was unregistered, so remove all reports + dbStore, err := store.NewAnalysisDBStore(analysis.portalProxy.GetDatabaseConnection()) + if err == nil { + dbStore.DeleteForEndpoint(endpoint.GUID) + + // Now ask the analysis engine to to delete all files on disk + deleteURL := fmt.Sprintf("%s/api/v1/report/%s", analysis.analysisServer, endpoint.GUID) + r, _ := http.NewRequest(http.MethodDelete, deleteURL, nil) + client := &http.Client{Timeout: 30 * time.Second} + rsp, err := client.Do(r) + if err != nil { + log.Errorf("Failed deleting reports from Analyzer service: %v", err) + return + } + + if rsp.StatusCode != http.StatusOK { + log.Errorf("Failed deleting reports from Analyzer service: %d", rsp.StatusCode) + } + + if rsp.Body != nil { + defer rsp.Body.Close() + _, err = ioutil.ReadAll(rsp.Body) + if err != nil { + log.Errorf("Could not read response: %v", err) + } + } + } + } +} diff --git a/src/jetstream/plugins/analysis/run.go b/src/jetstream/plugins/analysis/run.go new file mode 100644 index 0000000000..157c6e1a68 --- /dev/null +++ b/src/jetstream/plugins/analysis/run.go @@ -0,0 +1,188 @@ +package analysis + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "mime/multipart" + "net/http" + "net/textproto" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/analysis/store" + + "github.com/labstack/echo/v4" + uuid "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" +) + +type popeyeConfig struct { + Namespace string `json:"namespace"` + App string `json:"app"` +} + +type KubeConfigExporter interface { + GetKubeConfigForEndpointUser(endpointID, userID string) (string, error) +} + +const idHeaderName = "X-Stratos-Analaysis-ID" + +func (c *Analysis) runReport(ec echo.Context) error { + log.Debug("runReport") + + analyzer := ec.Param("analyzer") + endpointID := ec.Param("endpoint") + userID := ec.Get("user_id").(string) + + // Look up the endpoint for the user + var p = c.portalProxy + endpoint, err := p.GetCNSIRecord(endpointID) + if err != nil { + return errors.New("Could not get endpoint information") + } + + report := store.AnalysisRecord{ + ID: uuid.NewV4().String(), + EndpointID: endpointID, + EndpointType: endpoint.CNSIType, + UserID: userID, + Path: "", + Created: time.Now(), + Read: false, + Duration: 0, + Status: "pending", + Result: "", + } + + // Create a record in the reports datastore + dbStore, err := store.NewAnalysisDBStore(p.GetDatabaseConnection()) + if err != nil { + return err + } + + report.Name = fmt.Sprintf("Analysis report %s", analyzer) + dbStore.Save((report)) + + err = c.doRunReport(ec, analyzer, endpointID, userID, dbStore, &report) + if err != nil { + report.Status = "error" + report.Result = err.Error() + dbStore.UpdateReport(userID, &report) + } + + return err + +} + +func (c *Analysis) doRunReport(ec echo.Context, analyzer, endpointID, userID string, dbStore store.AnalysisStore, report *store.AnalysisRecord) error { + + // Get Kube Config + k8s := c.portalProxy.GetPlugin("kubernetes") + if k8s == nil { + return errors.New("Could not find Kubernetes plugin") + } + + k8sConfig, ok := k8s.(KubeConfigExporter) + if !ok { + return errors.New("Could not find Kubernetes plugin interface") + } + + config, err := k8sConfig.GetKubeConfigForEndpointUser(endpointID, userID) + if err != nil { + return errors.New("Could not get Kube Config for the endpoint") + } + + id := fmt.Sprintf("%s/%s/%s", userID, endpointID, report.ID) + + // Create a multi-part form to send to the analyzer container + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + // Add kube config + metadataHeader := textproto.MIMEHeader{} + metadataHeader.Set("Content-Type", "application/yaml") + metadataHeader.Set("Content-ID", "kubeconfig") + part, _ := writer.CreatePart(metadataHeader) + part.Write([]byte(config)) + + requestBody := make([]byte, 0) + + // Read body + defer ec.Request().Body.Close() + if b, err := ioutil.ReadAll((ec.Request().Body)); err == nil { + requestBody = b + } + + // Content that was posted to us + postHeader := textproto.MIMEHeader{} + postHeader.Set("Content-Type", "application/json") + postHeader.Set("Content-ID", "body") + part, _ = writer.CreatePart(postHeader) + part.Write(requestBody) + + // Report config + reportHeader := textproto.MIMEHeader{} + reportHeader.Set("Content-Type", "application/json") + reportHeader.Set("Content-ID", "job") + part, _ = writer.CreatePart(reportHeader) + job, err := json.Marshal(report) + if err != nil { + return errors.New("Could not serialize job") + } + part.Write(job) + writer.Close() + + // Post this to the Analyzer API + contentType := fmt.Sprintf("multipart/form-data; boundary=%s", writer.Boundary()) + uploadURL := fmt.Sprintf("%s/api/v1/run/%s", c.analysisServer, analyzer) + r, _ := http.NewRequest(http.MethodPost, uploadURL, bytes.NewReader(body.Bytes())) + r.Header.Set("Content-Type", contentType) + r.Header.Set(idHeaderName, id) + client := &http.Client{Timeout: 180 * time.Second} + rsp, err := client.Do(r) + if err != nil { + return errors.New("Analysis job failed - could not contact Analysis Server") + } + + if rsp.StatusCode != http.StatusOK { + log.Debugf("Request failed with response code: %d", rsp.StatusCode) + return fmt.Errorf("Analysis job failed with response code: %d", rsp.StatusCode) + } + + // Job submitted okay + // Updated job is in the response + + defer rsp.Body.Close() + response, err := ioutil.ReadAll(rsp.Body) + if err != nil { + return errors.New("Could not read response") + } + + updatedJob := store.AnalysisRecord{} + if err = json.Unmarshal(response, &updatedJob); err != nil { + return errors.New("Could not read response - could not deserialize response") + } + + report.Duration = updatedJob.Duration + report.Status = updatedJob.Status + report.Name = updatedJob.Name + report.Format = updatedJob.Format + report.Type = updatedJob.Type + report.Path = updatedJob.Path + + log.Debug("OK => Job submitted okay") + log.Debug("=======================================================") + log.Debugf("%+v", report) + log.Debug("=======================================================") + + err = dbStore.UpdateReport(userID, report) + if err != nil { + return fmt.Errorf("Could not save report %s", err) + } + + log.Debug("All done - job saved") + + return ec.JSON(200, report) +} diff --git a/src/jetstream/plugins/analysis/status.go b/src/jetstream/plugins/analysis/status.go new file mode 100644 index 0000000000..5635589289 --- /dev/null +++ b/src/jetstream/plugins/analysis/status.go @@ -0,0 +1,109 @@ +package analysis + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/analysis/store" + + log "github.com/sirupsen/logrus" +) + +// Start a poller to check the status +func (c *Analysis) initStatusCheck() { + + log.Info("Analysis Plugin: Starting status check ...") + + // Just loop forever, checking the status of running jobs every 10s + go func() { + for { + time.Sleep(10 * time.Second) + err := c.checkStatus() + if err != nil { + log.Errorf("Error checking status: %v", err) + } + } + }() +} + +func (c *Analysis) checkStatus() error { + log.Debug("Checking status....") + p := c.portalProxy + // Create a record in the reports datastore + dbStore, err := store.NewAnalysisDBStore(p.GetDatabaseConnection()) + if err != nil { + return fmt.Errorf("Status Check: Can not get anaylsis store db: %v", err) + } + + // Get all running jobs + running, err := dbStore.ListRunning() + if err != nil { + return fmt.Errorf("Can not get list of running jobs: %v", err) + } + + if len(running) == 0 { + return nil + } + + ids := make([]string, 0) + for _, job := range running { + log.Debugf("Got running job: %s", job.ID) + ids = append(ids, job.ID) + } + + data, err := json.Marshal(ids) + if err != nil { + log.Errorf("Could not marshal IDs: %v", err) + return fmt.Errorf("Could not marshal IDs: %v", err) + } + + // Make request to status + statusURL := fmt.Sprintf("%s/api/v1/status", c.analysisServer) + r, _ := http.NewRequest(http.MethodPost, statusURL, bytes.NewReader(data)) + r.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 180 * time.Second} + rsp, err := client.Do(r) + if err != nil { + return fmt.Errorf("Failed getting status from Analyzer service: %v", err) + } + + if rsp.StatusCode != http.StatusOK { + return fmt.Errorf("Failed getting status from Analyzer service: %d", rsp.StatusCode) + } + + defer rsp.Body.Close() + response, err := ioutil.ReadAll(rsp.Body) + if err != nil { + log.Errorf("Could not read response: %v", err) + return fmt.Errorf("Could not read response: %v", err) + } + + // Turn into map of IDs to Jobs + statuses := make(map[string]store.AnalysisRecord) + + if err := json.Unmarshal(response, &statuses); err != nil { + return fmt.Errorf("Could not parse response: %v", err) + } + + for _, job := range running { + if status, ok := statuses[job.ID]; ok { + job.Duration = status.Duration + job.Status = status.Status + if err := dbStore.UpdateReport(job.UserID, job); err != nil { + log.Warnf("Unable to update status for job %s: %v", job.ID, err) + } + } else { + // The analysis server did not know about our job, os mark as error + job.Status = "error" + if err := dbStore.UpdateReport(job.UserID, job); err != nil { + log.Warnf("Unable to update status for job %s: %v", job.ID, err) + } + } + } + + return nil +} diff --git a/src/jetstream/plugins/analysis/store/analysis_store_db.go b/src/jetstream/plugins/analysis/store/analysis_store_db.go new file mode 100644 index 0000000000..481408fa6f --- /dev/null +++ b/src/jetstream/plugins/analysis/store/analysis_store_db.go @@ -0,0 +1,164 @@ +package store + +import ( + "database/sql" + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore" +) + +var ( + listReports = `SELECT id, endpoint_type, endpoint, user, name, path, type, format, created, acknowledged, status, duration, result FROM analysis WHERE user = $1 AND endpoint = $2` + listCompletedReportsByPath = `SELECT id, endpoint_type, endpoint, user, name, path, type, format, created, acknowledged, status, duration, result FROM analysis WHERE status = 'completed' AND user = $1 AND endpoint = $2 AND path = $3 ORDER BY created DESC` + getReport = `SELECT id, endpoint_type, endpoint, user, name, path, type, format, created, acknowledged, status, duration, result FROM analysis WHERE user = $1 AND id=$2` + deleteReport = `DELETE FROM analysis WHERE user = $1 AND id = $2` + saveReport = `INSERT INTO analysis (id, user, endpoint_type, endpoint, name, path, type, format, created, acknowledged, status, duration, result) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)` + updateReport = `UPDATE analysis SET type = $1, format = $2, acknowledged = $3, status = $4, duration = $5, result = $6, name = $7, path = $8, result = $9 WHERE user = $10 AND id = $11` + getLatestReport = `SELECT id, endpoint_type, endpoint, user, name, path, type, format, created, acknowledged, status, duration, result FROM analysis WHERE status = 'completed' AND user = $1 AND endpoint = $2 AND path = $3 ORDER BY created DESC` + listRunningReports = `SELECT id, endpoint_type, endpoint, user, name, path, type, format, created, acknowledged, status, duration, result FROM analysis WHERE status = 'running' ORDER BY created DESC` + deleteForEndpoint = `DELETE FROM analysis WHERE endpoint = $1` +) + +// InitRepositoryProvider - One time init for the given DB Provider +func InitRepositoryProvider(databaseProvider string) { + // Modify the database statements if needed, for the given database type + listReports = datastore.ModifySQLStatement(listReports, databaseProvider) + listCompletedReportsByPath = datastore.ModifySQLStatement(listCompletedReportsByPath, databaseProvider) + getReport = datastore.ModifySQLStatement(getReport, databaseProvider) + deleteReport = datastore.ModifySQLStatement(deleteReport, databaseProvider) + saveReport = datastore.ModifySQLStatement(saveReport, databaseProvider) + updateReport = datastore.ModifySQLStatement(updateReport, databaseProvider) + getLatestReport = datastore.ModifySQLStatement(getLatestReport, databaseProvider) + listRunningReports = datastore.ModifySQLStatement(listRunningReports, databaseProvider) + deleteForEndpoint = datastore.ModifySQLStatement(deleteForEndpoint, databaseProvider) +} + +// AnalysisDBStore is a DB-backed Analysis Reports repository +type AnalysisDBStore struct { + db *sql.DB +} + +// NewAnalysisDBStore will create a new instance of the AnalysisDBStore +func NewAnalysisDBStore(dcp *sql.DB) (AnalysisStore, error) { + return &AnalysisDBStore{db: dcp}, nil +} + +// List - Returns a list of all user Analysis Reports for the given endpoint +func (p *AnalysisDBStore) List(userGUID, endpointID string) ([]*AnalysisRecord, error) { + log.Debug("List") + rows, err := p.db.Query(listReports, userGUID, endpointID) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve Analysis Reports records: %v", err) + } + defer rows.Close() + + return list(rows) +} + +func (p *AnalysisDBStore) ListCompletedByPath(userGUID, endpointID, path string) ([]*AnalysisRecord, error) { + log.Debug("ListCompletedByPath") + rows, err := p.db.Query(listCompletedReportsByPath, userGUID, endpointID, path) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve Analysis Reports records: %v", err) + } + defer rows.Close() + + return list(rows) +} + +func (p *AnalysisDBStore) ListRunning() ([]*AnalysisRecord, error) { + log.Debug("ListRunning") + rows, err := p.db.Query(listRunningReports) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve Analysis Reports records: %v", err) + } + defer rows.Close() + + return list(rows) +} + +func list(rows *sql.Rows) ([]*AnalysisRecord, error) { + var reportList []*AnalysisRecord + reportList = make([]*AnalysisRecord, 0) + + for rows.Next() { + report := new(AnalysisRecord) + err := rows.Scan(&report.ID, &report.EndpointType, &report.EndpointID, &report.UserID, &report.Name, &report.Path, &report.Type, &report.Format, &report.Created, &report.Read, &report.Status, &report.Duration, &report.Result) + if err != nil { + return nil, fmt.Errorf("Unable to scan Analysis Reports records: %v", err) + } + reportList = append(reportList, report) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("Unable to List Analysis Reports records: %v", err) + } + + return reportList, nil +} + +// Get - Get a specific Analysis Report by ID +func (p *AnalysisDBStore) Get(userGUID, ID string) (*AnalysisRecord, error) { + log.Debug("Get") + + report := AnalysisRecord{} + err := p.db.QueryRow(getReport, userGUID, ID).Scan(&report.ID, &report.EndpointType, &report.EndpointID, &report.UserID, &report.Name, &report.Path, &report.Type, &report.Format, &report.Created, &report.Read, &report.Status, &report.Duration, &report.Result) + if err != nil { + msg := "Unable to Get Analysis Report record: %v" + log.Debugf(msg, err) + return nil, fmt.Errorf(msg, err) + } + + return &report, nil +} + +// GetLatestCompleted - Get latest report for the specified path +func (p *AnalysisDBStore) GetLatestCompleted(userGUID, endpointID, path string) (*AnalysisRecord, error) { + log.Debug("GetLatestCompleted") + + report := AnalysisRecord{} + err := p.db.QueryRow(getLatestReport, userGUID, endpointID, path).Scan(&report.ID, &report.EndpointType, &report.EndpointID, &report.UserID, &report.Name, &report.Path, &report.Type, &report.Format, &report.Created, &report.Read, &report.Status, &report.Duration, &report.Result) + if err != nil { + msg := "Unable to get laetst completed Analysis Report record: %v" + log.Debugf(msg, err) + return nil, fmt.Errorf(msg, err) + } + + return &report, nil +} + +// Delete will delete an Analysis Report from the datastore +func (p *AnalysisDBStore) Delete(userGUID string, id string) error { + if _, err := p.db.Exec(deleteReport, userGUID, id); err != nil { + return fmt.Errorf("Unable to delete Analysis Report record: %v", err) + } + + return nil +} + +// UpdateReport will update the dynamic fields of the Analysis Record in thedatastore +func (p *AnalysisDBStore) UpdateReport(userGUID string, report *AnalysisRecord) error { + if _, err := p.db.Exec(updateReport, report.Type, report.Format, report.Read, report.Status, report.Duration, report.Result, report.Name, report.Path, report.Result, userGUID, report.ID); err != nil { + return fmt.Errorf("Unable to update Analysis Report record: %v", err) + } + return nil +} + +// Save will persist an Analysis Report to the datastore +func (p *AnalysisDBStore) Save(report AnalysisRecord) (*AnalysisRecord, error) { + if _, err := p.db.Exec(saveReport, report.ID, report.UserID, report.EndpointType, report.EndpointID, report.Name, report.Path, report.Type, report.Format, report.Created, report.Read, &report.Status, &report.Duration, &report.Result); err != nil { + return nil, fmt.Errorf("Unable to save Analysis Report record: %v", err) + } + + return &report, nil +} + +// DeleteForEndpoint will remove all Analysis Reports for a given endpoint guid +func (p *AnalysisDBStore) DeleteForEndpoint(endpointID string) error { + if _, err := p.db.Exec(deleteForEndpoint, endpointID); err != nil { + return fmt.Errorf("Unable to delete reports for endpoint: %s %v", endpointID, err) + } + return nil +} diff --git a/src/jetstream/plugins/analysis/store/main.go b/src/jetstream/plugins/analysis/store/main.go new file mode 100644 index 0000000000..e9a14edac6 --- /dev/null +++ b/src/jetstream/plugins/analysis/store/main.go @@ -0,0 +1,39 @@ +package store + +import ( + "encoding/json" + "time" +) + +// AnalysisRecord represents an analysis that has been run +type AnalysisRecord struct { + ID string `json:"id"` + UserID string `json:"-"` + EndpointType string `json:"endpointType"` + EndpointID string `json:"endpoint"` + Type string `json:"type"` + Format string `json:"format"` + Name string `json:"name"` + Path string `json:"path"` + Created time.Time `json:"created"` + Read bool `json:"read"` + Status string `json:"status"` + Duration int `json:"duration"` + Result string `json:"-"` + Error string `json:"error"` + Summary *json.RawMessage `json:"summary"` + Report *json.RawMessage `json:"report,omitempty"` +} + +// AnalysisStore is the analysis repository +type AnalysisStore interface { + List(userGUID, endpointID string) ([]*AnalysisRecord, error) + Get(userGUID, id string) (*AnalysisRecord, error) + GetLatestCompleted(userGUID, endpointID, path string) (*AnalysisRecord, error) + ListCompletedByPath(userGUID, endpointID, path string) ([]*AnalysisRecord, error) + ListRunning() ([]*AnalysisRecord, error) + Delete(userGUID, id string) error + DeleteForEndpoint(endpointID string) error + Save(record AnalysisRecord) (*AnalysisRecord, error) + UpdateReport(userGUID string, report *AnalysisRecord) error +} diff --git a/src/jetstream/plugins/cfapppush/deploy.go b/src/jetstream/plugins/cfapppush/deploy.go index 47be2a5a50..ffa7b00813 100644 --- a/src/jetstream/plugins/cfapppush/deploy.go +++ b/src/jetstream/plugins/cfapppush/deploy.go @@ -223,7 +223,7 @@ func (cfAppPush *CFAppPush) deploy(echoContext echo.Context) error { sendEvent(clientWebSocket, CLOSE_SUCCESS) - log.Debug("Waiting for close ackhowledgement from the client") + log.Debug("Waiting for close acknowledgement from the client") wait := 30 * time.Second clientWebSocket.SetReadDeadline(time.Now().Add(wait)) diff --git a/src/jetstream/plugins/kubernetes/api/api.go b/src/jetstream/plugins/kubernetes/api/api.go new file mode 100644 index 0000000000..b15fc1b3c3 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/api/api.go @@ -0,0 +1,12 @@ +package api + +import ( + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + + restclient "k8s.io/client-go/rest" +) + +type Kubernetes interface { + GetConfigForEndpoint(masterURL string, token interfaces.TokenRecord) (*restclient.Config, error) + GetKubeConfigForEndpoint(masterURL string, token interfaces.TokenRecord, namespace string) (string, error) +} diff --git a/src/jetstream/plugins/kubernetes/api_proxy.go b/src/jetstream/plugins/kubernetes/api_proxy.go new file mode 100644 index 0000000000..74d285d399 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/api_proxy.go @@ -0,0 +1,70 @@ +package kubernetes + +import ( + "fmt" + "sync" + + // Import the OIDC auth plugin + _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +// KubeProxyError represents error when a proxied request to the Kube API failes +type KubeProxyError struct { + Name string +} + +// KubeProxyFunc represents a function to proxy to the Kube API +type KubeProxyFunc func(*interfaces.ConnectedEndpoint, chan KubeProxyResponse) + +// KubeProxyResponse represents a response from a proxy request to the Kube API +type KubeProxyResponse struct { + Endpoint string + Result interface{} + Error *KubeProxyError +} + +// KubeProxyResponses represents response from multiple proxy requests to the Kube API +type KubeProxyResponses map[string]interface{} + +// ProxyKubernetesAPI proxies an API request to all of the user's connected Kubernetes endpoints +func (c *KubernetesSpecification) ProxyKubernetesAPI(userID string, f KubeProxyFunc) (KubeProxyResponses, error) { + + var p = c.portalProxy + k8sList := make([]*interfaces.ConnectedEndpoint, 0) + eps, err := p.ListEndpointsByUser(userID) + if err != nil { + return nil, fmt.Errorf("Could not get endpints Client for endpoint: %v+", err) + } + + // Get all connected k8s endpoints for the user + for _, endpoint := range eps { + if endpoint.CNSIType == "k8s" { + k8sList = append(k8sList, endpoint) + } + } + + mapMutex := sync.RWMutex{} + + // Check that we actually have some + // TODO + done := make(chan KubeProxyResponse) + for _, endpoint := range k8sList { + go f(endpoint, done) + } + + responses := make(KubeProxyResponses) + for range k8sList { + res := <-done + mapMutex.RLock() + if res.Error == nil { + responses[res.Endpoint] = res.Result + } else { + responses[res.Endpoint] = res.Error + } + mapMutex.RUnlock() + } + + return responses, nil +} diff --git a/src/jetstream/plugins/kubernetes/auth/awsiam.go b/src/jetstream/plugins/kubernetes/auth/awsiam.go new file mode 100644 index 0000000000..06c733bb9e --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/awsiam.go @@ -0,0 +1,183 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + // "github.com/SermoDigital/jose/jws" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/kubernetes-sigs/aws-iam-authenticator/pkg/token" +) + +// AWSIAMUserInfo is the user info needed to connect to AWS Kubernetes +type AWSIAMUserInfo struct { + Cluster string `json:"cluster"` + AccessKey string `json:"accessKey"` + SecretKey string `json:"secretKey"` +} + +// AWSKubeAuth is AWS IAM Authentication for Kubernetes +type AWSKubeAuth struct { + portalProxy interfaces.PortalProxy +} + +const authConnectTypeAWSIAM = "aws-iam" + +// InitAWSKubeAuth creates a GKEKubeAuth +func InitAWSKubeAuth(portalProxy interfaces.PortalProxy) KubeAuthProvider { + return &AWSKubeAuth{portalProxy: portalProxy} +} + +// GetName returns the Auth Provider name +func (c *AWSKubeAuth) GetName() string { + return authConnectTypeAWSIAM +} + +func (c *AWSKubeAuth) AddAuthInfo(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error { + awsInfo := &AWSIAMUserInfo{} + err := json.Unmarshal([]byte(tokenRec.RefreshToken), &awsInfo) + if err != nil { + return err + } + + // NOTE: We really should check first to see if the token has expired before we try and get another + + // Get an access token + token, err := c.getTokenIAM(*awsInfo) + if err != nil { + return fmt.Errorf("Could not get new token using the IAM info: %v+", err) + } + + info.Token = token + return nil +} + +func (c *AWSIAMUserInfo) Retrieve() (credentials.Value, error) { + return credentials.Value{ + AccessKeyID: c.AccessKey, + SecretAccessKey: c.SecretKey, + }, nil +} + +func (c *AWSIAMUserInfo) IsExpired() bool { + return true +} + +func (c *AWSKubeAuth) FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + log.Debug("FetchIAMToken") + + // Place the IAM properties into a JSON Struct and store that in the Refresh Token + // Then use the refresh method to get a current access token + cluster := ec.FormValue("cluster") + accessKey := ec.FormValue("access_key") + secretKey := ec.FormValue("secret_key") + + if len(cluster) == 0 || len(accessKey) == 0 || len(secretKey) == 0 { + return nil, nil, errors.New("Need cluster, access key and secret key") + } + + info := AWSIAMUserInfo{ + Cluster: cluster, + AccessKey: accessKey, + SecretKey: secretKey, + } + + jsonString, err := json.Marshal(info) + if err != nil { + return nil, nil, err + } + + refreshToken := string(jsonString) + + // Use the AWS IAM library to get a token + accessToken, err := c.getTokenIAM(info) + + // Tokens last 15 minutes + expiry := time.Now().Local().Add(time.Minute * time.Duration(15)) + + tokenRecord := c.portalProxy.InitEndpointTokenRecord(expiry.Unix(), accessToken, refreshToken, false) + tokenRecord.AuthType = authConnectTypeAWSIAM + return &tokenRecord, &cnsiRecord, nil +} + +func (c *AWSKubeAuth) GetUserFromToken(cnsiGUID string, cfTokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) { + return &interfaces.ConnectedUser{ + GUID: "AWS IAM", + Name: "IAM", + }, true +} + +func (c *AWSKubeAuth) getTokenIAM(info AWSIAMUserInfo) (string, error) { + generator, err := token.NewGenerator(false) + if err != nil { + return "", fmt.Errorf("AWS IAM: Failed to create generator due to %+v", err) + } + + sess, err := session.NewSessionWithOptions(session.Options{ + AssumeRoleTokenProvider: token.StdinStderrTokenProvider, + SharedConfigState: session.SharedConfigEnable, + }) + if err != nil { + return "", fmt.Errorf("AWS IAM: Failed to create new session %+v", err) + } + + creds := credentials.NewCredentials(&info) + stsAPI := sts.New(sess, &aws.Config{Credentials: creds}) + tok, err := generator.GetWithSTS(info.Cluster, stsAPI) + if err != nil { + return "", fmt.Errorf("AWS IAM: Failed to get token due to: %+v ", err) + } + + // Got the token + return tok.Token, nil +} + +func (c *AWSKubeAuth) RegisterJetstreamAuthType(portal interfaces.PortalProxy) { + // Register auth type with Jetstream + c.portalProxy.AddAuthProvider(c.GetName(), interfaces.AuthProvider{ + Handler: c.DoFlowRequest, + UserInfo: c.GetUserFromToken, + }) +} + +func (c *AWSKubeAuth) DoFlowRequest(cnsiRequest *interfaces.CNSIRequest, req *http.Request) (*http.Response, error) { + log.Debug("doAWSIAMFlowRequest") + + authHandler := c.portalProxy.OAuthHandlerFunc(cnsiRequest, req, c.RefreshIAMToken) + return c.portalProxy.DoAuthFlowRequest(cnsiRequest, req, authHandler) +} + +func (c *AWSKubeAuth) RefreshIAMToken(skipSSLValidation bool, cnsiGUID, userGUID, client, clientSecret, tokenEndpoint string) (t interfaces.TokenRecord, err error) { + log.Debug("RefreshIAMToken") + + userToken, ok := c.portalProxy.GetCNSITokenRecordWithDisconnected(cnsiGUID, userGUID) + if !ok { + return t, fmt.Errorf("Info could not be found for user with GUID %s", userGUID) + } + + // Refresh token is the IAM info + var iamInfo AWSIAMUserInfo + err = json.Unmarshal([]byte(userToken.RefreshToken), &iamInfo) + if err != nil { + return userToken, fmt.Errorf("Could not get the IAM info from the refresh token: %v+", err) + } + + token, err := c.getTokenIAM(iamInfo) + if err != nil { + return userToken, fmt.Errorf("Could not get new token using the IAM info: %v+", err) + } + + userToken.AuthToken = token + return userToken, nil +} diff --git a/src/jetstream/plugins/kubernetes/auth/azure.go b/src/jetstream/plugins/kubernetes/auth/azure.go new file mode 100644 index 0000000000..ee49f2d0ee --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/azure.go @@ -0,0 +1,108 @@ +package auth + +import ( + "encoding/base64" + "errors" + "io/ioutil" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/config" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + + "github.com/labstack/echo/v4" +) + +const authConnectTypeKubeConfigAz = "kubeconfig-az" + +// AzureKubeAuth is Azure Authentication with Certificates +type AzureKubeAuth struct { + CertKubeAuth +} + +// InitAzureKubeAuth creates a AzureKubeAuth +func InitAzureKubeAuth(portalProxy interfaces.PortalProxy) KubeAuthProvider { + return &AzureKubeAuth{*InitCertKubeAuth(portalProxy)} +} + +// GetName returns the provider name +func (c *AzureKubeAuth) GetName() string { + return authConnectTypeKubeConfigAz +} + +func (p *AzureKubeAuth) FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + req := ec.Request() + + // Need to extract the parameters from the request body + defer req.Body.Close() + body, err := ioutil.ReadAll(req.Body) + if err != nil { + return nil, nil, err + } + + kubeConfig, err := config.ParseKubeConfig(body) + + kubeConfigUser, err := kubeConfig.GetUserForCluster(cnsiRecord.APIEndpoint.String()) + if err != nil { + return nil, nil, errors.New("Unable to find cluster in kubeconfig") + } + + authConfig, err := p.getAKSAuthConfig(kubeConfigUser) + if err != nil { + return nil, nil, errors.New("User doesn't use AKS auth") + } + + jsonString, err := authConfig.GetJSON() + if err != nil { + return nil, nil, err + } + // Refresh token isn't required since the AccessToken will never expire + refreshToken := jsonString + + accessToken := jsonString + // Indefinite expiry + expiry := time.Now().Local().Add(time.Hour * time.Duration(100000)) + + tokenRecord := p.portalProxy.InitEndpointTokenRecord(expiry.Unix(), accessToken, refreshToken, false) + tokenRecord.AuthType = authConnectTypeKubeConfigAz + + return &tokenRecord, &cnsiRecord, nil +} + +func (p *AzureKubeAuth) getAKSAuthConfig(k *config.KubeConfigUser) (*KubeCertificate, error) { + + if !isAKSAuth(k) { + return nil, errors.New("User doesn't use AKS") + } + + cert, err := base64.StdEncoding.DecodeString(k.User.ClientCertificate) + if err != nil { + return nil, errors.New("Unable to decode certificate") + } + certKey, err := base64.StdEncoding.DecodeString(k.User.ClientKeyData) + if err != nil { + return nil, errors.New("Unable to decode certificate key") + } + kubeCertAuth := &KubeCertificate{ + Certificate: string(cert), + CertificateKey: string(certKey), + Token: k.User.Token, + } + return kubeCertAuth, nil +} + +func isAKSAuth(k *config.KubeConfigUser) bool { + if k.User.ClientCertificate == "" || + k.User.ClientKeyData == "" || + k.User.Token == "" { + return false + } + return true +} + +func (c *AzureKubeAuth) RegisterJetstreamAuthType(portal interfaces.PortalProxy) { + // Register auth type with Jetstream + c.portalProxy.AddAuthProvider(c.GetName(), interfaces.AuthProvider{ + Handler: c.DoFlowRequest, + UserInfo: c.GetUserFromToken, + }) +} diff --git a/src/jetstream/plugins/kubernetes/auth/basic_auth.go b/src/jetstream/plugins/kubernetes/auth/basic_auth.go new file mode 100644 index 0000000000..d8ee9b98cf --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/basic_auth.go @@ -0,0 +1,88 @@ +package auth + +import ( + "encoding/base64" + "errors" + "fmt" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +const authConnectTypeBasicAuth = "creds" +const authTypeHttpBasic = "HttpBasic" + +// KubeBasicAuth is HTTP Basic Authentication +type KubeBasicAuth struct { + portalProxy interfaces.PortalProxy +} + +// InitKubeBasicAuth creates a GKEKubeAuth +func InitKubeBasicAuth(portalProxy interfaces.PortalProxy) *KubeBasicAuth { + return &KubeBasicAuth{portalProxy: portalProxy} +} + +// GetName returns the provider name +func (c *KubeBasicAuth) GetName() string { + return authConnectTypeBasicAuth +} + +func (c *KubeBasicAuth) AddAuthInfo(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error { + // Decode the token + authString, err := base64.StdEncoding.DecodeString(tokenRec.AuthToken) + if err != nil { + return err + } + + // Password is separated by a colon from the username + info.Username = tokenRec.RefreshToken + basicAuth := string(authString) + info.Password = basicAuth[len(info.Username)+1:] + + return nil +} + +func (c *KubeBasicAuth) FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + + log.Info("FetchToken") + + username := ec.FormValue("username") + password := ec.FormValue("password") + + if len(username) == 0 || len(password) == 0 { + return nil, &cnsiRecord, errors.New("Needs username and password") + } + + authString := fmt.Sprintf("%s:%s", username, password) + base64EncodedAuthString := base64.StdEncoding.EncodeToString([]byte(authString)) + + tr := &interfaces.TokenRecord{ + AuthType: authConnectTypeBasicAuth, + AuthToken: base64EncodedAuthString, + RefreshToken: username, + } + + return tr, &cnsiRecord, nil +} + +func (c *KubeBasicAuth) GetUserFromToken(cnsiGUID string, cfTokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) { + return &interfaces.ConnectedUser{ + // RefreshjToken is the username + GUID: fmt.Sprintf("%s-%s", cnsiGUID, cfTokenRecord.RefreshToken), + Name: cfTokenRecord.RefreshToken, + }, true +} + +func (c *KubeBasicAuth) RegisterJetstreamAuthType(portal interfaces.PortalProxy) { + // Register auth type with Jetstream - use the same as the HttpBasic auth + + auth := c.portalProxy.GetAuthProvider(authTypeHttpBasic) + if auth.Handler != nil { + c.portalProxy.AddAuthProvider(c.GetName(), interfaces.AuthProvider{ + Handler: auth.Handler, + UserInfo: auth.UserInfo, + }) + } +} diff --git a/src/jetstream/plugins/kubernetes/auth/cert.go b/src/jetstream/plugins/kubernetes/auth/cert.go new file mode 100644 index 0000000000..7deb13b622 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/cert.go @@ -0,0 +1,185 @@ +package auth + +import ( + "bytes" + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + "time" + + // "github.com/SermoDigital/jose/jws" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +const authConnectTypeCertAuth = "kube-cert-auth" + +// CertKubeAuth is GKE Authentication with Certificates +type CertKubeAuth struct { + portalProxy interfaces.PortalProxy +} + +// InitCertKubeAuth creates a GKEKubeAuth +func InitCertKubeAuth(portalProxy interfaces.PortalProxy) *CertKubeAuth { + return &CertKubeAuth{portalProxy: portalProxy} +} + +// GetName returns the provider name +func (c *CertKubeAuth) GetName() string { + return authConnectTypeCertAuth +} + +func (c *CertKubeAuth) AddAuthInfo(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error { + kubeAuthToken := &KubeCertificate{} + err := json.NewDecoder(strings.NewReader(tokenRec.AuthToken)).Decode(kubeAuthToken) + if err != nil { + return err + } + + info.ClientCertificateData = []byte(kubeAuthToken.Certificate) + info.ClientKeyData = []byte(kubeAuthToken.CertificateKey) + info.Token = kubeAuthToken.Token + + return nil +} + +func (c *CertKubeAuth) extractCerts(ec echo.Context) (*KubeCertificate, error) { + + kubeCertAuth := &KubeCertificate{} + + bodyReader := ec.Request().Body + defer bodyReader.Close() + buf := new(bytes.Buffer) + buf.ReadFrom(bodyReader) + body := buf.String() + firstColon := strings.IndexByte(body, ':') + + cert, err := base64.StdEncoding.DecodeString(body[:firstColon]) + if err != nil { + return nil, err + } + certKey, err := base64.StdEncoding.DecodeString(body[firstColon+1:]) + if err != nil { + return nil, err + } + + kubeCertAuth.Certificate = string(cert) + kubeCertAuth.CertificateKey = string(certKey) + return kubeCertAuth, nil + +} + +func (c *CertKubeAuth) FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + log.Debug("Kube Certs - FetchToken") + + kubeCertAuth, err := c.extractCerts(ec) + if err != nil { + return nil, nil, errors.New("Unable to find required certificate and certificate key") + } + + jsonString, err := kubeCertAuth.GetJSON() + if err != nil { + return nil, nil, err + } + + // Refresh token isn't required since the AccessToken will never expire + refreshToken := jsonString + + accessToken := jsonString + + // Tokens lasts forever + expiry := time.Now().Local().Add(time.Hour * time.Duration(100000)) + disconnected := false + tokenRecord := c.portalProxy.InitEndpointTokenRecord(expiry.Unix(), accessToken, refreshToken, disconnected) + tokenRecord.AuthType = authConnectTypeCertAuth + return &tokenRecord, &cnsiRecord, nil +} + +func (c *CertKubeAuth) GetUserFromToken(cnsiGUID string, cfTokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) { + return &interfaces.ConnectedUser{ + GUID: "Kube Cert Auth", + Name: "Cert Auth", + }, true +} + +func (c *CertKubeAuth) DoFlowRequest(cnsiRequest *interfaces.CNSIRequest, req *http.Request) (*http.Response, error) { + log.Debug("doCertAuthFlowRequest") + + authHandler := func(tokenRec interfaces.TokenRecord, cnsi interfaces.CNSIRecord) (*http.Response, error) { + + kubeAuthToken := &KubeCertificate{} + err := json.NewDecoder(strings.NewReader(tokenRec.AuthToken)).Decode(kubeAuthToken) + if err != nil { + return nil, err + } + cert, err := kubeAuthToken.GetCerticate() + if err != nil { + return nil, err + } + dial := (&net.Dialer{ + Timeout: time.Duration(30) * time.Second, + KeepAlive: 30 * time.Second, + }).Dial + + sslTransport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + Dial: dial, + TLSHandshakeTimeout: 10 * time.Second, // 10 seconds is a sound default value (default is 0) + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cnsi.SkipSSLValidation, + Certificates: []tls.Certificate{cert}, + }, + MaxIdleConnsPerHost: 6, // (default is 2) + } + + kubeCertClient := http.Client{} + kubeCertClient.Transport = sslTransport + kubeCertClient.Timeout = time.Duration(30) * time.Second + + if kubeAuthToken.Token != "" { + req.Header.Set("Authorization", "bearer "+kubeAuthToken.Token) + } + + res, err := kubeCertClient.Do(req) + + kubeCertClient.CloseIdleConnections() + + if err != nil { + return nil, fmt.Errorf("Request failed: %v", err) + } + + if res.StatusCode != 401 { + return res, nil + } + return res, fmt.Errorf("Request failed with status code: %d ", res.StatusCode) + + } + return c.portalProxy.DoAuthFlowRequest(cnsiRequest, req, authHandler) +} + +func (c *CertKubeAuth) RefreshCertAuth(skipSSLValidation bool, cnsiGUID, userGUID, client, clientSecret, tokenEndpoint string) (t interfaces.TokenRecord, err error) { + log.Debug("RefreshCertAuth") + // This shouldn't be called since cert-auth K8S shouldn't expire + + userToken, ok := c.portalProxy.GetCNSITokenRecordWithDisconnected(cnsiGUID, userGUID) + if !ok { + return t, fmt.Errorf("Info could not be found for user with GUID %s", userGUID) + } + + return userToken, nil +} + +func (c *CertKubeAuth) RegisterJetstreamAuthType(portal interfaces.PortalProxy) { + // Register auth type with Jetstream + c.portalProxy.AddAuthProvider(c.GetName(), interfaces.AuthProvider{ + Handler: c.DoFlowRequest, + UserInfo: c.GetUserFromToken, + }) +} diff --git a/src/jetstream/plugins/kubernetes/auth/cert_tests.go b/src/jetstream/plugins/kubernetes/auth/cert_tests.go new file mode 100644 index 0000000000..4c052f5350 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/cert_tests.go @@ -0,0 +1,55 @@ +package auth + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" +) + +func TestFetchCertAuth(t *testing.T) { + + kubeSpec := &CertKubeAuth{} + + mockConnectRequet := "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURBRENDQWVpZ0F3SUJBZ0lCQWpBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwdGFXNXAKYTNWaVpVTkJNQjRYRFRFNE1UQXhNakUyTlRnMU1Wb1hEVEU1TVRBeE16RTJOVGcxTVZvd01URVhNQlVHQTFVRQpDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGakFVQmdOVkJBTVREVzFwYm1scmRXSmxMWFZ6WlhJd2dnRWlNQTBHCkNTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFEeFNtT2dORmV5VjkyRE5Sa0RmNVNSZlk5WHhGRVkKbFF2Z3lEcmFnVVpCcXFTaFBaZGdFdnBFWlVObkVoa1ZaYnc5WGUxelFMdVFuZnJEaERzOHlqTk8xWTV4ZUt2cwpIQlVBZEsxa2FQMlBXMVhSN3hxREh2SFdhQ3dRWVdHK1Bsa0NXMzg1YzBsNktXRUc5SVlFWFdkT1VDbEZuT3ZoCnJBN2RTNWR5eFViU3FCd0RSSDljMlhDVjQ4c1dLZnJhdmQvMTg5bWJENStQRk9ZditnRFFycHBTYnUzQytjTmYKbC93NDl0L0NQWlQ4bHFWU2FBUHhzOXFKbStKSFVFWHk3a1p2bGRZbG83NjJ3V1B6c0JoaXNkNU5ua2hmbGhrUApKSWNrWjN5eWF5bkdkOWJlKzdva2hwVEt1STNReGh5MTBrMWsxc1dyYmJTWVJDbmQ1akdVN3N2REFnTUJBQUdqClB6QTlNQTRHQTFVZER3RUIvd1FFQXdJRm9EQWRCZ05WSFNVRUZqQVVCZ2dyQmdFRkJRY0RBUVlJS3dZQkJRVUgKQXdJd0RBWURWUjBUQVFIL0JBSXdBREFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBQ2NNS2IreVNOeG9wYmRGNwpqcTBTN1EvQmFUbWhzWnVUWDRPL2V4ZllkZFpwK0didDc2VEVqYWdmUmQvbXFFazlheFAxWDVzWkRhQ283blkzCitDdnpMb1h2Y3NHdnVnRTJSWStsSjdBbTdLS0lTdGVGTFdsRnBDTXJBWXF6TG1Wb3NyeDlWM0ZTbWY2REdWRk4KSmVVVFBnYTFrNTltMUNFSjZDYTAzM2hEYmp6aWsxd0xtR3pLVmRDR29HT1N0Vm1tbTZFWWMza1hheGVXTUtuMQpoRDhmREV5R3p1Z2hhTkQyYjZGdnlha28yUVQ3dFd3L09yMXNhQWQ1S2N5Wk4xdDVtUHI1RHh0SmFKNHN2dzh4Ck01R0MySFVUM2pzK3dQdDVSUlpSRXd2MkNCUVNHUWtxcDFrWTNLV1pXTDB2WEQvbjJqYlZGUUtacHQ2dDZKT3EKaVQ2bnlRPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBOFVwam9EUlhzbGZkZ3pVWkEzK1VrWDJQVjhSUkdKVUw0TWc2Mm9GR1FhcWtvVDJYCllCTDZSR1ZEWnhJWkZXVzhQVjN0YzBDN2tKMzZ3NFE3UE1velR0V09jWGlyN0J3VkFIU3RaR2o5ajF0VjBlOGEKZ3g3eDFtZ3NFR0Zodmo1WkFsdC9PWE5KZWlsaEJ2U0dCRjFuVGxBcFJaenI0YXdPM1V1WGNzVkcwcWdjQTBSLwpYTmx3bGVQTEZpbjYycjNmOWZQWm13K2ZqeFRtTC9vQTBLNmFVbTd0d3ZuRFg1ZjhPUGJmd2oyVS9KYWxVbWdECjhiUGFpWnZpUjFCRjh1NUdiNVhXSmFPK3RzRmo4N0FZWXJIZVRaNUlYNVlaRHlTSEpHZDhzbXNweG5mVzN2dTYKSklhVXlyaU4wTVljdGRKTlpOYkZxMjIwbUVRcDNlWXhsTzdMd3dJREFRQUJBb0lCQVFESEVJd291NFl1U0hjagpyRWE2c0NLdDlWeXhGL0dmeWpkR2QycTJvamlJTEhRdDRsWmttTU9JY2RLdDBpeUhqcXRDSlora21oOGtMSEdaCnBCb0xDUFpUYjdSWXdTbDFYYVdsL3B5ZVhrL3lXWFB3QXNkb3JicnZISHBkK1RsZWJxbVlYRXdWNVpzVkFkWmUKbXBXR1BGamlMeGdkcWx5Z2pnYWxZNXZLd0I2eDR3MHZTczdaUVBoQkFqTSs3a2Q3ZUpBRUJHZUxRL1lRTEIvVgo1Qmtrci9mT2d6K1A3V2t5QUovRk1iRzY4K0E3Z1FHeTJvWGdnbHVsOXg5YjlkcXJDSm5NVHFkNENvbU5qTmFjCkpValhjMEVGeFJqTjdOR0djenhXREVnc1dicGMxeFpYK20zcEQrQnRDd3BSeXJ4Q3NDWGwrTllnT01Jb1FZaWIKaUxQZzhsM0JBb0dCQVBQa3BMdTM0U1NhQVJNOE1YMWxCckdMa0k2cjlLZW5MaVZNYU55bG9CMWtyM29DaEJueApiek5kMkdxaXNBbEJFb3JVWEdkOVBadVNqYmRGMnVxTGgvdVMvdlBBMUpmR3drakZERmNyTVlXeDExWGVMcy9RCmYrdjF2dmQ5RUp5b0w2ekIxQkhaelZuenFIQ2w4OWlvNEY3Wkl3MmlKQ0NjWlJiN2JlOUdzbkdiQW9HQkFQMUUKckR3bldQNG5XN00wdlJYcFVkY3Q1UE96dHZIVFpoNTFJaC93NklOQzladXl3eFJoMi9XTEs0ZlZBRDYxeFloTgpVR0FvU2FUQzRrZWdoYlUvTWY5aWtBS3c3MWRPdk5HSzBSdjROTlZwbEJWbFhhcnF5OEpTeGJIcnNNU25rSTI4CjhNZ21YdlYvNkYwZTUxWFl1bHlYUXNqVWpCbVRXUTUzZnRWcXRhVDVBb0dCQU9vRkpod0pJRHNpbW8xK1lHNVYKbGNxZWhDS2gxS3RadXVtSEc4YzhGUnFmRmRFWXdQQ3p2V09vVkpSZGJsUXk0RHZkOEp4TWkrVFBCclFvanhvbQpzR0F3ZC9vanVObTVtWXFCcUltcnBHVUljL3FzcW5ZMU5jbVBqNkdobTJMMTdtanh3eTh0c2VEeDcxbkhvdWJ0CmcveitsS2ZzUUlZYUN0VzJnNUhvWUNpcEFvR0JBTTNtcHAvQTNYakNScXJLbFc3YTRNNHZZWk0rNTl4eUlQTmkKQnZ3d3Z0YjMrUFU3djUweWNjQ09CRFhKMVFrbWZoRHh5Z1ppdW54WWM5NEhncXgzVkE1cjh1ZzlNRmVxaTVkUApZL0Y1T0hySCtydnFUTnhIUnFBVTZ1UmEyTHNILzEwNzNnVGFMUmtwZzU4eElLR0tNUGhWZ05ZRTltRlVpWEpaCmM2UE52UjhCQW9HQU0zNTlZSUNUalNNYlYvSjJUbUx1Zjh2ejh0WVJDeXlla0h6Z2tTcmpqWUZWNmoxUTllWGQKa1ZFWitaYW1xYkZvUmxqK1lHS21XQ0w0OXJTWkYxNmVYL1V3OW5haEErcmtYL21aaERkOXIrVE90enpycUVYSQpUQVlld3R5ZGEzUzkyaytOWU8zZE5kbWZLQVdYV2JzdjZUKzBBTXR6VTFkMlNaaDlsTU5aVkUwPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=" + e := echo.New() + req := httptest.NewRequest(echo.POST, "/pp/v1/auth/login/cnsi?cert=&certKey=&cnsi_guid=testGuid&connect_type=kube-cert-auth&system_shared=false", strings.NewReader(mockConnectRequet)) + + engineReq := req + rec := httptest.NewRecorder() + res := echo.NewResponse(rec, e) + c := e.NewContext(engineReq, res) + + kubeCertAuth, err := kubeSpec.extractCerts(c) + if err != nil { + t.Fail() + } + + if kubeCertAuth.Certificate == "" { + t.Fail() + } + + if kubeCertAuth.CertificateKey == "" { + t.Fail() + } + + jsonString, err := kubeCertAuth.GetJSON() + + if err != nil { + t.Fail() + } + + testKubeCertAuth := &KubeCertificate{} + err = json.NewDecoder(strings.NewReader(jsonString)).Decode(testKubeCertAuth) + if err != nil { + t.Fail() + } + if testKubeCertAuth.Certificate != kubeCertAuth.Certificate { + t.Fail() + } + if testKubeCertAuth.CertificateKey != kubeCertAuth.CertificateKey { + t.Fail() + } +} diff --git a/src/jetstream/plugins/kubernetes/auth/gke.go b/src/jetstream/plugins/kubernetes/auth/gke.go new file mode 100644 index 0000000000..573f192f08 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/gke.go @@ -0,0 +1,203 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/SermoDigital/jose/jws" +) + +const ( + gkeConfigType = "authorized_user" + googleOAuthEndpoint = "https://www.googleapis.com/oauth2/v4/token" +) + +// GKEConfig is the format of the config file we expect for GKE authentication +type GKEConfig struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RefreshToken string `json:"refresh_token"` + Type string `json:"type"` + Email string `json:"email"` +} + +// GKEKubeAuth is GKE Authentication for Kubernetes +type GKEKubeAuth struct { + portalProxy interfaces.PortalProxy +} + +const authConnectTypeGKE = "gke-auth" + +// InitGKEKubeAuth creates a GKEKubeAuth +func InitGKEKubeAuth(portalProxy interfaces.PortalProxy) KubeAuthProvider { + return &GKEKubeAuth{portalProxy: portalProxy} +} + +// GetName returns the provider name +func (c *GKEKubeAuth) GetName() string { + return authConnectTypeGKE +} + +func (c *GKEKubeAuth) AddAuthInfo(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error { + gkeInfo := &GKEConfig{} + err := json.Unmarshal([]byte(tokenRec.RefreshToken), &gkeInfo) + if err != nil { + return err + } + + info.Token = tokenRec.AuthToken + return nil +} + +// FetchToken will create a token for the GKE Authentication using the POSTed data +func (c *GKEKubeAuth) FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + log.Debug("FetchToken (GKE)") + + // We should already have the refresh token in the body sent to us + req := ec.Request() + + // Need to extract the parameters from the request body + defer req.Body.Close() + body, err := ioutil.ReadAll(req.Body) + if err != nil { + return nil, nil, err + } + + gkeInfo := &GKEConfig{} + err = json.Unmarshal(body, &gkeInfo) + if err != nil { + return nil, nil, err + } + + // Type needs to be "authorized_user" + if gkeInfo.Type != gkeConfigType || len(gkeInfo.RefreshToken) == 0 { + return nil, nil, errors.New("Invalid configuration file") + } + + oauthToken, err := c.refreshGKEToken(cnsiRecord.SkipSSLValidation, gkeInfo.ClientID, gkeInfo.ClientSecret, gkeInfo.RefreshToken) + if err != nil { + return nil, nil, fmt.Errorf("Could not refresh the GKE token: %v+", err) + } + + token, err := jws.ParseJWT([]byte(oauthToken.IDToken)) + if err != nil { + log.Info(err) + return nil, nil, errors.New("Can not parse JWT Access token") + } + + email := token.Claims().Get("email") + if emailAddress, ok := email.(string); ok { + gkeInfo.Email = emailAddress + } + + tokenInfo, err := json.Marshal(gkeInfo) + if err != nil { + return nil, nil, err + } + + // Create a new token record - we need to store the client ID and secret as well, so cheekily use the refresh token for this + tokenRecord := c.portalProxy.InitEndpointTokenRecord(0, oauthToken.AccessToken, string(tokenInfo), false) + tokenRecord.AuthType = authConnectTypeGKE + return &tokenRecord, &cnsiRecord, nil +} + +// GetUserFromToken gets the username from the GKE Token +func (c *GKEKubeAuth) GetUserFromToken(cnsiGUID string, tokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) { + log.Debug("GetUserFromToken (GKE)") + + gkeInfo := &GKEConfig{} + err := json.Unmarshal([]byte(tokenRecord.RefreshToken), &gkeInfo) + if err != nil { + return nil, false + } + + return &interfaces.ConnectedUser{ + GUID: gkeInfo.Email, + Name: gkeInfo.Email, + }, true +} + +func (c *GKEKubeAuth) DoFlowRequest(cnsiRequest *interfaces.CNSIRequest, req *http.Request) (*http.Response, error) { + log.Debug("doGKEFlowRequest") + + authHandler := c.portalProxy.OAuthHandlerFunc(cnsiRequest, req, c.RefreshGKEToken) + return c.portalProxy.DoAuthFlowRequest(cnsiRequest, req, authHandler) +} + +// RefreshGKEToken will refresh a GKE token +func (c *GKEKubeAuth) RefreshGKEToken(skipSSLValidation bool, cnsiGUID, userGUID, client, clientSecret, tokenEndpoint string) (t interfaces.TokenRecord, err error) { + log.Debug("RefreshGKEToken") + now := time.Now() + + userToken, ok := c.portalProxy.GetCNSITokenRecordWithDisconnected(cnsiGUID, userGUID) + if !ok { + return t, fmt.Errorf("Info could not be found for user with GUID %s", userGUID) + } + + // Refresh token is the GKE info + var gkeInfo GKEConfig + err = json.Unmarshal([]byte(userToken.RefreshToken), &gkeInfo) + if err != nil { + return userToken, fmt.Errorf("Could not get the GKE info from the refresh token: %v+", err) + } + + oauthToken, err := c.refreshGKEToken(skipSSLValidation, gkeInfo.ClientID, gkeInfo.ClientSecret, gkeInfo.RefreshToken) + if err != nil { + return userToken, fmt.Errorf("Could not refresh the GKE token: %v+", err) + } + + userToken.AuthToken = oauthToken.AccessToken + + duration := time.Duration(oauthToken.ExpiresIn) * time.Second + expiry := now.Add(duration).Unix() + userToken.TokenExpiry = expiry + + return userToken, nil +} + +func (c *GKEKubeAuth) refreshGKEToken(skipSSLValidation bool, clientID, clientSecret, refreshToken string) (u interfaces.UAAResponse, err error) { + log.Debug("refreshGKEToken") + tokenInfo := interfaces.UAAResponse{} + + // Go and get a new access token + httpClient := c.portalProxy.GetHttpClient(skipSSLValidation) + body := fmt.Sprintf("client_secret=%s&refresh_token=%s&client_id=%s&grant_type=refresh_token", url.QueryEscape(clientSecret), url.QueryEscape(refreshToken), url.QueryEscape(clientID)) + resp, err := httpClient.Post(googleOAuthEndpoint, "application/x-www-form-urlencoded", strings.NewReader(body)) + if err != nil { + return tokenInfo, err + } + + // Check status code + if resp.StatusCode != 200 { + return tokenInfo, fmt.Errorf("Failed to get access token: %s", resp.Status) + } + + // Parse the response + defer resp.Body.Close() + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return tokenInfo, err + } + + err = json.Unmarshal(respBody, &tokenInfo) + return tokenInfo, err +} + +func (c *GKEKubeAuth) RegisterJetstreamAuthType(portal interfaces.PortalProxy) { + // Register auth type with Jetstream + c.portalProxy.AddAuthProvider(c.GetName(), interfaces.AuthProvider{ + Handler: c.DoFlowRequest, + UserInfo: c.GetUserFromToken, + }) +} diff --git a/src/jetstream/plugins/kubernetes/auth/kubeconfig.go b/src/jetstream/plugins/kubernetes/auth/kubeconfig.go new file mode 100644 index 0000000000..82f973ee21 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/kubeconfig.go @@ -0,0 +1,124 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/config" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +const AuthConnectTypeKubeConfig = "kubeconfig" + +// KubeConfigAuth will look at the kube config file and use the appropriate auth provider + +// KubeConfigAuth is same as OIDC with different name +type KubeConfigAuth struct { + OIDCKubeAuth +} + +// InitKubeConfigAuth +func InitKubeConfigAuth(portalProxy interfaces.PortalProxy) KubeAuthProvider { + return &KubeConfigAuth{*InitOIDCKubeAuth(portalProxy)} +} + +func (c *KubeConfigAuth) GetName() string { + return AuthConnectTypeKubeConfig +} + +func (c *KubeConfigAuth) AddAuthInfo(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error { + log.Error("KubeConfigAuth: AddAuthInfo: Not supported") + return fmt.Errorf("Not supported: %s", tokenRec.AuthType) +} + +func (c *KubeConfigAuth) FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + log.Debug("FetchToken (KubeConfigAuth)") + + req := ec.Request() + + // Need to extract the parameters from the request body + defer req.Body.Close() + body, err := ioutil.ReadAll(req.Body) + if err != nil { + return nil, nil, err + } + + kubeConfig, err := config.ParseKubeConfig(body) + kubeConfigUser, err := kubeConfig.GetUserForCluster(cnsiRecord.APIEndpoint.String()) + if err != nil { + return nil, nil, fmt.Errorf("Unable to find cluster in kubeconfig") + } + + // OIDC ? == CaaSP V3 + if kubeConfigUser.User.AuthProvider.Name == "oidc" { + return c.GetTokenFromKubeConfigUser(cnsiRecord, kubeConfigUser) + } + + // Check for Certificate == CaaSP V4 + if len(kubeConfigUser.User.ClientCertificate) > 0 && len(kubeConfigUser.User.ClientKeyData) > 0 { + return c.GetCertAuth(cnsiRecord, kubeConfigUser) + } + + // Check for Token == CaaSP V4 + if len(kubeConfigUser.User.Token) > 0 { + tokenRecord := NewKubeTokenAuthTokenRecord(c.portalProxy, kubeConfigUser.User.Token) + + // Could try and make a K8S Api call to validate the token + // Or, maybe we can verify the access token with the auth URL ? + return tokenRecord, &cnsiRecord, nil + } + + return nil, nil, fmt.Errorf("OIDC: Unsupported authentication provider for user: %s", kubeConfigUser.User.AuthProvider.Name) +} + +func (c *KubeConfigAuth) GetCertAuth(cnsiRecord interfaces.CNSIRecord, user *config.KubeConfigUser) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + + kubeCertAuth := &KubeCertificate{} + + cert, err := base64.StdEncoding.DecodeString(user.User.ClientCertificate) + if err != nil { + return nil, nil, err + } + certKey, err := base64.StdEncoding.DecodeString(user.User.ClientKeyData) + if err != nil { + return nil, nil, err + } + + kubeCertAuth.Certificate = string(cert) + kubeCertAuth.CertificateKey = string(certKey) + + jsonString, err := kubeCertAuth.GetJSON() + if err != nil { + return nil, nil, err + } + + // Refresh token isn't required since the AccessToken will never expire + refreshToken := jsonString + + accessToken := jsonString + + // Tokens lasts forever + disconnected := false + + tokenRecord := c.portalProxy.InitEndpointTokenRecord(getLargeExpiryTime(), accessToken, refreshToken, disconnected) + tokenRecord.AuthType = authConnectTypeCertAuth + return &tokenRecord, &cnsiRecord, nil +} + +func (c *KubeConfigAuth) RegisterJetstreamAuthType(portal interfaces.PortalProxy) { + // Register auth type with Jetstream + c.portalProxy.AddAuthProvider(c.GetName(), interfaces.AuthProvider{ + Handler: c.portalProxy.DoOidcFlowRequest, + UserInfo: nil, + }) +} + +func getLargeExpiryTime() int64 { + expiry := time.Now().Local().Add(time.Hour * time.Duration(100000)) + return expiry.Unix() +} diff --git a/src/jetstream/plugins/kubernetes/auth/oidc.go b/src/jetstream/plugins/kubernetes/auth/oidc.go new file mode 100644 index 0000000000..dea26fa43f --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/oidc.go @@ -0,0 +1,170 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/config" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + + "github.com/SermoDigital/jose/jws" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +type KubeConfigAuthProviderOIDC struct { + ClientID string `yaml:"client-id"` + ClientSecret string `yaml:"client-secret"` + IDToken string `yaml:"id-token"` + IdpIssuerURL string `yaml:"idp-issuer-url"` + RefreshToken string `yaml:"refresh-token"` + Expiry time.Time +} + +const authConnectTypeOIDC = "OIDC" + +// OIDCKubeAuth +type OIDCKubeAuth struct { + portalProxy interfaces.PortalProxy +} + +// InitOIDCKubeAuth +func InitOIDCKubeAuth(portalProxy interfaces.PortalProxy) *OIDCKubeAuth { + return &OIDCKubeAuth{portalProxy: portalProxy} +} + +// GetName returns the provider name +func (c *OIDCKubeAuth) GetName() string { + return authConnectTypeOIDC +} + +func (c *OIDCKubeAuth) AddAuthInfo(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error { + authInfo := &interfaces.OAuth2Metadata{} + err := json.Unmarshal([]byte(tokenRec.Metadata), &authInfo) + if err != nil { + return err + } + + info.AuthProvider = &clientcmdapi.AuthProviderConfig{} + info.AuthProvider.Name = "oidc" + info.AuthProvider.Config = make(map[string]string) + info.AuthProvider.Config["client-id"] = authInfo.ClientID + info.AuthProvider.Config["client-secret"] = authInfo.ClientSecret + info.AuthProvider.Config["idp-issuer-url"] = authInfo.IssuerURL + + info.AuthProvider.Config["id-token"] = tokenRec.AuthToken + info.AuthProvider.Config["refresh-token"] = tokenRec.RefreshToken + info.AuthProvider.Config["extra-scopes"] = "groups" + + return nil +} + +func (c *OIDCKubeAuth) FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + log.Debug("FetchToken (OIDC)") + + req := ec.Request() + + // Need to extract the parameters from the request body + defer req.Body.Close() + body, err := ioutil.ReadAll(req.Body) + if err != nil { + return nil, nil, err + } + + kubeConfig, err := config.ParseKubeConfig(body) + + kubeConfigUser, err := kubeConfig.GetUserForCluster(cnsiRecord.APIEndpoint.String()) + + if err != nil { + return nil, nil, fmt.Errorf("Unable to find cluster in kubeconfig") + } + + // We only support OIDC auth provider at the moment + if kubeConfigUser.User.AuthProvider.Name != "oidc" { + return nil, nil, fmt.Errorf("OIDC: Unsupported authentication provider for user: %s", kubeConfigUser.User.AuthProvider.Name) + } + + return c.GetTokenFromKubeConfigUser(cnsiRecord, kubeConfigUser) +} + +func (c *OIDCKubeAuth) GetTokenFromKubeConfigUser(cnsiRecord interfaces.CNSIRecord, kubeConfigUser *config.KubeConfigUser) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + + oidcConfig, err := c.GetOIDCConfig(kubeConfigUser) + if err != nil { + log.Info(err) + return nil, nil, errors.New("Can not unmarshal OIDC Auth Provider configuration") + } + tokenRecord := c.portalProxy.InitEndpointTokenRecord(oidcConfig.Expiry.Unix(), oidcConfig.IDToken, oidcConfig.RefreshToken, false) + tokenRecord.AuthType = interfaces.AuthTypeOIDC + + oauthMetadata := &interfaces.OAuth2Metadata{} + oauthMetadata.ClientID = oidcConfig.ClientID + oauthMetadata.ClientSecret = oidcConfig.ClientSecret + oauthMetadata.IssuerURL = oidcConfig.IdpIssuerURL + + jsonString, err := json.Marshal(oauthMetadata) + if err == nil { + tokenRecord.Metadata = string(jsonString) + } + + // Could try and make a K8S Api call to validate the token + // Or, maybe we can verify the access token with the auth URL ? + + return &tokenRecord, &cnsiRecord, nil +} + +// GetUserFromToken gets the username from the GKE Token +func (c *OIDCKubeAuth) GetUserFromToken(cnsiGUID string, tokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) { + log.Debug("GetUserFromToken (OIDC)") + return c.portalProxy.GetCNSIUserFromOAuthToken(cnsiGUID, tokenRecord) +} + +func (c *OIDCKubeAuth) GetOIDCConfig(k *config.KubeConfigUser) (*KubeConfigAuthProviderOIDC, error) { + + if k.User.AuthProvider.Name != "oidc" { + return nil, errors.New("User doesn't use OIDC") + } + + OIDCConfig := &KubeConfigAuthProviderOIDC{} + err := config.UnMarshalHelper(k.User.AuthProvider.Config, OIDCConfig) + if err != nil { + log.Info(err) + return nil, errors.New("Can not unmarshal OIDC Auth Provider configuration") + } + + token, err := jws.ParseJWT([]byte(OIDCConfig.IDToken)) + if err != nil { + log.Info(err) + return nil, errors.New("Can not parse JWT Access token") + } + + expiry, ok := token.Claims().Expiration() + if !ok { + return nil, errors.New("Can not get Access Token expiry time") + } + OIDCConfig.Expiry = expiry + + return OIDCConfig, nil +} + +func (c *OIDCKubeAuth) DoFlowRequest(cnsiRequest *interfaces.CNSIRequest, req *http.Request) (*http.Response, error) { + log.Debug("DoFlowRequest (OIDC)") + return c.portalProxy.DoOidcFlowRequest(cnsiRequest, req) +} + +func (c *OIDCKubeAuth) RegisterJetstreamAuthType(portal interfaces.PortalProxy) { + // No need to register OIDC, as its already built in + existing := c.portalProxy.HasAuthProvider(c.GetName()) + if !existing { + // Register auth type with Jetstream + c.portalProxy.AddAuthProvider(c.GetName(), interfaces.AuthProvider{ + Handler: c.portalProxy.DoOidcFlowRequest, + UserInfo: nil, + }) + } +} diff --git a/src/jetstream/plugins/kubernetes/auth/token.go b/src/jetstream/plugins/kubernetes/auth/token.go new file mode 100644 index 0000000000..44806481a3 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/token.go @@ -0,0 +1,79 @@ +package auth + +import ( + "strings" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" +) + +const AuthConnectTypeKubeToken = "k8sToken" + +// KubeTokenAuth uses a token (e.g. service account token) +type KubeTokenAuth struct { + portalProxy interfaces.PortalProxy +} + +// InitKubeTokenAuth +func InitKubeTokenAuth(portalProxy interfaces.PortalProxy) KubeAuthProvider { + return &KubeTokenAuth{portalProxy} +} + +func (c *KubeTokenAuth) GetName() string { + return AuthConnectTypeKubeToken +} + +func (c *KubeTokenAuth) AddAuthInfo(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error { + log.Debug("AddAuthInfo: KubeTokenAuth") + // Just add the token in + info.Token = tokenRec.AuthToken + return nil +} + +func (c *KubeTokenAuth) FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) { + log.Debug("FetchToken (KubeTokenAuth)") + + token := ec.FormValue("token") + + tokenRecord := NewKubeTokenAuthTokenRecord(c.portalProxy, token) + return tokenRecord, &cnsiRecord, nil +} + +func NewKubeTokenAuthTokenRecord(portalProxy interfaces.PortalProxy, token string) *interfaces.TokenRecord { + tokenRecord := portalProxy.InitEndpointTokenRecord(getLargeExpiryTime(), token, "__NONE__", false) + tokenRecord.AuthType = AuthConnectTypeKubeToken + + return &tokenRecord +} + +func (c *KubeTokenAuth) RegisterJetstreamAuthType(portal interfaces.PortalProxy) { + // Register auth type with Jetstream + c.portalProxy.AddAuthProvider(c.GetName(), interfaces.AuthProvider{ + Handler: c.portalProxy.DoOidcFlowRequest, + UserInfo: c.GetUserFromToken, + }) +} + +func (c *KubeTokenAuth) GetUserFromToken(cnsiGUID string, tokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) { + log.Debug("GetUserFromToken (KubeTokenAuth)") + + // See if we can get token info - if we can, use it + _, err := c.portalProxy.GetUserTokenInfo(tokenRecord.AuthToken) + if err == nil { + return c.portalProxy.GetCNSIUserFromOAuthToken(cnsiGUID, tokenRecord) + } + + parts := strings.Split(tokenRecord.AuthToken, ":") + if len(parts) != 2 { + log.Errorf("Could not get user information from token: %s", tokenRecord.TokenGUID) + return nil, false + } + + return &interfaces.ConnectedUser{ + GUID: parts[0], + Name: parts[0], + Scopes: make([]string, 0), + }, true +} diff --git a/src/jetstream/plugins/kubernetes/auth/types.go b/src/jetstream/plugins/kubernetes/auth/types.go new file mode 100644 index 0000000000..141797bc68 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth/types.go @@ -0,0 +1,52 @@ +package auth + +import ( + "crypto/tls" + "encoding/json" + "net/http" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/labstack/echo/v4" +) + +// KubeAuthProvider is the interface for Kubernetes Authentication Providers +type KubeAuthProvider interface { + GetName() string + AddAuthInfo(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error + FetchToken(cnsiRecord interfaces.CNSIRecord, ec echo.Context) (*interfaces.TokenRecord, *interfaces.CNSIRecord, error) + + RegisterJetstreamAuthType(portal interfaces.PortalProxy) +} + +// KubeJetstreamAuthProvider is the optional interface that can be implemented if you want to control Jetstream Auth Registration +type KubeJetstreamAuthProvider interface { + DoFlowRequest(cnsiRequest *interfaces.CNSIRequest, req *http.Request) (*http.Response, error) + GetUserFromToken(cnsiGUID string, tokenRecord *interfaces.TokenRecord) (*interfaces.ConnectedUser, bool) +} + +// KubeCertificate represents certificate infor for Kube Authentication +type KubeCertificate struct { + Certificate string `json:"cert"` + CertificateKey string `json:"certKey"` + Token string `json:"token,omitempty"` +} + +// GetJSON persists the config to JSON +func (k *KubeCertificate) GetJSON() (string, error) { + jsonString, err := json.Marshal(k) + if err != nil { + return "", err + } + return string(jsonString), nil +} + +// GetCerticate gets a certiciate from the info available +func (k *KubeCertificate) GetCerticate() (tls.Certificate, error) { + cert, err := tls.X509KeyPair([]byte(k.Certificate), []byte(k.CertificateKey)) + if err != nil { + return tls.Certificate{}, err + } + return cert, nil +} diff --git a/src/jetstream/plugins/kubernetes/auth_providers.go b/src/jetstream/plugins/kubernetes/auth_providers.go new file mode 100644 index 0000000000..eaaf6c8a83 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/auth_providers.go @@ -0,0 +1,42 @@ +package kubernetes + +import ( + "strings" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/auth" +) + +var kubeAuthProviders map[string]auth.KubeAuthProvider + +// AddAuthProvider adds a Kubernetes auth provider +func (c *KubernetesSpecification) AddAuthProvider(provider auth.KubeAuthProvider) { + if provider == nil { + return + } + + var name = provider.GetName() + if kubeAuthProviders == nil { + kubeAuthProviders = make(map[string]auth.KubeAuthProvider) + } + + kubeAuthProviders[name] = provider + + // Get the auth provider to register itself with Stratos, if needed + provider.RegisterJetstreamAuthType(c.portalProxy) +} + +// GetAuthProvider gets a Kubernetes auth provider by key +func (c *KubernetesSpecification) GetAuthProvider(name string) auth.KubeAuthProvider { + return kubeAuthProviders[name] +} + +// FindAuthProvider finds auth provider - case insensitive +func (c *KubernetesSpecification) FindAuthProvider(name string) auth.KubeAuthProvider { + for k, v := range kubeAuthProviders { + if strings.EqualFold(name, k) { + return v + } + } + + return nil +} diff --git a/src/jetstream/plugins/kubernetes/config/kube_config.go b/src/jetstream/plugins/kubernetes/config/kube_config.go new file mode 100644 index 0000000000..7bb67f5b6e --- /dev/null +++ b/src/jetstream/plugins/kubernetes/config/kube_config.go @@ -0,0 +1,226 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "reflect" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces/config" + + "gopkg.in/yaml.v2" +) + +type KubeConfigClusterDetail struct { + Server string `yaml:"server"` +} + +type KubeConfigCluster struct { + Name string `yaml:"name"` + Cluster struct { + Server string + } +} + +type KubeConfigUser struct { + Name string `yaml:"name"` + User struct { + AuthProvider struct { + Name string `yaml:"name"` + Config map[string]interface{} `yaml:"config"` + } `yaml:"auth-provider,omitempty"` + ClientCertificate string `yaml:"client-certificate-data,omitempty"` + ClientKeyData string `yaml:"client-key-data,omitempty"` + Token string `yaml:"token,omitempty"` + } +} + +//ExtraScopes string `yaml:"extra-scopes"` + +type KubeConfigContexts struct { + Name string `yaml:"name"` + Context struct { + Cluster string + User string + } `yaml:"context"` +} + +type KubeConfigFile struct { + ApiVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Clusters []KubeConfigCluster `yaml:"clusters"` + Users []KubeConfigUser `yaml:"users"` + Contexts []KubeConfigContexts `yaml:"contexts"` + CurrentContext string `yaml:"current-context"` +} + +func (k *KubeConfigFile) GetClusterByAPIEndpoint(endpoint string) (*KubeConfigCluster, error) { + for _, cluster := range k.Clusters { + if compareURL(cluster.Cluster.Server, endpoint) { + return &cluster, nil + } + } + return nil, fmt.Errorf("Unable to find cluster") +} + +func (k *KubeConfigFile) GetClusterByName(name string) (*KubeConfigCluster, error) { + for _, cluster := range k.Clusters { + if cluster.Name == name { + return &cluster, nil + } + } + return nil, fmt.Errorf("Unable to find cluster") +} + +func (k *KubeConfigFile) GetClusterContext(clusterName string) (*KubeConfigContexts, error) { + for _, context := range k.Contexts { + if context.Context.Cluster == clusterName { + return &context, nil + } + } + return nil, fmt.Errorf("Unable to find context") +} + +func (k *KubeConfigFile) GetContext(contextName string) (*KubeConfigContexts, error) { + for _, context := range k.Contexts { + if context.Name == contextName { + return &context, nil + } + } + return nil, fmt.Errorf("Unable to find context") +} + +func (k *KubeConfigFile) GetUser(userName string) (*KubeConfigUser, error) { + for _, user := range k.Users { + if user.Name == userName { + return &user, nil + } + } + return nil, fmt.Errorf("Unable to find user") +} + +func (k *KubeConfigFile) GetUserForCluster(clusterEndpoint string) (*KubeConfigUser, error) { + + var cluster *KubeConfigCluster + var err error + + // Check to see if the current-context is for this endpoint, before going a search through all contexts + if len(k.CurrentContext) > 0 { + currentContext, err := k.GetContext(k.CurrentContext) + if err == nil { + c, err := k.GetClusterByName(currentContext.Context.Cluster) + if err == nil { + if compareURL(c.Cluster.Server, clusterEndpoint) { + // Cluster refrences the same Kube API Server + cluster = c + } + } + } + } + + if cluster == nil { + cluster, err = k.GetClusterByAPIEndpoint(clusterEndpoint) + if err != nil { + return nil, errors.New("Unable to find cluster in kubeconfig") + } + } + + clusterName := cluster.Name + if clusterName == "" { + return nil, errors.New("Unable to find cluster") + } + + context, err := k.GetClusterContext(clusterName) + if err != nil { + return nil, errors.New("Unable to find cluster context") + } + + kubeConfigUser, err := k.GetUser(context.Context.User) + if err != nil { + return nil, errors.New("Can not find config for Kubernetes cluster") + } + + return kubeConfigUser, nil +} + +func ParseKubeConfig(kubeConfigData []byte) (*KubeConfigFile, error) { + + kubeConfig := &KubeConfigFile{} + err := yaml.Unmarshal(kubeConfigData, &kubeConfig) + if err != nil { + return nil, err + } + if kubeConfig.ApiVersion != "v1" || kubeConfig.Kind != "Config" { + return nil, errors.New("Not a valid Kubernetes Config file") + } + + return kubeConfig, nil +} + +func UnMarshalHelper(values map[string]interface{}, intf interface{}) error { + + value := reflect.ValueOf(intf) + + if value.Kind() != reflect.Ptr { + return errors.New("config: must provide pointer to struct value") + } + + value = value.Elem() + if value.Kind() != reflect.Struct { + return errors.New("config: must provide pointer to struct value") + } + + nFields := value.NumField() + typ := value.Type() + + for i := 0; i < nFields; i++ { + field := value.Field(i) + strField := typ.Field(i) + tag := strField.Tag.Get("yaml") + if tag == "" { + continue + } + + if tagValue, ok := values[tag].(string); ok { + if err := config.SetStructFieldValue(value, field, tagValue); err != nil { + return err + } + } + } + + return nil +} + +// Compare two URLs, taking into account default HTTP/HTTPS ports and ignoring query string +func compareURL(a, b string) bool { + + ua, err := url.Parse(a) + if err != nil { + return false + } + + ub, err := url.Parse(b) + if err != nil { + return false + } + + aPort := getPort(ua) + bPort := getPort(ub) + return ua.Scheme == ub.Scheme && ua.Hostname() == ub.Hostname() && aPort == bPort && ua.Path == ub.Path +} + +func getPort(u *url.URL) string { + port := u.Port() + if len(port) == 0 { + switch u.Scheme { + case "http": + port = "80" + case "https": + port = "443" + default: + port = "" + } + } + + return port +} diff --git a/src/jetstream/plugins/kubernetes/dashboard/common.go b/src/jetstream/plugins/kubernetes/dashboard/common.go new file mode 100644 index 0000000000..7d19495f25 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/dashboard/common.go @@ -0,0 +1,249 @@ +package dashboard + +import ( + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + + v1 "k8s.io/api/core/v1" +) + +const ( + kubeDashSessionGroup = "kubernetes-dashboard" + kubeDashSessionEndpointID = "kubeDashSessionEndpointID" + kubeDashSessionNamespace = "kubeDashSessionNamespace" + kubeDashSessionScheme = "kubeDashSessionScheme" + kubeDashSessionServiceName = "kubeDashSessionServiceName" + kubeDashSessionToken = "kubeDashSessionToken" + + defaultFlushInterval = 200 * time.Millisecond +) + +// ServiceInfo represents the information for the Dashboard Service +// that we need to proxy the service +type ServiceInfo struct { + Namespace string `json:"namespace"` + ServiceName string `json:"name"` + Scheme string `json:"scheme"` + StratosInstalled bool `json:"-"` +} + +// StatusResponse is the response from the dashboard status check +type StatusResponse struct { + Endpoint string `json:"guid"` + Installed bool `json:"installed"` + StratosInstalled bool `json:"stratosInstalled"` + Running bool `json:"running"` + Pod *v1.Pod `json:"pod"` + Version string `json:"version"` + Service *ServiceInfo `json:"service"` + HasToken bool `json:"tokenExists"` + ServiceAccont *v1.ServiceAccount `json:"serviceAccount"` + Token string `json:"-"` +} + +// Determine if the specified Kube endpoint has the dashboard installed and ready +func getKubeDashboardPod(p interfaces.PortalProxy, cnsiGUID, userGUID string, labelSelector string) (*v1.Pod, error) { + log.Debug("kubeDashboardStatus request") + + response, err := p.DoProxySingleRequest(cnsiGUID, userGUID, "GET", "/api/v1/pods?labelSelector="+labelSelector, nil, nil) + if err != nil || response.StatusCode != 200 { + return nil, errors.New("Could not fetch pod list") + } + + ok, list, err := tryDecodePodList(response.Response) + if !ok { + return nil, errors.New("Kube dashboard not installed - could not decode pod list") + } + + if len(list.Items) == 0 { + return nil, errors.New("Kube dashboard not installed") + } + + // Should just be one pod + if len(list.Items) > 1 { + return nil, errors.New("More than one Kubernetes Dashboard installation found") + } + + pod := list.Items[0] + return &pod, nil +} + +// Get the service for the kubernetes dashboard +func getKubeDashboardService(p interfaces.PortalProxy, cnsiGUID, userGUID string, labelSelector string) (ServiceInfo, error) { + log.Debug("getKubeDashboardService request") + + info := ServiceInfo{} + response, err := p.DoProxySingleRequest(cnsiGUID, userGUID, "GET", "/api/v1/services?labelSelector="+labelSelector, nil, nil) + if err != nil || response.StatusCode != 200 { + return info, errors.New("Could not fetch service list") + } + + ok, list, err := tryDecodeServiceList(response.Response) + if !ok { + return info, errors.New("Kube dashboard not installed - could not decode service list") + } + + if len(list.Items) == 0 { + return info, errors.New("Kube dashboard not installed") + } + + // Should just be one pod + if len(list.Items) != 1 { + return info, errors.New("Kube dashboard not installed - too many pods") + } + + svc := list.Items[0] + info.Namespace = svc.Namespace + info.ServiceName = svc.Name + info.Scheme = "http" + + if len(svc.Spec.Ports) > 0 { + port := svc.Spec.Ports[0].Port + if port == 443 { + info.Scheme = "https" + } + } + + // Check the labels on the service + info.StratosInstalled = hasAnnotation(svc.Labels, "stratos-role", "kubernetes-dashboard") + + return info, nil +} + +func getKubeDashboardServiceInfo(p interfaces.PortalProxy, endpointGUID, userGUID string) (ServiceInfo, error) { + svc, err := getKubeDashboardService(p, endpointGUID, userGUID, "app%3Dkubernetes-dashboard") + if err != nil { + svc, err = getKubeDashboardService(p, endpointGUID, userGUID, "k8s-app%3Dkubernetes-dashboard") + } + return svc, err +} + +// Get the service account for the kubernetes dashboard +func getKubeDashboardServiceAccount(p interfaces.PortalProxy, cnsiGUID, userGUID string, labelSelector string) (*v1.ServiceAccount, error) { + log.Debug("getKubeDashboardService request") + + response, err := p.DoProxySingleRequest(cnsiGUID, userGUID, "GET", "/api/v1/serviceaccounts?labelSelector="+labelSelector, nil, nil) + if err != nil || response.StatusCode != 200 { + return nil, errors.New("Could not fetch service account list") + } + + ok, list, err := tryDecodeServiceAccountList(response.Response) + if !ok { + return nil, errors.New("Could not find service account for Kubernetes dashboard") + } + + if len(list.Items) == 0 { + return nil, errors.New("Could not find service account for Kubernetes dashboard") + } + + // Should just be one pod + if len(list.Items) != 1 { + return nil, errors.New("Could not find service account for Kubernetes dashboard - too may accounts") + } + + svcAccount := list.Items[0] + return &svcAccount, nil +} + +// Get the service account for the kubernetes dashboard +func getKubeDashboardSecretToken(p interfaces.PortalProxy, cnsiGUID, userGUID string, sa *v1.ServiceAccount) (string, error) { + log.Debug("getKubeDashboardSecretToken request") + + namespace := sa.Namespace + + if len(sa.Secrets) != 1 { + return "", errors.New("Service Account has too many secrets - expecting only 1") + } + + // Need to get all secrets in the namespace and find the one with the correct annotation + apiURL := fmt.Sprintf("/api/v1/namespaces/%s/secrets", namespace) + response, err := p.DoProxySingleRequest(cnsiGUID, userGUID, "GET", apiURL, nil, nil) + if err != nil || response.StatusCode != 200 { + return "", errors.New("Could not find secrets for Kubernetes dashboard") + } + + ok, secrets, err := tryDecodeSecrets(response.Response) + if !ok { + return "", errors.New("Could not find secrets for Kubernetes dashboard") + } + + for _, secret := range secrets.Items { + if hasAnnotation(secret.Annotations, "kubernetes.io/service-account.name", sa.Name) { + if token, ok := secret.Data["token"]; ok { + return string(token), nil + } + return "", errors.New("Could not find token in the data for the Service Account Secret") + } + } + + return "", errors.New("Could not find token for the Service Account") +} + +// Check string map for the given (key, value) pair +// Used to check if an annotation with the specified value if present on a resource +func hasAnnotation(annotations map[string]string, key, value string) bool { + for k, v := range annotations { + if k == key && v == value { + return true + } + } + return false +} + +func tryDecodePodList(data []byte) (bool, v1.PodList, error) { + var pods v1.PodList + var err error + + err = json.Unmarshal(data, &pods) + if err != nil { + return false, pods, err + } + return true, pods, err +} + +func tryDecodeServiceList(data []byte) (bool, v1.ServiceList, error) { + var svcs v1.ServiceList + var err error + + err = json.Unmarshal(data, &svcs) + if err != nil { + return false, svcs, err + } + return true, svcs, err +} + +func tryDecodeServiceAccountList(data []byte) (bool, v1.ServiceAccountList, error) { + var svcAccounts v1.ServiceAccountList + var err error + + err = json.Unmarshal(data, &svcAccounts) + if err != nil { + return false, svcAccounts, err + } + return true, svcAccounts, err +} + +func tryDecodeSecrets(data []byte) (bool, v1.SecretList, error) { + var secrets v1.SecretList + var err error + + err = json.Unmarshal(data, &secrets) + if err != nil { + return false, secrets, err + } + return true, secrets, err +} + +// Send an error page that will get loaded into the IFRAME and the onload handler will detect +// it and show a Stratos error message +func sendErrorPage(c echo.Context, msg string) error { + html := fmt.Sprintf("%s", msg) + c.Response().Write([]byte(html)) + return nil +} diff --git a/src/jetstream/plugins/kubernetes/dashboard/configure.go b/src/jetstream/plugins/kubernetes/dashboard/configure.go new file mode 100644 index 0000000000..ed648b29d0 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/dashboard/configure.go @@ -0,0 +1,251 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" +) + +const dashboardInstallYAMLDownloadURL = "https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.3/aio/deploy/recommended.yaml" + +// Service Account definition - as per kube dashboard docs +const serviceAccountDefinition = `{ + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "name": "stratos-dashboard-user", + "namespace": "$NAMESPACE", + "labels": { + "stratos-role": "kubernetes-dashboard-user" + } + } +}` + +// Cluster Role Binding definition - as per kube dashboard docs +const clusterRoleBindingDefinition = `{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRoleBinding", + "metadata": { + "name": "stratos-dashboard-user", + "labels": { + "stratos-role": "kubernetes-dashboard-user" + } + }, + "roleRef": { + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "cluster-admin" + }, + "subjects": [ + { + "kind": "ServiceAccount", + "name": "stratos-dashboard-user", + "namespace": "$NAMESPACE" + } + ] +}` + +type apiVersionAndKind struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` +} + +// CreateServiceAccount will create a service account for accessing the Kubernetes Dashboard +func CreateServiceAccount(p interfaces.PortalProxy, endpointGUID, userGUID string) error { + log.Debug("CreateServiceAccount") + + svc, err := getKubeDashboardServiceInfo(p, endpointGUID, userGUID) + if err != nil { + return err + } + + namespace := svc.Namespace + target := fmt.Sprintf("api/v1/namespaces/%s/serviceaccounts", namespace) + headers := make(http.Header, 0) + headers.Set("Content-Type", "application/json") + + response, err := p.DoProxySingleRequest(endpointGUID, userGUID, "POST", target, headers, + replaceNamespace(serviceAccountDefinition, namespace)) + if err != nil { + return err + } + + if response.StatusCode != 201 { + return fmt.Errorf("Unable to create Service Account - unexpected response from API: %d", response.StatusCode) + } + + target = "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings" + response, err = p.DoProxySingleRequest(endpointGUID, userGUID, "POST", target, headers, + replaceNamespace(clusterRoleBindingDefinition, namespace)) + if err != nil { + return err + } + + if response.StatusCode != 201 { + return fmt.Errorf("Unable to create Cluster Role Binding - unexpected response from API: %d", response.StatusCode) + } + + return err +} + +func replaceNamespace(definition, namespace string) []byte { + updated := strings.ReplaceAll(definition, "$NAMESPACE", namespace) + return []byte(updated) +} + +// DeleteServiceAccount will delete the service account +func DeleteServiceAccount(p interfaces.PortalProxy, endpointGUID, userGUID string) error { + log.Debug("DeleteServiceAccount") + + svcAccount, err := getKubeDashboardServiceAccount(p, endpointGUID, userGUID, stratosServiceAccountSelector) + if err != nil { + return err + } + + msg := "" + target := fmt.Sprintf("api/v1/namespaces/%s/serviceaccounts/%s", svcAccount.Namespace, svcAccount.Name) + response, err := p.DoProxySingleRequest(endpointGUID, userGUID, "DELETE", target, nil, nil) + msg = addErrorMessage(msg, "Unable to delete Service Account", response, err) + + target = fmt.Sprintf("apis/rbac.authorization.k8s.io/v1/clusterrolebindings/%s", svcAccount.Name) + response, err = p.DoProxySingleRequest(endpointGUID, userGUID, "DELETE", target, nil, nil) + msg = addErrorMessage(msg, "Unable to delete Cluster Role Binding", response, err) + + if len(msg) > 0 { + return errors.New(msg) + } + + return nil +} + +func addErrorMessage(msg, prefix string, response *interfaces.CNSIRequest, err error) string { + errMsg := "" + if err != nil { + errMsg = fmt.Sprintf("%s - Error: %v", prefix, err.Error()) + } else if response.StatusCode != 200 { + errMsg = fmt.Sprintf("%s - unexpected response from API: %d", prefix, response.StatusCode) + } + + if len(errMsg) > 0 { + if len(msg) > 0 { + return fmt.Sprintf("%s. %s", msg, errMsg) + } + return errMsg + } + + return msg +} + +// InstallDashboard will install the dashboard into a Kubernetes cluster +func InstallDashboard(p interfaces.PortalProxy, endpointGUID, userGUID string) error { + // Download the Yaml for the dashboard + kubeDashboardImage := p.Env().String("STRATOS_KUBERNETES_DASHBOARD_IMAGE", "") + if len(kubeDashboardImage) == 0 { + kubeDashboardImage = dashboardInstallYAMLDownloadURL + } + + log.Debugf("InstallDashboard: %s", kubeDashboardImage) + + http := p.GetHttpClient(false) + resp, err := http.Get(kubeDashboardImage) + if err != nil { + return fmt.Errorf("Could not download YAML to install the dashboard: %+v", err) + } + if resp.StatusCode != 200 { + return fmt.Errorf("Could not download YAML to install the dashboard: %s", resp.Status) + } + + defer resp.Body.Close() + + // Read the entire body + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("Could not read YAML to install the dashboard: %s", err.Error()) + } + + r := bytes.NewReader(body) + dec := yaml.NewDecoder(r) + var t interface{} + for dec.Decode(&t) == nil { + jsonDoc, err := YAMLToJSONWithLabel(t) + if err != nil { + return fmt.Errorf("Could not convert YAML to JSON during dashboard installation %s", err.Error()) + } + + info := &apiVersionAndKind{} + if err := json.Unmarshal(jsonDoc, info); err != nil { + return fmt.Errorf("Could not parse YAML during dashboard installation %s", err.Error()) + } + + // Now create the resource + var api, resource string + if info.APIVersion != "v1" { + api = fmt.Sprintf("apis/%s", info.APIVersion) + } else { + api = fmt.Sprintf("api/%s", info.APIVersion) + } + + if isClusterAPI(info.Kind) { + if info.Kind == "Namespace" { + resource = fmt.Sprintf("api/v1/namespaces") + } else { + resource = fmt.Sprintf("%s/%ss", api, strings.ToLower(info.Kind)) + } + } else { + resource = fmt.Sprintf("%s/namespaces/kubernetes-dashboard/%ss", api, strings.ToLower(info.Kind)) + } + + response, err := p.DoProxySingleRequest(endpointGUID, userGUID, "POST", resource, nil, jsonDoc) + if err != nil { + return err + } + + if response.StatusCode != 201 { + // Don't fail if creation of a cluster-level resoures fails beacuse it already exists + if !(response.StatusCode == 409 && isClusterAPI(info.Kind)) { + return fmt.Errorf("Unable to delete %s - unexpected response from API: %d", info.Kind, response.StatusCode) + } + } + } + + return nil +} + +func isClusterAPI(api string) bool { + return strings.HasPrefix(api, "Cluster") || api == "Namespace" +} + +// DeleteDashboard will delete the dashboard from Kubernetes cluster +func DeleteDashboard(p interfaces.PortalProxy, endpointGUID, userGUID string) error { + log.Debug("DeleteDashboard") + + // Delete the service + svc, err := getKubeDashboardServiceInfo(p, endpointGUID, userGUID) + if err == nil { + svcTarget := fmt.Sprintf("api/v1/namespaces/%s/services/%s", svc.Namespace, svc.ServiceName) + // Don't wory if this fails, it will get deleted when the namespace is deleted + // We delete it here specifically so we know that it has gone since this is what we use + // to determine if the Dashboard is installed + p.DoProxySingleRequest(endpointGUID, userGUID, "DELETE", svcTarget, nil, nil) + } + + // Delete the service account + DeleteServiceAccount(p, endpointGUID, userGUID) + + // Delete the namespace 'kubernetes-dashboard' + target := "api/v1/namespaces/kubernetes-dashboard?propagationPolicy=Background" + _, err = p.DoProxySingleRequest(endpointGUID, userGUID, "DELETE", target, nil, nil) + if err != nil { + return err + } + + return nil +} diff --git a/src/jetstream/plugins/kubernetes/dashboard/login.go b/src/jetstream/plugins/kubernetes/dashboard/login.go new file mode 100644 index 0000000000..f27bac8c8d --- /dev/null +++ b/src/jetstream/plugins/kubernetes/dashboard/login.go @@ -0,0 +1,101 @@ +package dashboard + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +type loginResponse struct { + Token string `json:"token"` +} + +type loginOKResponse struct { + JWEToken string `json:"jweToken"` +} + +// KubeDashboardLogin will check and log into the Kubernetes Dashboard then redirect to the Dashboard UI +func KubeDashboardLogin(c echo.Context, p interfaces.PortalProxy) error { + log.Debug("kubeDashboardLogin request") + + endpointGUID := c.Param("guid") + userGUID := c.Get("user_id").(string) + + // Get the dashboard status + status, _ := KubeDashboardStatus(p, endpointGUID, userGUID, true) + if status.Service == nil { + return sendErrorPage(c, "Kubernetes Dashboard is not installed") + } + + // Check that we have a token + if len(status.Token) == 0 { + return sendErrorPage(c, "Kubernetes Dashboard is not confiured - could not find Service Account Token") + } + + // Now we need to log the user in + svc := status.Service + target := fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%s:/proxy/api/v1/csrftoken/login", svc.Namespace, svc.Scheme, svc.ServiceName) + response, err := p.DoProxySingleRequest(endpointGUID, userGUID, "GET", target, nil, nil) + if err != nil || response.StatusCode != 200 { + return sendErrorPage(c, "Unable to login to Kubernetes Dashboard") + } + + // Get the csrf token + token := &loginResponse{} + if err := json.Unmarshal(response.Response, token); err != nil { + return sendErrorPage(c, "Failed to login to Kubernetes Dashboard - invalid login response") + } + + // Login + login := loginResponse{ + Token: status.Token, + } + body, err := json.Marshal(login) + if err != nil { + return sendErrorPage(c, "Failed to set auth token for Kubernetes Dashboard") + } + + target = fmt.Sprintf("/api/v1/namespaces/%s/services/%s:%s:/proxy/api/v1/login", svc.Namespace, svc.Scheme, svc.ServiceName) + headers := make(http.Header) + headers.Set("X-CSRF-TOKEN", token.Token) + headers.Set("Content-Type", "application/json") + + response, err = p.DoProxySingleRequest(endpointGUID, userGUID, "POST", target, headers, body) + if err != nil || response.StatusCode != 200 { + return sendErrorPage(c, "Failed to login to Kubernetes Dashboard") + } + + // Get the csrf token + jweToken := &loginOKResponse{} + if err := json.Unmarshal(response.Response, jweToken); err != nil { + return sendErrorPage(c, "Failed to login to Kubernetes Dashboard - invalid login response") + } + + session, err := p.GetSession(c) + if err != nil { + return sendErrorPage(c, "Failed to login to Kubernetes Dashboard - could not get Stratos Session") + } + + // Need to cache the service information in the session to improve proxying performance + sessionValues := make(map[string]string) + sessionValues[kubeDashSessionEndpointID] = endpointGUID + sessionValues[kubeDashSessionNamespace] = svc.Namespace + sessionValues[kubeDashSessionScheme] = svc.Scheme + sessionValues[kubeDashSessionServiceName] = svc.ServiceName + sessionValues[kubeDashSessionToken] = url.QueryEscape(string(jweToken.JWEToken)) + + err = p.GetSessionDataStore().SetValues(session.ID, kubeDashSessionGroup, sessionValues, false) + if err != nil { + return sendErrorPage(c, "Failed to login to Kubernetes Dashboard - could not save Stratos Session data") + } + + // Redirect to the kube dashboard proxy + redirectURL := fmt.Sprintf("/pp/v1/apps/kubedash/ui/%s/", endpointGUID) + return c.Redirect(http.StatusTemporaryRedirect, redirectURL) +} diff --git a/src/jetstream/plugins/kubernetes/dashboard/proxy.go b/src/jetstream/plugins/kubernetes/dashboard/proxy.go new file mode 100644 index 0000000000..3c74603107 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/dashboard/proxy.go @@ -0,0 +1,137 @@ +package dashboard + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "strings" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + utilnet "k8s.io/apimachinery/pkg/util/net" + "k8s.io/client-go/rest" +) + +// KubeDashboardProxy proxies a request to the Kube Dash service using the K8S API +func KubeDashboardProxy(c echo.Context, p interfaces.PortalProxy, config *rest.Config) error { + log.Debugf("KubeDashboardProxy request for: %s", c.Request().RequestURI) + + cnsiGUID := c.Param("guid") + prefix := "/pp/v1/apps/kubedash/ui/" + cnsiGUID + "/" + path := c.Request().RequestURI[len(prefix):] + + cnsiRecord, err := p.GetCNSIRecord(cnsiGUID) + if err != nil { + return sendErrorPage(c, "Failed to access Kubernetes Dashboard - could not find endpoint") + } + + session, err := p.GetSession(c) + if err != nil { + return sendErrorPage(c, "Failed to access Kubernetes Dashboard - could not get Stratos Session") + } + + var kubeDashEndpointID = "" + var token = "" + svc := ServiceInfo{} + + sessionData, err := p.GetSessionDataStore().GetValues(session.ID, kubeDashSessionGroup) + if err != nil { + return sendErrorPage(c, "Failed to access to Kubernetes Dashboard - could not get Stratos Session data") + } + + // We have to have cached the data we need via the /login endpoint + var errors = 0 + var ok bool + + if kubeDashEndpointID, ok = sessionData[kubeDashSessionEndpointID]; !ok { + errors = errors + 1 + } + + if svc.Namespace, ok = sessionData[kubeDashSessionNamespace]; !ok { + errors = errors + 1 + } + + if svc.Scheme, ok = sessionData[kubeDashSessionScheme]; !ok { + errors = errors + 1 + } + + if svc.ServiceName, ok = sessionData[kubeDashSessionServiceName]; !ok { + errors = errors + 1 + } + + if token, ok = sessionData[kubeDashSessionToken]; !ok { + errors = errors + 1 + } + + // The cached data must be all there and must be for the correct endpoint + if errors > 0 || kubeDashEndpointID != cnsiGUID { + return sendErrorPage(c, "Failed to access to Kubernetes Dashboard - session data invalid") + } + + apiEndpoint := cnsiRecord.APIEndpoint + log.Debug(apiEndpoint) + + target := fmt.Sprintf("%s/api/v1/namespaces/%s/services/%s:%s:/proxy/%s", apiEndpoint, svc.Namespace, svc.Scheme, svc.ServiceName, path) + log.Debug(target) + targetURL, _ := url.Parse(target) + + req := c.Request() + w := c.Response().Writer + + loc := targetURL + loc.RawQuery = req.URL.RawQuery + + // If original request URL ended in '/', append a '/' at the end of the + // of the proxy URL + if !strings.HasSuffix(loc.Path, "/") && strings.HasSuffix(req.URL.Path, "/") { + loc.Path += "/" + } + + // From pkg/genericapiserver/endpoints/handlers/proxy.go#ServeHTTP: + // Redirect requests with an empty path to a location that ends with a '/' + // This is essentially a hack for http://issue.k8s.io/4958. + // Note: Keep this code after tryUpgrade to not break that flow. + if len(loc.Path) == 0 { + log.Debug("Redirecting") + var queryPart string + if len(req.URL.RawQuery) > 0 { + queryPart = "?" + req.URL.RawQuery + } + w.Header().Set("Location", req.URL.Path+"/"+queryPart) + w.WriteHeader(http.StatusMovedPermanently) + return nil + } + + transport, err := rest.TransportFor(config) + if err != nil { + return err + } + + // WithContext creates a shallow clone of the request with the new context. + newReq := req.WithContext(req.Context()) + newReq.Header = utilnet.CloneHeader(req.Header) + newReq.URL = loc + + proxy := httputil.NewSingleHostReverseProxy(&url.URL{Scheme: loc.Scheme, Host: loc.Host}) + proxy.Transport = transport + proxy.FlushInterval = defaultFlushInterval + proxy.ModifyResponse = func(response *http.Response) error { + log.Debugf("Got proxy response for: %s (Status: %s)", loc.String(), response.StatusCode) + // For the root page, set the session cookie so that the user is automatically logged in from + // the login we did manually + if len(path) == 0 { + // TODO: Check the value for the cookie header - kube dash may well update with the value + // that it wants to use + cookie := fmt.Sprintf("jweToken=%s; Max-Age=36000", token) + response.Header.Set("Set-Cookie", cookie) + } + return nil + } + + // Proxy the request + proxy.ServeHTTP(w, newReq) + + return nil +} diff --git a/src/jetstream/plugins/kubernetes/dashboard/status.go b/src/jetstream/plugins/kubernetes/dashboard/status.go new file mode 100644 index 0000000000..d8f844bae5 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/dashboard/status.go @@ -0,0 +1,53 @@ +package dashboard + +import ( + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +const stratosServiceAccountSelector = "stratos-role%3Dkubernetes-dashboard-user" + +// KubeDashboardStatus will determine if the specified Kube endpoint has the dashboard installed and ready +func KubeDashboardStatus(p interfaces.PortalProxy, endpointGUID, userGUID string, includeToken bool) (*StatusResponse, error) { + + status := &StatusResponse{ + Endpoint: endpointGUID, + Installed: false, + Running: false, + HasToken: false, + StratosInstalled: false, + } + + pod, err := getKubeDashboardPod(p, endpointGUID, userGUID, "app%3Dkubernetes-dashboard") + if err != nil { + pod, err = getKubeDashboardPod(p, endpointGUID, userGUID, "k8s-app%3Dkubernetes-dashboard") + } + + status.Pod = pod + if err == nil { + status.Installed = true + status.Running = (pod.Status.Phase == "Running") + + // Get the image name + if len(pod.Spec.Containers) == 1 { + status.Version = pod.Spec.Containers[0].Image + } + } + + svc, err := getKubeDashboardServiceInfo(p, endpointGUID, userGUID) + if err == nil { + status.Service = &svc + status.StratosInstalled = svc.StratosInstalled + } + + svcAccount, err := getKubeDashboardServiceAccount(p, endpointGUID, userGUID, stratosServiceAccountSelector) + status.ServiceAccont = svcAccount + if err == nil && includeToken { + token, err := getKubeDashboardSecretToken(p, endpointGUID, userGUID, svcAccount) + if err == nil { + status.HasToken = true + status.Token = token + } + } + + return status, nil +} diff --git a/src/jetstream/plugins/kubernetes/dashboard/yaml.go b/src/jetstream/plugins/kubernetes/dashboard/yaml.go new file mode 100644 index 0000000000..92e0c9eabc --- /dev/null +++ b/src/jetstream/plugins/kubernetes/dashboard/yaml.go @@ -0,0 +1,63 @@ +package dashboard + +import ( + "encoding/json" + "strings" +) + +func YAMLToJSONWithLabel(body interface{}) ([]byte, error) { + body = convert(body) + addLabel(body) + b, err := json.Marshal(body) + return b, err +} + +func convert(i interface{}) interface{} { + switch x := i.(type) { + case map[interface{}]interface{}: + m2 := map[string]interface{}{} + for k, v := range x { + m2[k.(string)] = convert(v) + } + return m2 + case []interface{}: + for i, v := range x { + x[i] = convert(v) + } + } + return i +} + +func addLabel(resource interface{}) { + if labels, ok := getPath("metadata.labels", resource); ok { + labels["stratos-role"] = "kubernetes-dashboard" + } else { + // Resource may not have labels + if metadata, ok := getPath("metadata", resource); ok { + // Got metadata + labels := make(map[string]interface{}) + labels["stratos-role"] = "kubernetes-dashboard" + metadata["labels"] = labels + } + } +} + +func getPath(path string, resource interface{}) (map[string]interface{}, bool) { + paths := strings.Split(path, ".") + res := resource + for _, key := range paths { + if m, ok := res.(map[string]interface{}); ok { + if value, ok := m[key]; ok { + res = value + } else { + return make(map[string]interface{}), false + } + } + } + + if m, ok := res.(map[string]interface{}); ok { + return m, true + } + + return make(map[string]interface{}), false +} diff --git a/src/jetstream/plugins/kubernetes/endpoint_config.go b/src/jetstream/plugins/kubernetes/endpoint_config.go new file mode 100644 index 0000000000..8e7b983235 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/endpoint_config.go @@ -0,0 +1,117 @@ +package kubernetes + +import ( + "errors" + "fmt" + + log "github.com/sirupsen/logrus" + + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +// GetConfigForEndpoint gets a config for the Kubernetes go-client for the specified endpoint +func (c *KubernetesSpecification) GetConfigForEndpoint(masterURL string, token interfaces.TokenRecord) (*restclient.Config, error) { + return clientcmd.BuildConfigFromKubeconfigGetter(masterURL, func() (*clientcmdapi.Config, error) { + return c.getKubeConfigForEndpoint(masterURL, token, "") + }) +} + +// GetConfigForEndpointUser gets a kube config for the endpoint ID and user ID +func (c *KubernetesSpecification) GetConfigForEndpointUser(endpointID, userID string) (*restclient.Config, error) { + + var p = c.portalProxy + cnsiRecord, err := p.GetCNSIRecord(endpointID) + if err != nil { + return nil, errors.New("Could not get endpoint information") + } + + // Get token for this users + tokenRec, ok := p.GetCNSITokenRecord(endpointID, userID) + if !ok { + return nil, errors.New("Could not get token") + } + + return c.GetConfigForEndpoint(cnsiRecord.APIEndpoint.String(), tokenRec) +} + +func (c *KubernetesSpecification) GetKubeConfigForEndpointUser(endpointID, userID string) (string, error) { + + var p = c.portalProxy + cnsiRecord, err := p.GetCNSIRecord(endpointID) + if err != nil { + return "", errors.New("Could not get endpoint information") + } + + // Get token for this users + tokenRec, ok := p.GetCNSITokenRecord(endpointID, userID) + if !ok { + return "", errors.New("Could not get token") + } + + return c.GetKubeConfigForEndpoint(cnsiRecord.APIEndpoint.String(), tokenRec, "") +} + +func (c *KubernetesSpecification) getKubeConfigForEndpoint(masterURL string, token interfaces.TokenRecord, namespace string) (*clientcmdapi.Config, error) { + + name := "cluster-0" + + // Create a config + + // Initialize a new config + context := clientcmdapi.NewContext() + context.Cluster = name + context.AuthInfo = name + if len(namespace) > 0 { + context.Namespace = namespace + } + + // Configure the cluster + cluster := clientcmdapi.NewCluster() + cluster.Server = masterURL + + // TODO + cluster.InsecureSkipTLSVerify = true + + // Configure auth information + authInfo := clientcmdapi.NewAuthInfo() + err := c.addAuthInfoForEndpoint(authInfo, token) + + config := clientcmdapi.NewConfig() + config.Clusters[name] = cluster + config.Contexts[name] = context + config.AuthInfos[name] = authInfo + config.CurrentContext = context.Cluster + + return config, err +} + +// GetKubeConfigForEndpoint gets a Kube Config file contents for the specified endpoint +func (c *KubernetesSpecification) GetKubeConfigForEndpoint(masterURL string, token interfaces.TokenRecord, namespace string) (string, error) { + + config, err := c.getKubeConfigForEndpoint(masterURL, token, namespace) + if err != nil { + return "", err + } + + kconfig, err := clientcmd.Write(*config) + if err != nil { + return "", err + } + + return string(kconfig), nil +} + +func (c *KubernetesSpecification) addAuthInfoForEndpoint(info *clientcmdapi.AuthInfo, tokenRec interfaces.TokenRecord) error { + + log.Debug("addAuthInfoForEndpoint") + var authProvider = c.GetAuthProvider(tokenRec.AuthType) + if authProvider == nil { + return fmt.Errorf("Unsupported auth type: %s", tokenRec.AuthType) + } + + return authProvider.AddAuthInfo(info, tokenRec) +} diff --git a/src/jetstream/plugins/kubernetes/get_release.go b/src/jetstream/plugins/kubernetes/get_release.go new file mode 100644 index 0000000000..62795d8a94 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/get_release.go @@ -0,0 +1,264 @@ +package kubernetes + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "time" + + "github.com/gorilla/websocket" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + + "helm.sh/helm/v3/pkg/action" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/helm" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +const ( + PauseTrue int = iota + 20000 + PauseFalse +) + +// ResourceMessage ... Incoming content of socket +type ResourceMessage struct { + MessageType int `json:"type"` +} + +// ResourceResponse ... Outgoing content of socket +type ResourceResponse struct { + Kind string `json:"kind"` + Data json.RawMessage `json:"data"` +} + +type kubeReleasesData struct { + Endpoint string `json:"endpoint"` + Name string `json:"releaseName"` + Namespace string `json:"releaseNamespace"` + Chart struct { + Name string `json:"chartName"` + Repository string `json:"repo"` + Version string `json:"version"` + } `json:"chart"` +} + +// GetRelease gets the release information for a specific Helm release +func (c *KubernetesSpecification) GetRelease(ec echo.Context) error { + + // Need to get a config object for the target endpoint + endpointGUID := ec.Param("endpoint") + release := ec.Param("name") + namespace := ec.Param("namespace") + userID := ec.Get("user_id").(string) + + log.Debugf("Helm: Get Release: %s %s %s", endpointGUID, namespace, release) + + config, hc, err := c.GetHelmConfiguration(endpointGUID, userID, namespace) + if err != nil { + log.Errorf("Helm: GetRelease could not get a Helm Configuration: %s", err) + return err + } + + defer hc.Cleanup() + + status := action.NewStatus(config) + res, err := status.Run(release) + if err != nil { + log.Error(err) + return err + } + + return ec.JSON(200, res) +} + +// GetReleaseStatus will get release status for the given release +// This is a web socket request and will return info over the websocket +// polling until disconnected +func (c *KubernetesSpecification) GetReleaseStatus(ec echo.Context) error { + + // Need to get a config object for the target endpoint + endpointGUID := ec.Param("endpoint") + release := ec.Param("name") + namespace := ec.Param("namespace") + userID := ec.Get("user_id").(string) + + log.Debugf("Helm: Get Release Status: %s %s %s", endpointGUID, namespace, release) + + config, hc, err := c.GetHelmConfiguration(endpointGUID, userID, namespace) + if err != nil { + log.Errorf("Helm: GetRelease could not get a Helm Configuration: %s", err) + return err + } + + defer hc.Cleanup() + + status := action.NewStatus(config) + res, err := status.Run(release) + if err != nil { + log.Error(err) + return err + } + + // Upgrade to a web socket + ws, pingTicker, err := interfaces.UpgradeToWebSocket(ec) + if err != nil { + return err + } + defer ws.Close() + defer pingTicker.Stop() + + // ws is the websocket ready for use + + // Write the release info first - we will then go fetch the status of everything in the release and send + // this back incrementally + + // Parse the manifest + rel := helm.NewHelmRelease(res, endpointGUID, userID, c.portalProxy) + + graph := helm.NewHelmReleaseGraph(rel) + + id := fmt.Sprintf("%s-%s", endpointGUID, rel.Namespace) + + // Send over the namespace details of the release + sendResource(ws, "ReleasePrefix", id) + + //graph.ParseManifest(rel) + + // Send the manifest for the release + sendResource(ws, "Resources", rel.GetResources()) + + // // Send the manifest for the release + // sendResource(ws, "Test", rel.HelmManifest) + + // Send the graph as we have it now + sendResource(ws, "Graph", graph) + + // Loop over this until the web socket is closed + + // Get the pods first and send those + rel.UpdatePods(c.portalProxy) + sendResource(ws, "Pods", rel.GetPods()) + + //graph.Generate(pods) + //graph.ParseManifest(rel) + sendResource(ws, "Graph", graph) + + // Send the manifest for the release again (ReplicaSets will now be added) + sendResource(ws, "Manifest", rel.GetResources()) + + // Now get all of the resources in the manifest + rel.UpdateResources(c.portalProxy) + sendResource(ws, "Resources", rel.GetResources()) + + graph.ParseManifest(rel) + sendResource(ws, "Graph", graph) + + sendResource(ws, "ManifestErrors", rel.ManifestErrors) + + stopchan := make(chan bool) + pausechan := make(chan bool) + + go readLoop(ws, stopchan, pausechan) + + var sleep = 1 * time.Second + var paused = false + + // Now we have everything, so loop, polling to get status + for { + + select { + case pause := <-pausechan: + paused = pause + break + case <-stopchan: + ws.Close() + return nil + case <-time.After(sleep): + break + } + + if paused { + log.Debug("Updating release resources paused ....") + continue + } + + log.Debug("Updating release resources ....") + + // Pods + rel.UpdatePods(c.portalProxy) + sendResource(ws, "Pods", rel.GetPods()) + + graph.ParseManifest(rel) + sendResource(ws, "Graph", graph) + + // Now get all of the resources in the manifest + rel.UpdateResources(c.portalProxy) + sendResource(ws, "Resources", rel.GetResources()) + + graph.ParseManifest(rel) + sendResource(ws, "Graph", graph) + + sleep = 10 * time.Second + } +} + +func readLoop(c *websocket.Conn, stopchan chan<- bool, pausechan chan<- bool) { + for { + + messageType, r, err := c.NextReader() + if err != nil { + c.Close() + close(stopchan) + break + } + + switch messageType { + case websocket.TextMessage: + data, err := ioutil.ReadAll(r) + if err != nil { + log.Warnf("Failed to read content of helm resource websocket message: %+v", err) + break + } + + message := ResourceMessage{} + err = json.Unmarshal(data, &message) + if err != nil { + log.Warnf("Failed to parse content of helm resource websocket message: %+v", err) + break + } + + switch message.MessageType { + case PauseTrue: + pausechan <- true + break + case PauseFalse: + pausechan <- false + break + } + default: + c.Close() + close(stopchan) + break + } + } +} + +func sendResource(ws *websocket.Conn, kind string, data interface{}) error { + var err error + var txt []byte + if txt, err = json.Marshal(data); err == nil { + resp := ResourceResponse{ + Kind: kind, + Data: json.RawMessage(txt), + } + + if txt, err = json.Marshal(resp); err == nil { + if ws.WriteMessage(websocket.TextMessage, txt); err == nil { + return nil + } + } + } + + return err +} diff --git a/src/jetstream/plugins/kubernetes/go.mod b/src/jetstream/plugins/kubernetes/go.mod new file mode 100644 index 0000000000..e5d44dd87d --- /dev/null +++ b/src/jetstream/plugins/kubernetes/go.mod @@ -0,0 +1,41 @@ +module github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes + +go 1.12 + +require ( + github.com/Masterminds/semver v1.4.2 // indirect + github.com/Masterminds/sprig v2.18.0+incompatible // indirect + github.com/SermoDigital/jose v0.9.1 + github.com/aws/aws-sdk-go v1.17.5 + github.com/chai2010/gettext-go v0.0.0-20170215093142-bf70f2a70fb1 // indirect + github.com/docker/docker v1.13.1 // indirect + github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484 // indirect + github.com/ghodss/yaml v1.0.0 + github.com/gorilla/websocket v1.4.0 + github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect + github.com/heptio/authenticator v0.3.0 // indirect + github.com/kubernetes-sigs/aws-iam-authenticator v0.3.0 + github.com/labstack/echo v3.3.10+incompatible + github.com/russross/blackfriday v2.0.0+incompatible // indirect + github.com/satori/go.uuid v1.2.0 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/sirupsen/logrus v1.4.2 + github.com/smartystreets/goconvey v1.6.4 + github.com/technosophos/moniker v0.0.0-20180509230615-a5dbd03a2245 // indirect + gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 // indirect + gopkg.in/yaml.v2 v2.2.4 + helm.sh/helm/v3 v3.0.0 + k8s.io/api v0.0.0-20191016110408-35e52d86657a + k8s.io/apimachinery v0.0.0-20191004115801-a2eda9f80ab8 + k8s.io/client-go v0.0.0-20191016111102-bec269661e48 +) + +replace ( + github.com/SermoDigital/jose => github.com/SermoDigital/jose v0.9.2-0.20180104203859-803625baeddc + github.com/cloudfoundry-incubator/stratos/src/jetstream => ../.. + github.com/docker/docker => github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309 + github.com/kubernetes-sigs/aws-iam-authenticator => github.com/kubernetes-sigs/aws-iam-authenticator v0.3.1-0.20190111160901-390d9087a4bc + github.com/russross/blackfriday v2.0.0+incompatible => github.com/russross/blackfriday v1.5.2 + github.com/sergi/go-diff => github.com/sergi/go-diff v1.0.0 + github.com/spf13/cobra => github.com/spf13/cobra v0.0.3 +) diff --git a/src/jetstream/plugins/kubernetes/go.sum b/src/jetstream/plugins/kubernetes/go.sum new file mode 100644 index 0000000000..3159ac83df --- /dev/null +++ b/src/jetstream/plugins/kubernetes/go.sum @@ -0,0 +1,857 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +contrib.go.opencensus.io/exporter/ocagent v0.2.0/go.mod h1:0fnkYHF+ORKj7HWzOExKkUHeFX79gXSKUQbpnAM+wzo= +git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= +github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/MakeNowJust/heredoc v0.0.0-20170808103936-bb23615498cd/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= +github.com/MakeNowJust/heredoc v0.0.0-20171113091838-e9091a26100e h1:eb0Pzkt15Bm7f2FFYv7sjY7NPFi3cPkS3tv1CcrFBWA= +github.com/MakeNowJust/heredoc v0.0.0-20171113091838-e9091a26100e/go.mod h1:64YHyfSL2R96J44Nlwm39UHepQbyR5q10x7iYa1ks2E= +github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg= +github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc= +github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.0.1 h1:2kKm5lb7dKVrt5TYUiAavE6oFc1cFT0057UVGT+JqLk= +github.com/Masterminds/semver/v3 v3.0.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/sprig v2.18.0+incompatible h1:QoGhlbC6pter1jxKnjMFxT8EqsLuDE6FEcNbWEpw+lI= +github.com/Masterminds/sprig v2.18.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/Masterminds/sprig/v3 v3.0.0 h1:KSQz7Nb08/3VU9E4ns29dDxcczhOD1q7O1UfM4G3t3g= +github.com/Masterminds/sprig/v3 v3.0.0/go.mod h1:NEUY/Qq8Gdm2xgYA+NwJM6wmfdRV9xkh8h/Rld20R0U= +github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA= +github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/go-winio v0.4.12 h1:xAfWHN1IrQ0NJ9TBC0KBZoqLjzDTr1ML+4MywiUOryc= +github.com/Microsoft/go-winio v0.4.12/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= +github.com/Microsoft/hcsshim v0.8.6 h1:ZfF0+zZeYdzMIVMZHKtDKJvLHj76XCuVae/jNkjj0IA= +github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.0 h1:rmGxhojJlM0tuKtfdvliR84CFHljx9ag64t2xmVkjK4= +github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/SermoDigital/jose v0.9.1 h1:atYaHPD3lPICcbK1owly3aPm0iaJGSGPi0WD4vLznv8= +github.com/SermoDigital/jose v0.9.1/go.mod h1:ARgCUhI1MHQH+ONky/PAtmVHQrP5JlGY0F3poXOp/fA= +github.com/SermoDigital/jose v0.9.2-0.20180104203859-803625baeddc h1:MhBvG7RLaLqlyjxMR6of35vt6MVQ+eXMcgn9X/sy0FE= +github.com/SermoDigital/jose v0.9.2-0.20180104203859-803625baeddc/go.mod h1:ARgCUhI1MHQH+ONky/PAtmVHQrP5JlGY0F3poXOp/fA= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= +github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/aws/aws-sdk-go v1.17.5 h1:WW9Hm3KYo48iZHpmBc+b7sgyS0h32zgCvya28SLW4BU= +github.com/aws/aws-sdk-go v1.17.5/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bitly/go-simplejson v0.5.0 h1:6IH+V8/tVMab511d5bn4M7EwGXZf9Hj6i2xSwkNEM+Y= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bshuster-repo/logrus-logstash-hook v0.4.1 h1:pgAtgj+A31JBVtEHu2uHuEx0n+2ukqUJnS2vVe5pQNA= +github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/bugsnag/bugsnag-go v1.5.0 h1:tP8hiPv1pGGW3LA6LKy5lW6WG+y9J2xWUdPd3WC452k= +github.com/bugsnag/bugsnag-go v1.5.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0 h1:OzrKrRvXis8qEvOkfcxNcYbOd2O7xXS2nnKMEMABFQA= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/census-instrumentation/opencensus-proto v0.0.2-0.20180913191712-f303ae3f8d6a/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/census-instrumentation/opencensus-proto v0.1.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.0 h1:yTUvW7Vhb89inJ+8irsUqiWjh8iT6sQPZiQzI6ReGkA= +github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= +github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw= +github.com/chai2010/gettext-go v0.0.0-20170215093142-bf70f2a70fb1 h1:HD4PLRzjuCVW79mQ0/pdsalOLHJ+FaEoqJLxfltpb2U= +github.com/chai2010/gettext-go v0.0.0-20170215093142-bf70f2a70fb1/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudfoundry-community/go-cfenv v1.17.0 h1:qfxEfn8qKkaHY3ZEk/Y2noY79HBASvNgmtHK9x4+6GY= +github.com/cloudfoundry-community/go-cfenv v1.17.0/go.mod h1:2UgWvQTRXUuIZ/x3KnW6fk6CgPBhcV4UQb/UGIrUyyI= +github.com/cloudfoundry-incubator/stratos v2.0.0-beta-001+incompatible h1:UUxNbLjhv2cfymub5yNN1tjjqYkteHBBagb4jcbXEIQ= +github.com/cloudfoundry-incubator/stratos/src/jetstream v0.0.0-20190516104506-727fa3589a90 h1:IeIyBIgh2xEQm1CxHN2yFSBU7Ap+HQZleOs3TlAymI0= +github.com/containerd/containerd v1.3.0-beta.2.0.20190823190603-4a2f61c4f2b4 h1:aMyA5J7j6D07U7pf8BFEY67BKoDcz0zWleAbQj3zVng= +github.com/containerd/containerd v1.3.0-beta.2.0.20190823190603-4a2f61c4f2b4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/containerd v1.3.0 h1:xjvXQWABwS2uiv3TWgQt5Uth60Gu86LTGZXMJkjc7rY= +github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= +github.com/containerd/continuity v0.0.0-20181203112020-004b46473808 h1:4BX8f882bXEDKfWIf0wa8HRvpnBoPszJJXL+TVbBw4M= +github.com/containerd/continuity v0.0.0-20181203112020-004b46473808/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= +github.com/coreos/bbolt v1.3.1-coreos.6/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.15+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrPujOBSCj8xRg= +github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1:dv4zxwHi5C/8AeI+4gX4dCWOIvNi7I6JCSX0HvlKPgE= +github.com/deislabs/oras v0.7.0 h1:RnDoFd3tQYODMiUqxgQ8JxlrlWL0/VMKIKRD01MmNYk= +github.com/deislabs/oras v0.7.0/go.mod h1:sqMKPG3tMyIX9xwXUBRLhZ24o+uT4y6jgBD2RzUTKDM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA= +github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/docker/cli v0.0.0-20190506213505-d88565df0c2d h1:qdD+BtyCE1XXpDyhvn0yZVcZOLILdj9Cw4pKu0kQbPQ= +github.com/docker/cli v0.0.0-20190506213505-d88565df0c2d/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= +github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.6.1 h1:Dq4iIfcM7cNtddhLVWe9h4QDjsi4OER3Z8voPu/I52g= +github.com/docker/docker-credential-helpers v0.6.1/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 h1:X0fj836zx99zFu83v/M79DuBn84IL/Syx1SY6Y5ZEMA= +github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= +github.com/docker/go-units v0.3.3 h1:Xk8S3Xj5sLGlG5g67hJmYMmUgXv5N4PhkjJHHqrwnTk= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= +github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c h1:ZfSZ3P3BedhKGUhzj7BQlPSU4OvT6tfOKe3DVHzOA7s= +github.com/docker/spdystream v0.0.0-20181023171402-6480d4af844c/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484 h1:pEtiCjIXx3RvGjlUJuCNxNOw0MNblyR9Wi+vJGBFh+8= +github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= +github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/emicklei/go-restful v2.11.1+incompatible h1:CjKsv3uWcCMvySPQYKxO8XX3f9zD4FeZRsW4G0B4ffE= +github.com/emicklei/go-restful v2.11.1+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/evanphx/json-patch v4.1.0+incompatible h1:K1MDoo4AZ4wU0GIU/fPmtZg7VpzLjCxu+UwBD1FvwOc= +github.com/evanphx/json-patch v4.1.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.2.0+incompatible h1:fUDGZCv/7iAN7u0puUVhvKCcsR6vRfwrJatElLBEf0I= +github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v4.5.0+incompatible h1:ouOWdg56aJriqS0huScTkVXPC5IcNrDCXZ6OoTAWu7M= +github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM= +github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= +github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/garyburd/redigo v1.6.0 h1:0VruCpn7yAIIu7pWVClQC8wxCJEcG3nyzpMSHKi1PQc= +github.com/garyburd/redigo v1.6.0/go.mod h1:NR3MbYisc3/PwhQ00EMzDiPmrwpPxAn5GI05/YaO1SY= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v0.0.0-20180820084758-c7ce16629ff4/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 h1:DujepqpGd1hyOd7aW59XpK7Qymp8iy83xq74fLr21is= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/go-inf/inf v0.9.1/go.mod h1:ZWwB6rTV+0pO94RdIMKue59tExzQp6/pj/BMuPQkXaA= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= +github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.18.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/analysis v0.19.2/go.mod h1:3P1osvZa9jKjb8ed2TPng3f0i/UY9snX6gxi44djMjk= +github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.18.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonpointer v0.17.0 h1:nH6xp8XdXHx8dqveo0ZuJBluCO2qGrPbDNZ0dwoRHP0= +github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonpointer v0.19.2 h1:A9+F4Dc/MCNB5jibxf6rRvOvR/iFgQdyNx9eIhnGqq0= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/jsonreference v0.17.0 h1:yJW3HCkTHg7NOA+gZ83IPHzUSnUzGXhGmsdiCcMexbA= +github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.18.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/jsonreference v0.19.2 h1:o20suLFB4Ri0tuzpWtyHlh7E7HnkqTNLq6aR6WVNS1w= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3 h1:5cxNfTy0UVC3X8JL5ymxzyoUZmo8iZb+jeTWn7tUa8o= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.18.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/loads v0.19.2/go.mod h1:QAskZPMX5V0C2gvfkGZzJlINuP7Hx/4+ix5jWFxsNPs= +github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= +github.com/go-openapi/runtime v0.19.0/go.mod h1:OwNfisksmmaZse4+gpV3Ne9AyMOlP1lt4sK4FXt0O64= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.18.0 h1:aIjeyG5mo5/FrvDkpKKEGZPmF9MPHahS72mzfVqeQXQ= +github.com/go-openapi/spec v0.18.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/spec v0.19.2 h1:SStNd1jRcYtfKCN7R0laGNs80WYYvn5CbBjM2sOmCrE= +github.com/go-openapi/spec v0.19.2/go.mod h1:sCxk3jxKgioEJikev4fgkNmwS+3kuYdJtcsZsD5zxMY= +github.com/go-openapi/spec v0.19.4 h1:ixzUSnHTd6hCemgtAJgluaTSGYpLNpJY4mA2DIkdOAo= +github.com/go-openapi/spec v0.19.4/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.18.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/strfmt v0.19.0/go.mod h1:+uW+93UVvGGq2qGaZxdDeJqSAqBqBdl+ZPMF/cC8nDY= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-openapi/swag v0.17.0 h1:iqrgMg7Q7SvtbWLlltPrkMs0UBJI6oTSs79JFRUi880= +github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.18.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/swag v0.19.2 h1:jvO6bCMBEilGwMfHhrd61zIID4oIFdwb76V17SM88dE= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1 h1:/s5zKNz0uPFCZ5hddgPdo2TK2TVrUNMn0OOX8/aZMTE= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20181024230925-c65c006176ff/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450/go.mod h1:Bk6SMAONeMXrxql8uvOKuAZSu8aM5RUGv+1C6IJaEho= +github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995/go.mod h1:lJgMEyOkYFkPcDKwRXegd+iM6E7matEszMG5HhwytU8= +github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c h1:964Od4U6p2jUkFxvCydnIczKteheJEzHRToSGK3Bnlw= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck= +github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.0 h1:Jf4mxPC/ziBnoPIdpQdPJ9OeiomAUHLvxmPRSPH9m4s= +github.com/google/uuid v1.1.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g= +github.com/googleapis/gnostic v0.2.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/googleapis/gnostic v0.3.1 h1:WeAefnSUHlBb0iJKwxFDZdbfGwkd7xRNuV+IpXMJhYk= +github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU= +github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/handlers v1.4.0 h1:XulKRWSQK5uChr4pEgSE4Tc/OcmnU9GJuSwdog/tZsA= +github.com/gorilla/handlers v1.4.0/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= +github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= +github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= +github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gosuri/uitable v0.0.1 h1:M9sMNgSZPyAu1FJZJLpJ16ofL8q5ko2EDUkICsynvlY= +github.com/gosuri/uitable v0.0.1/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/govau/cf-common v0.0.7 h1:uhp1P6XM6GGzu1+A4C7LELLX/9mCmH6W5DpJZC0kWmo= +github.com/govau/cf-common v0.0.7/go.mod h1:5xL/OfE7wxeyHlXb7iei0rAbdQ/5v6dF18BZknPv7NQ= +github.com/gregjones/httpcache v0.0.0-20170728041850-787624de3eb7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/gregjones/httpcache v0.0.0-20181110185634-c63ab54fda8f/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc h1:f8eY6cV/x1x+HLjOp4r72s/31/V2aTUtg5oKRRPf8/Q= +github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v0.0.0-20190222133341-cfaf5686ec79/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.3.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.3 h1:YPkqC67at8FYaadspW/6uE0COsBxS2656RLEr8Bppgk= +github.com/hashicorp/golang-lru v0.5.3/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/helm/monocular v1.4.0 h1:g0sOpuMe+9u+aPfd9ZO8mWV+c8W0dfGyBG9Wl23nwec= +github.com/helm/monocular v1.4.0/go.mod h1:PpkCN0v4zVVigsIHnsQdJytKFmaUkwfhxB7z33a9/gE= +github.com/helm/monocular v1.5.0 h1:y8anOb2XLsCluYNOPx01G6EPqyBZ9fxDi+9BKbesULE= +github.com/helm/monocular v1.6.0 h1:zE8OggduPEtnF2x1XgrQnMwnKFUGCvk/RNs0NX8hgjg= +github.com/helm/monocular v1.7.0 h1:SW2xr6KoVJtZT6ZAtON8jJVLqcRE3C8pBb9kyo8Icxw= +github.com/heptio/authenticator v0.3.0 h1:Xh6XWkLZ+CksGuky+vsr77mHnI9C4L3nwj+xuZu28J0= +github.com/heptio/authenticator v0.3.0/go.mod h1:Q86X8hc61JXhE5XxYLKmrSRWby/Oe8IIYZIBgmGVkTA= +github.com/heptiolabs/healthcheck v0.0.0-20180807145615-6ff867650f40 h1:GT4RsKmHh1uZyhmTkWJTDALRjSHYQp6FRKrotf0zhAs= +github.com/heptiolabs/healthcheck v0.0.0-20180807145615-6ff867650f40/go.mod h1:NtmN9h8vrTveVQRLHcX2HQ5wIPBDCsZ351TGbZWgg38= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.7 h1:Y+UAYTZ7gDEuOfhxKWy+dvb5dRQ6rJjFSdX2HZY1/gI= +github.com/imdario/mergo v0.3.7/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ= +github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.5 h1:gL2yXlmiIo4+t+y32d4WGwOjKGYcGOuyrg46vadswDE= +github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1 h1:PJPDf8OUfOK1bb/NeTKd4f1QXZItOX389VN3B6qC8ro= +github.com/kardianos/osext v0.0.0-20170510131534-ae77be60afb1/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kubeapps/common v0.0.0-20181107174310-61d8eb6f11b4 h1:wdTBUArlqtBYGN2Dd4+zsaFxFH0m4iGCHToW10jPX0k= +github.com/kubeapps/common v0.0.0-20181107174310-61d8eb6f11b4/go.mod h1:TsgmjeDpbftqhwPKInJ3v+l+xbHs4goiB6DFb2WqY9c= +github.com/kubeapps/common v0.0.0-20190508164739-10b110436c1a/go.mod h1:TsgmjeDpbftqhwPKInJ3v+l+xbHs4goiB6DFb2WqY9c= +github.com/kubernetes-sigs/aws-iam-authenticator v0.3.0 h1:HOC7YpUao5F3RTIncfBfoh+7/ID1Jl97ALNgEmWIjxo= +github.com/kubernetes-sigs/aws-iam-authenticator v0.3.0/go.mod h1:ItxiN33Ho7Di8wiC4S4XqbH1NLF0DNdDWOd/5MI9gJU= +github.com/kubernetes-sigs/aws-iam-authenticator v0.3.1-0.20190111160901-390d9087a4bc h1:Ttr4Z3ZrMv4rAXn10UAqOC8ACx+F1omvcyV1a3hRArE= +github.com/kubernetes-sigs/aws-iam-authenticator v0.3.1-0.20190111160901-390d9087a4bc/go.mod h1:ItxiN33Ho7Di8wiC4S4XqbH1NLF0DNdDWOd/5MI9gJU= +github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= +github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= +github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0= +github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329 h1:2gxZ0XQIU/5z3Z3bUBu+FXuk2pFbkN6tcwi/pjyaDic= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63 h1:nTT4s92Dgz2HlrB2NaMgvlfqHH39OgMhA7z3PK7PGD4= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM= +github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= +github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.6 h1:SrwhHcpV4nWrMGdNcC2kXpMfcBVYGDuTArqyhocJgvA= +github.com/mattn/go-isatty v0.0.6/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-shellwords v1.0.5/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v0.0.0-20181005163659-0d29b283ac0f h1:wVzAD6PG9MIDNQMZ6zc2YpzE/9hhJ3EN+b+a4B1thVs= +github.com/miekg/dns v0.0.0-20181005163659-0d29b283ac0f/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.4/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= +github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309 h1:cvy4lBOYN3gKfKj8Lzz5Q9TfviP+L7koMHY7SvkyTKs= +github.com/moby/moby v0.7.3-0.20190826074503-38ab9da00309/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= +github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v1.0.0-rc1 h1:WzifXhOVOEOuFYOJAW6aQqW0TooG2iki3E3Ii+WN7gQ= +github.com/opencontainers/go-digest v1.0.0-rc1/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJGY8Y= +github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/runc v1.0.0-rc2.0.20190611121236-6cc515888830 h1:yvQ/2Pupw60ON8TYEIGGTAI77yZsWYkiOeHFZWkwlCk= +github.com/opencontainers/runc v1.0.0-rc2.0.20190611121236-6cc515888830/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/petar/GoLLRB v0.0.0-20130427215148-53be0d36a84c/go.mod h1:HUpKUBZnpzkdx0kD/+Yfuft+uD3zHGtXF/XJB14TUr4= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5 h1:rZQtoozkfsiNs36c7Tdv/gyGNzD1X1XWKO8rptVNZuM= +github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= +github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.2.1 h1:JnMpQc6ppsNgw9QPAGF6Dod479itz7lvlsMzzNayLOI= +github.com/prometheus/client_golang v1.2.1/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.2.0 h1:kUZDBDTdBVBYBj5Tmh2NZLlF60mfjA27rM34b+cVwNU= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0 h1:L+1lyG48J1zAQXA3RBX/nG/B3gjlHq0zTt2tlbJLyCY= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190129233650-316cf8ccfec5 h1:Etei0Wx6pooT/DeOKcGTr1M/01ggz95Ajq8BBwCOKBU= +github.com/prometheus/procfs v0.0.0-20190129233650-316cf8ccfec5/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.5 h1:3+auTFlqw+ZaQYJARz6ArODtkaIwtvBTx3N2NehQlL8= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= +github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= +github.com/rubenv/sql-migrate v0.0.0-20190902133344-8926f37f0bc1/go.mod h1:WS0rl9eEliYI8DPnr3TOwz4439pay+qNgzJoVya/DmY= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday v2.0.0 h1:L7Oc72h7rDqGkbUorN/ncJ4N/y220/YRezHvBoKLOFA= +github.com/russross/blackfriday v2.0.0+incompatible/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday v2.0.0+incompatible/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday v2.0.0/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= +github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.3/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/square/go-jose v2.3.0+incompatible/go.mod h1:7MxpAF/1WTVUu8Am+T5kNy+t0902CaLWM4Z745MkOa8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/technosophos/moniker v0.0.0-20180509230615-a5dbd03a2245 h1:DNVk+NIkGS0RbLkjQOLCJb/759yfCysThkMbl7EXxyY= +github.com/technosophos/moniker v0.0.0-20180509230615-a5dbd03a2245/go.mod h1:O1c8HleITsZqzNZDjSNzirUGsMT0oGu9LhHKoJrqO+A= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/unrolled/render v1.0.0 h1:XYtvhA3UkpB7PqkvhUFYmpKD55OudoIeygcfus4vcd4= +github.com/unrolled/render v1.0.0/go.mod h1:tu82oB5W2ykJRVioYsB+IQKcft7ryBr7w12qMBUPyXg= +github.com/unrolled/render v1.0.1/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM= +github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8= +github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.1.0 h1:ngVtJC9TY/lg0AA/1k48FYhBrhRoFlEmWzsehpNAaZg= +github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +github.com/xenolf/lego v0.0.0-20160613233155-a9d8cec0e656/go.mod h1:fwiGnfsIjG7OHPfOvgK7Y/Qo6+2Ox0iozjNTkZICKbY= +github.com/xenolf/lego v0.3.2-0.20160613233155-a9d8cec0e656 h1:BTvU+npm3/yjuBd53EvgiFLl5+YLikf2WvHsjRQ4KrY= +github.com/xenolf/lego v0.3.2-0.20160613233155-a9d8cec0e656/go.mod h1:fwiGnfsIjG7OHPfOvgK7Y/Qo6+2Ox0iozjNTkZICKbY= +github.com/xiang90/probing v0.0.0-20160813154853-07dd2e8dfe18/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yvasiyarov/go-metrics v0.0.0-20150112132944-c25f46c4b940 h1:p7OofyZ509h8DmPLh8Hn+EIIZm/xYhdZHJ9GnXHdr6U= +github.com/yvasiyarov/go-metrics v0.0.0-20150112132944-c25f46c4b940/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= +github.com/yvasiyarov/gorelic v0.0.6 h1:qMJQYPNdtJ7UNYHjX38KXZtltKTqimMuoQjNnSVIuJg= +github.com/yvasiyarov/gorelic v0.0.6/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f h1:ERexzlUfuTvpE74urLSbIQW0Z/6hF9t8U4NsJLaioAY= +github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= +go.mongodb.org/mongo-driver v1.1.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +go.opencensus.io v0.17.0/go.mod h1:mp1VrMQxhlqqDpKvH4UcQUa4YwlzNmymAjPrDdfxNpI= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.uber.org/atomic v0.0.0-20181018215023-8dc6146f7569/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v0.0.0-20180122172545-ddea229ff1df/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v0.0.0-20180814183419-67bc79d13d15/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190320223903-b7391e95e576/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7 h1:0hQKqeLdqlt5iIwVOBErRisrHJAN57yOiPRQItI20fU= +golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191028145041-f83a4685e152 h1:ZC1Xn5A1nlpSmQCIva4bZ3ob3lmhYIefc+GU+DLg1Ow= +golang.org/x/crypto v0.0.0-20191028145041-f83a4685e152/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20191205161847-0a08dada0ff9 h1:abxekknhS/Drh3uoQDk5Hc7BgeiyI39Crb7vhf/1j5s= +golang.org/x/crypto v0.0.0-20191205161847-0a08dada0ff9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190312203227-4b39c73a6495/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190225153610-fe579d43d832 h1:2IdId8zoI92l1bUzjAOygcAOkmCe13HY1j0rqPPPzB8= +golang.org/x/net v0.0.0-20190225153610-fe579d43d832/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190320064053-1272bf9dcd53/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190812203447-cdfb69ac37fc h1:gkKoSkUmnU6bpS/VhkuO27bzQeSA51uaEfbOW5dNb68= +golang.org/x/net v0.0.0-20190812203447-cdfb69ac37fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191028085509-fe3aa8a45271 h1:N66aaryRB3Ax92gH0v3hp1QYZ3zWWCCUR/j8Ifh45Ss= +golang.org/x/net v0.0.0-20191028085509-fe3aa8a45271/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226191147-529b322ea346 h1:zxGQKdHVCsCsJpbd7ijKsVC27CyETheUBql7Br2TGmA= +golang.org/x/oauth2 v0.0.0-20190226191147-529b322ea346/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTuEGr4PN7F4XJ1p4E3Y8= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190321052220-f7bb7a8bee54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f h1:25KHgbfyiSm6vwQLbM3zZIe1v9p/3ea4Rz+nnM5K/i4= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191028164358-195ce5e7f934 h1:u/E0NqCIWRDAo9WCFo6Ko49njPFDLSd3z+X1HgWDMpE= +golang.org/x/sys v0.0.0-20191028164358-195ce5e7f934/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c h1:fqgJT0MGcGpPgpWU7VRdRjuArfcOvC4AoJmILihzhDg= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +gonum.org/v1/gonum v0.0.0-20190331200053-3d26580ed485/go.mod h1:2ltnJ7xHfj0zHS40VVPYEAAMTa3ZGguvHGBSJeRWqE0= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/netlib v0.0.0-20190331212654-76723241ea4e/go.mod h1:kS+toOQn6AQKjmKJ7gzohV1XkqsFehRA2FbsbkopSuQ= +google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.6.1-0.20190607001116-5213b8090861/go.mod h1:btoxGiFvQNVUZQ8W08zLtrVS08CNpINPEfxXxgJL1Q4= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190128161407-8ac453e89fca/go.mod h1:L3J43x8/uS+qIUoksaLKe6OS3nUKxOKuIFz1sl2/jx4= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873 h1:nfPFGzJkUDX6uBmpN/pSw7MbOAWegH5QDQuoXFHedLg= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20191028173616-919d9bdd9fe6 h1:UXl+Zk3jqqcbEVV7ace5lrt4YdA4tXiz3f/KbmD29Vo= +google.golang.org/genproto v0.0.0-20191028173616-919d9bdd9fe6/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= +google.golang.org/grpc v1.15.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.23.0 h1:AzbTB6ux+okLTzP8Ru1Xs41C303zdcfEht7MQnYJt5A= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.24.0 h1:vb/1TCsVn3DcJlQ0Gs1yB1pKI6Do2/QNwxdKqmc/b0s= +google.golang.org/grpc v1.24.0/go.mod h1:XDChyiUovWa60DnaeDeZmSW86xtLtjtZbwvSiRnRtcA= +gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 h1:FVCohIoYO7IJoDDVpV2pdq7SgrMH6wHnuTyrdrxJNoY= +gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0/go.mod h1:OdE7CF6DbADk7lN8LIKRzRJTTZXIjtWgA5THM5lhBAw= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/gorp.v1 v1.7.2/go.mod h1:Wo3h+DBQZIxATwftsglhdD/62zRFPhGhTiu5jUJmCaw= +gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/square/go-jose.v1 v1.1.2 h1:/5jmADZB+RiKtZGr4HxsEFOEfbfsjTKsVnqpThUpE30= +gopkg.in/square/go-jose.v1 v1.1.2/go.mod h1:QpYS+a4WhS+DTlyQIi6Ka7MS3SuR9a055rgXNEe6EiA= +gopkg.in/square/go-jose.v2 v2.2.2 h1:orlkJ3myw8CN1nVQHBFfloD+L3egixIa4FvUP6RosSA= +gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +helm.sh/helm v2.14.3+incompatible h1:M3xvix8x41Xjc/ZGARGpUOTjxTBrb86yZG5FmCjgIew= +helm.sh/helm/v3 v3.0.0-beta.4 h1:gK5iJztTqr4Ai712LSS2fxqaW2b635GxunkBinEBCXY= +helm.sh/helm/v3 v3.0.0-beta.4/go.mod h1:ZQ5l9gLmS7attkwdVKXxygj84XE4dzZIdr3frQ9H7No= +helm.sh/helm/v3 v3.0.0 h1:or/9cs1GgfcTQeEnR2CVJNw893/rmqIG1KsNHmUiSFw= +helm.sh/helm/v3 v3.0.0/go.mod h1:sI7B9yfvMgxtTPMWdk1jSKJ2aa59UyP9qhPydqW6mgo= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.0.0-20190222213804-5cb15d344471 h1:MzQGt8qWQCR+39kbYRd0uQqsvSidpYqJLFeWiJ9l4OE= +k8s.io/api v0.0.0-20190222213804-5cb15d344471/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA= +k8s.io/api v0.0.0-20190226013409-f951fa7b8c72 h1:4wrH+M4Kpkbbjs4UzT7XOvuhgBczsd4eXfBn9T5WqKk= +k8s.io/api v0.0.0-20190226013409-f951fa7b8c72/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA= +k8s.io/api v0.0.0-20191016110408-35e52d86657a h1:VVUE9xTCXP6KUPMf92cQmN88orz600ebexcRRaBTepQ= +k8s.io/api v0.0.0-20191016110408-35e52d86657a/go.mod h1:/L5qH+AD540e7Cetbui1tuJeXdmNhO8jM6VkXeDdDhQ= +k8s.io/api v0.0.0-20191115135540-bbc9463b57e5 h1:o1kKo74JxBOOhxPdKzTx54MJHwu+Z6Lv5/1tu8Qf9eM= +k8s.io/api v0.0.0-20191115135540-bbc9463b57e5/go.mod h1:iA/8arsvelvo4IDqIhX4IbjTEKBGgvsf2OraTuRtLFU= +k8s.io/apiextensions-apiserver v0.0.0-20190221221350-bfb440be4b87 h1:BMfPZfi3CkPd9e9Qbm+/AFgE6qXWQcbzUvC91LR3m7w= +k8s.io/apiextensions-apiserver v0.0.0-20190221221350-bfb440be4b87/go.mod h1:IxkesAMoaCRoLrPJdZNZUQp9NfZnzqaVzLhb2VEQzXE= +k8s.io/apiextensions-apiserver v0.0.0-20190225215027-42f453e3c54a h1:+yPI0R62TeitWAXHh5K5xVQrUF1JQwK+nrl5Lz6213E= +k8s.io/apiextensions-apiserver v0.0.0-20190225215027-42f453e3c54a/go.mod h1:IxkesAMoaCRoLrPJdZNZUQp9NfZnzqaVzLhb2VEQzXE= +k8s.io/apiextensions-apiserver v0.0.0-20191016113550-5357c4baaf65 h1:kThoiqgMsSwBdMK/lPgjtYTsEjbUU9nXCA9DyU3feok= +k8s.io/apiextensions-apiserver v0.0.0-20191016113550-5357c4baaf65/go.mod h1:5BINdGqggRXXKnDgpwoJ7PyQH8f+Ypp02fvVNcIFy9s= +k8s.io/apimachinery v0.0.0-20190117220443-572dfc7bdfcb h1:+mNFBkhBgd0wFJ1K18cOYw3LVW7aMIM/pazb4i44aS0= +k8s.io/apimachinery v0.0.0-20190117220443-572dfc7bdfcb/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= +k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628 h1:UYfHH+KEF88OTg+GojQUwFTNxbxwmoktLwutUzR0GPg= +k8s.io/apimachinery v0.0.0-20190221213512-86fb29eff628/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= +k8s.io/apimachinery v0.0.0-20190223094358-dcb391cde5ca h1:rOczZwIfERvL7YLSQ96uI3NUYamzdK/MmSDIfChQRBk= +k8s.io/apimachinery v0.0.0-20190223094358-dcb391cde5ca/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= +k8s.io/apimachinery v0.0.0-20191004115801-a2eda9f80ab8 h1:Iieh/ZEgT3BWwbLD5qEKcY06jKuPEl6zC7gPSehoLw4= +k8s.io/apimachinery v0.0.0-20191004115801-a2eda9f80ab8/go.mod h1:llRdnznGEAqC3DcNm6yEj472xaFVfLM7hnYofMb12tQ= +k8s.io/apimachinery v0.0.0-20191115015347-3c7067801da2 h1:TSH6UZ+y3etc/aDbVqow1NT8o7SJXkxhLKbp3Ywhyvg= +k8s.io/apimachinery v0.0.0-20191115015347-3c7067801da2/go.mod h1:dXFS2zaQR8fyzuvRdJDHw2Aerij/yVGJSre0bZQSVJA= +k8s.io/apiserver v0.0.0-20190221215341-5838f549963b h1:yFFYCR/UVj6hsYX6yRbY3CJ3917NF7tn0hwcdUil1aI= +k8s.io/apiserver v0.0.0-20190221215341-5838f549963b/go.mod h1:6bqaTSOSJavUIXUtfaR9Os9JtTCm8ZqH2SUl2S60C4w= +k8s.io/apiserver v0.0.0-20190225213950-239e48e34c98 h1:aC93uSkvMU+AbozPRy72NtzFi7lefwzh4jKNuJZ8+ug= +k8s.io/apiserver v0.0.0-20190225213950-239e48e34c98/go.mod h1:6bqaTSOSJavUIXUtfaR9Os9JtTCm8ZqH2SUl2S60C4w= +k8s.io/apiserver v0.0.0-20191016112112-5190913f932d/go.mod h1:7OqfAolfWxUM/jJ/HBLyE+cdaWFBUoo5Q5pHgJVj2ws= +k8s.io/cli-runtime v0.0.0-20190221221947-d8fee89e76ca h1:UbXBTjFC1Yy5SDU8TPLVay3znB6yrlCo7Yq/q1VdESQ= +k8s.io/cli-runtime v0.0.0-20190221221947-d8fee89e76ca/go.mod h1:qWnH3/b8sp/l7EvlDh7ulDU3UWA4P4N1NFbEEP791tM= +k8s.io/cli-runtime v0.0.0-20190223141024-b5efea9a9c9c h1:fOmiMKsuY198ukXcZMRhEEQGYhVFhwCHn2j/fMExtcA= +k8s.io/cli-runtime v0.0.0-20190223141024-b5efea9a9c9c/go.mod h1:qWnH3/b8sp/l7EvlDh7ulDU3UWA4P4N1NFbEEP791tM= +k8s.io/cli-runtime v0.0.0-20191016114015-74ad18325ed5 h1:8ZfMjkMBzcXEawLsYHg9lDM7aLEVso3NiVKfUTnN56A= +k8s.io/cli-runtime v0.0.0-20191016114015-74ad18325ed5/go.mod h1:sDl6WKSQkDM6zS1u9F49a0VooQ3ycYFBFLqd2jf2Xfo= +k8s.io/client-go v0.0.0-20191016111102-bec269661e48 h1:C2XVy2z0dV94q9hSSoCuTPp1KOG7IegvbdXuz9VGxoU= +k8s.io/client-go v0.0.0-20191016111102-bec269661e48/go.mod h1:hrwktSwYGI4JK+TJA3dMaFyyvHVi/aLarVHpbs8bgCU= +k8s.io/client-go v2.0.0-alpha.0.0.20190202011228-6e4752048fde+incompatible h1:xkYgpj1zwmLkSh7QASe/GUMTyjPI3hwHy7k/RcQSp2A= +k8s.io/client-go v2.0.0-alpha.0.0.20190202011228-6e4752048fde+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= +k8s.io/client-go v10.0.0+incompatible h1:F1IqCqw7oMBzDkqlcBymRq1450wD0eNqLE9jzUrIi34= +k8s.io/client-go v10.0.0+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= +k8s.io/code-generator v0.0.0-20190927045949-f81bca4f5e85/go.mod h1:V5BD6M4CyaN5m+VthcclXWsVcT1Hu+glwa1bi3MIsyE= +k8s.io/code-generator v0.0.0-20191004115455-8e001e5d1894/go.mod h1:mJUgkl06XV4kstAnLHAIzJPVCOzVR+ZcfPIv4fUsFCY= +k8s.io/component-base v0.0.0-20191003000551-f573d376509c/go.mod h1:VLMr8+SrOC4J3MDbbL7cjBDEPBcwLP9/kv/u8PVUEo4= +k8s.io/component-base v0.0.0-20191016111319-039242c015a9/go.mod h1:SuWowIgd/dtU/m/iv8OD9eOxp3QZBBhTIiWMsBQvKjI= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/gengo v0.0.0-20190822140433-26a664648505/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/helm v2.12.3+incompatible h1:wo1cdYjOnr5Z+LFuhtwIJaeQnec6D4gcg2H5UAKzY6w= +k8s.io/helm v2.12.3+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= +k8s.io/helm v2.16.1+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.2.0 h1:0ElL0OHzF3N+OhoJTL0uca20SxtYt4X4+bzHeqrB83c= +k8s.io/klog v0.2.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v0.4.0 h1:lCJCxf/LIowc2IGS9TPjWDyXY4nOmdGdfcwwDQCOURQ= +k8s.io/klog v0.4.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/kube-openapi v0.0.0-20180509051136-39cb288412c4 h1:gW+EUB2I96nbxVenV/8ctfbACsHP+yxlT2dhMCsiy+s= +k8s.io/kube-openapi v0.0.0-20180509051136-39cb288412c4/go.mod h1:BXM9ceUBTj2QnfH2MK1odQs778ajze1RxcmP6S8RVVc= +k8s.io/kube-openapi v0.0.0-20190225204428-d50a959ae76a h1:x9PsbfKs/IaYsygEYCbR58UWoyxHQTeHjUDpy4o+QoE= +k8s.io/kube-openapi v0.0.0-20190225204428-d50a959ae76a/go.mod h1:BXM9ceUBTj2QnfH2MK1odQs778ajze1RxcmP6S8RVVc= +k8s.io/kube-openapi v0.0.0-20190816220812-743ec37842bf/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kube-openapi v0.0.0-20190918143330-0270cf2f1c1d h1:Xpe6sK+RY4ZgCTyZ3y273UmFmURhjtoJiwOMbQsXitY= +k8s.io/kube-openapi v0.0.0-20190918143330-0270cf2f1c1d/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a h1:UcxjrRMyNx/i/y8G7kPvLyy7rfbeuf1PYyBf973pgyU= +k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +k8s.io/kubectl v0.0.0-20191003004222-1f3c0cd90ca9 h1:fIRS0hFdUag6oW29mzbml8LOzXIFw6Pey2uRVT0vWsM= +k8s.io/kubectl v0.0.0-20191003004222-1f3c0cd90ca9/go.mod h1:vRGcIHH2xhkOt8KjrcyJGt6tJYopGswaRENLPPG3jvQ= +k8s.io/kubectl v0.0.0-20191016120415-2ed914427d51 h1:RBkTKVMF+xsNsSOVc0+HdC0B5gD1sr6s6Cu5w9qNbuQ= +k8s.io/kubectl v0.0.0-20191016120415-2ed914427d51/go.mod h1:gL826ZTIfD4vXTGlmzgTbliCAT9NGiqpCqK2aNYv5MQ= +k8s.io/kubernetes v1.13.3 h1:46t44D87wKtdKFgr/lXM60K8xPrW0wO67Woof3Vsv6E= +k8s.io/kubernetes v1.13.3/go.mod h1:ocZa8+6APFNC2tX1DZASIbocyYT5jHzqFVsY5aoB7Jk= +k8s.io/kubernetes/staging/src/k8s.io/component-base v0.0.0-20191001043732-d647ddbd755f/go.mod h1:spPP+vRNS8EsnNNIhFCZTTuRO3XhV1WoF18HJySoZn8= +k8s.io/metrics v0.0.0-20191003002233-837aead57baf/go.mod h1:3nppwBBAvxwYOuLha+uf3pQfBRBUT8unkiBEU3EH7/U= +k8s.io/metrics v0.0.0-20191016113814-3b1a734dba6e/go.mod h1:ve7/vMWeY5lEBkZf6Bt5TTbGS3b8wAxwGbdXAsufjRs= +k8s.io/utils v0.0.0-20190221042446-c2654d5206da h1:ElyM7RPonbKnQqOcw7dG2IK5uvQQn3b/WPHqD5mBvP4= +k8s.io/utils v0.0.0-20190221042446-c2654d5206da/go.mod h1:8k8uAuAQ0rXslZKaEWd0c3oVhZz7sSzSiPnVZayjIX0= +k8s.io/utils v0.0.0-20190801114015-581e00157fb1 h1:+ySTxfHnfzZb9ys375PXNlLhkJPLKgHajBU0N62BDvE= +k8s.io/utils v0.0.0-20190801114015-581e00157fb1/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +k8s.io/utils v0.0.0-20191010214722-8d271d903fe4 h1:Gi+/O1saihwDqnlmC8Vhv1M5Sp4+rbOmK9TbsLn8ZEA= +k8s.io/utils v0.0.0-20191010214722-8d271d903fe4/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew= +modernc.org/cc v1.0.0/go.mod h1:1Sk4//wdnYJiUIxnW8ddKpaOJCF37yAdqYnkxUpaYxw= +modernc.org/golex v1.0.0/go.mod h1:b/QX9oBD/LhixY6NDh+IdGv17hgB+51fET1i2kPSmvk= +modernc.org/mathutil v1.0.0/go.mod h1:wU0vUrJsVWBZ4P6e7xtFJEhFSNsfRLJ8H458uRjg03k= +modernc.org/strutil v1.0.0/go.mod h1:lstksw84oURvj9y3tn8lGvRxyRC1S2+g5uuIzNfIOBs= +modernc.org/xc v1.0.0/go.mod h1:mRNCo0bvLjGhHO9WsyuKVU4q0ceiDDDoEeWDJHrNx8I= +rsc.io/letsencrypt v0.0.1 h1:DV0d09Ne9E7UUa9ZqWktZ9L2VmybgTgfq7xlfFR/bbU= +rsc.io/letsencrypt v0.0.1/go.mod h1:buyQKZ6IXrRnB7TdkHP0RyEybLx18HHyOSoTyoOLqNY= +sigs.k8s.io/kustomize v2.0.3+incompatible h1:JUufWFNlI44MdtnjUqVnvh29rR37PQFzPbLXqhyOyX0= +sigs.k8s.io/kustomize v2.0.3+incompatible/go.mod h1:MkjgH3RdOWrievjo6c9T245dYlB5QeXV4WCbnt/PEpU= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/structured-merge-diff v0.0.0-20190817042607-6149e4549fca/go.mod h1:IIgPezJWb76P0hotTxzDbWsMYB8APh18qZnxkomBpxA= +sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787 h1:O69FD9pJA4WUZlEwYatBEEkRWKQ5cKodWpdKTrCS/iQ= +vbom.ml/util v0.0.0-20180919145318-efcd4e0f9787/go.mod h1:so/NYdZXCz+E3ZpW0uAoCj6uzU2+8OWDFv/HxUSs7kI= diff --git a/src/jetstream/plugins/kubernetes/helm/graph.go b/src/jetstream/plugins/kubernetes/helm/graph.go new file mode 100644 index 0000000000..a46bf3ac0a --- /dev/null +++ b/src/jetstream/plugins/kubernetes/helm/graph.go @@ -0,0 +1,280 @@ +package helm + +import ( + "fmt" + + "reflect" + "strings" + + log "github.com/sirupsen/logrus" + appsv1 "k8s.io/api/apps/v1" + appsv1beta1 "k8s.io/api/apps/v1beta1" + appsv1beta2 "k8s.io/api/apps/v1beta2" + batchv1 "k8s.io/api/batch/v1" + v1 "k8s.io/api/core/v1" + extv1beta1 "k8s.io/api/extensions/v1beta1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type HelmReleaseGraph struct { + Release *HelmRelease `json:"-"` + Nodes map[string]ReleaseNode `json:"nodes"` + Links map[string]ReleaseLink `json:"links"` +} + +type ReleaseNode struct { + ID string `json:"id"` + Label string `json:"label"` + Data struct { + Kind string `json:"kind"` + Status NodeStatus `json:"status"` + Metadata struct { + Name string `yaml:"name" json:"name"` + Namespace string `yaml:"namespace" json:"namespace"` + } `yaml:"metadata" json:"metadata"` + } `json:"data"` +} + +type ReleaseLink struct { + ID string `json:"id"` + Source string `json:"source"` + Target string `json:"target"` +} + +// NewHelmReleaseGraph represents graph of the Helm Release +func NewHelmReleaseGraph(release *HelmRelease) *HelmReleaseGraph { + r := &HelmReleaseGraph{ + Release: release, + } + r.Nodes = make(map[string]ReleaseNode) + r.Links = make(map[string]ReleaseLink) + return r +} + +func (r *HelmReleaseGraph) AddLink(source, target string) { + link := ReleaseLink{ + ID: fmt.Sprintf("%s_to_%s", source, target), + Source: source, + Target: target, + } + r.Links[link.ID] = link + log.Debugf("Adding link %s -> %s", source, target) +} + +// ParseManifest +func (r *HelmReleaseGraph) ParseManifest(release *HelmRelease) { + for _, item := range release.Resources { + node := ReleaseNode{ + ID: fmt.Sprintf("%s-%s", item.Kind, item.Metadata.Name), + Label: item.Metadata.Name, + } + + node.Data.Kind = item.Kind + node.Data.Metadata = item.Metadata + // Note - item.Metadata.Namespace is nil + node.Data.Status = "unknown" + + switch o := item.Resource.(type) { + case *appsv1.Deployment: + target := getShortResourceId(o.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + node.Data.Status = mapDeploymentStatus(o.Status.Replicas, o.Status.ReadyReplicas, o.Status.AvailableReplicas, o.Status.UnavailableReplicas) + case *appsv1beta1.Deployment: + target := getShortResourceId(o.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + node.Data.Status = mapDeploymentStatus(o.Status.Replicas, o.Status.ReadyReplicas, o.Status.AvailableReplicas, o.Status.UnavailableReplicas) + case *appsv1beta2.Deployment: + target := getShortResourceId(o.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + node.Data.Status = mapDeploymentStatus(o.Status.Replicas, o.Status.ReadyReplicas, o.Status.AvailableReplicas, o.Status.UnavailableReplicas) + case *extv1beta1.Deployment: + target := getShortResourceId(o.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + node.Data.Status = mapDeploymentStatus(o.Status.Replicas, o.Status.ReadyReplicas, o.Status.AvailableReplicas, o.Status.UnavailableReplicas) + case *appsv1.StatefulSet: + target := getShortResourceId(o.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + case *appsv1beta2.StatefulSet: + target := getShortResourceId(o.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + case *appsv1beta1.StatefulSet: + target := getShortResourceId(o.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + case *appsv1.ReplicaSet: + target := getShortResourceId(o.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + node.Data.Status = mapReplicaSetStatus(o.Status) + case *v1.Pod: + target := getShortResourceId(item.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + r.ProcessPod(target, item, o.Spec, o.Status) + node.Data.Status = mapPodStatus(o.Status.Phase) + case *v1.Service: + target := getShortResourceId(item.Kind, o.Name) + r.ProcessService(target, item, o.Spec) + case *batchv1.Job: + target := getShortResourceId(item.Kind, o.Name) + r.ParseResourceOwners(target, o.OwnerReferences) + r.ProcessServiceAccount(target, o.Spec.Template) + case *rbacv1.RoleBinding: + target := getShortResourceId(item.Kind, o.Name) + r.ParseRoleBinding(target, o) + case *rbacv1.ClusterRoleBinding: + target := getShortResourceId(item.Kind, o.Name) + r.ParseClusterRoleBinding(target, o) + default: + log.Debugf("Graph: Unknown type: %s", reflect.TypeOf(o)) + } + + // Add or replace the node in the map + r.Nodes[node.ID] = node + } + + // Make sure all links have nodes + for _, link := range r.Links { + if _, ok := r.Nodes[link.Source]; !ok { + r.generateTemporaryNode(link.Source) + } + if _, ok := r.Nodes[link.Target]; !ok { + r.generateTemporaryNode(link.Target) + } + } +} + +func (r *HelmReleaseGraph) generateTemporaryNode(id string) { + var parts = strings.Split(id, "-") + + node := ReleaseNode{ + ID: id, + Label: strings.Join(parts[1:], "-"), + } + + node.Data.Kind = parts[0] + node.Data.Status = "missing" + r.Nodes[node.ID] = node +} + +func getShortResourceId(kind, name string) string { + return fmt.Sprintf("%s-%s", kind, name) +} + +func (r *HelmReleaseGraph) ParseResourceOwners(id string, owners []metav1.OwnerReference) { + // We've got a pod, so associate it with its owner(s) + //objMeta.OwnerReferences + for _, owner := range owners { + source := getShortResourceId(owner.Kind, owner.Name) + r.AddLink(source, id) + } +} + +func (r *HelmReleaseGraph) ProcessPod(id string, res KubeResource, spec v1.PodSpec, status v1.PodStatus) { + // Look through volumes + // name and: PersistentVolumeClaim.ClaimName or Secret.SecretName + for _, volume := range spec.Volumes { + if volume.VolumeSource.PersistentVolumeClaim != nil { + ref := fmt.Sprintf("PersistentVolumeClaim-%s", volume.VolumeSource.PersistentVolumeClaim.ClaimName) + r.AddLink(id, ref) + } else if volume.VolumeSource.Secret != nil { + ref := fmt.Sprintf("Secret-%s", volume.VolumeSource.Secret.SecretName) + r.AddLink(id, ref) + } else if volume.VolumeSource.ConfigMap != nil { + ref := fmt.Sprintf("ConfigMap-%s", volume.VolumeSource.ConfigMap.Name) + r.AddLink(id, ref) + } + } + + // Service Account + saName := spec.ServiceAccountName + if len(saName) > 0 { + ref := fmt.Sprintf("ServiceAccount-%s", saName) + r.AddLink(id, ref) + } + + // Go through the pod and process each container + // Add a node for each container + for _, container := range spec.Containers { + node := ReleaseNode{ + ID: fmt.Sprintf("%s-%s", id, container.Name), + Label: container.Name, + } + + node.Data.Kind = "Container" + node.Data.Status = mapContainerStatus(status, container.Name) + + // Add a node for the container and link it to the pod + r.Nodes[node.ID] = node + r.AddLink(id, node.ID) + + // Add links for ConfigMaps and Secrets used in the env_from + for _, envFrom := range container.EnvFrom { + if envFrom.ConfigMapRef != nil { + ref := fmt.Sprintf("ConfigMap-%s", envFrom.ConfigMapRef.Name) + r.AddLink(node.ID, ref) + } else if envFrom.SecretRef != nil { + ref := fmt.Sprintf("Secret-%s", envFrom.SecretRef.Name) + r.AddLink(node.ID, ref) + } + } + + // Add links for ConfigMaps and Secrets used in the env + for _, env := range container.Env { + if env.ValueFrom != nil { + if env.ValueFrom.ConfigMapKeyRef != nil { + ref := fmt.Sprintf("ConfigMap-%s", env.ValueFrom.ConfigMapKeyRef) + r.AddLink(node.ID, ref) + } else if env.ValueFrom.SecretKeyRef != nil { + ref := fmt.Sprintf("Secret-%s", env.ValueFrom.SecretKeyRef.Name) + r.AddLink(node.ID, ref) + } + } + } + } +} + +func (r *HelmReleaseGraph) ProcessService(id string, res KubeResource, spec v1.ServiceSpec) { + if len(spec.Selector) > 0 { + // Find all Pods that match this selector + for _, item := range r.Release.Resources { + switch o := item.Resource.(type) { + case *v1.Pod: + if labelsMatch(spec.Selector, o.Labels) { + podID := fmt.Sprintf("Pod-%s", o.Name) + r.AddLink(podID, id) + } + } + } + } +} + +func (r *HelmReleaseGraph) ProcessServiceAccount(id string, template v1.PodTemplateSpec) { + if len(template.Spec.ServiceAccountName) > 0 { + svcAccountID := fmt.Sprintf("ServiceAccount-%s", template.Spec.ServiceAccountName) + r.AddLink(id, svcAccountID) + } else if len(template.Spec.DeprecatedServiceAccount) > 0 { + svcAccountID := fmt.Sprintf("ServiceAccount-%s", template.Spec.DeprecatedServiceAccount) + r.AddLink(id, svcAccountID) + } +} + +func (r *HelmReleaseGraph) ParseRoleBinding(id string, roleBinding *rbacv1.RoleBinding) { + for _, subject := range roleBinding.Subjects { + // TODO: Only match those with the same namespace ???? + subjectID := fmt.Sprintf("%s-%s", subject.Kind, subject.Name) + r.AddLink(id, subjectID) + } + + roleRefID := fmt.Sprintf("%s-%s", roleBinding.RoleRef.Kind, roleBinding.RoleRef.Name) + r.AddLink(id, roleRefID) +} + +func (r *HelmReleaseGraph) ParseClusterRoleBinding(id string, roleBinding *rbacv1.ClusterRoleBinding) { + for _, subject := range roleBinding.Subjects { + // TODO: Only match those with the same namespace ???? + subjectID := fmt.Sprintf("%s-%s", subject.Kind, subject.Name) + r.AddLink(id, subjectID) + } + + roleRefID := fmt.Sprintf("%s-%s", roleBinding.RoleRef.Kind, roleBinding.RoleRef.Name) + r.AddLink(id, roleRefID) +} diff --git a/src/jetstream/plugins/kubernetes/helm/graph_status.go b/src/jetstream/plugins/kubernetes/helm/graph_status.go new file mode 100644 index 0000000000..6598679a4f --- /dev/null +++ b/src/jetstream/plugins/kubernetes/helm/graph_status.go @@ -0,0 +1,86 @@ +package helm + +import ( + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" +) + +// NodeStatus represents the status of a node in the graph +type NodeStatus string + +const ( + // NodeOK indicates OK Status + NodeOK NodeStatus = "ok" + // NodeWarn indicates Warning Status + NodeWarn NodeStatus = "warn" + // NodeError indicates Error Status + NodeError NodeStatus = "error" + // NodeUnknown indicates status is unknown + NodeUnknown NodeStatus = "unknown" + // NodeNone indicates node has no status + NodeNone NodeStatus = "none" +) + +func mapDeploymentStatus(replicas, ready, available, unavailable int32) NodeStatus { + if replicas == ready { + return NodeOK + } + + if unavailable > 0 { + return NodeError + } + + if replicas != unavailable { + return NodeWarn + } + + return NodeWarn +} + +func mapReplicaSetStatus(status appsv1.ReplicaSetStatus) NodeStatus { + if status.Replicas == status.ReadyReplicas { + return NodeOK + } + + if status.Replicas != status.AvailableReplicas { + return NodeError + } + + return NodeWarn +} + +func mapContainerStatus(status v1.PodStatus, name string) NodeStatus { + for _, cstat := range status.ContainerStatuses { + if cstat.Name == name { + if cstat.Ready { + return NodeOK + } else { + // Could be a pod that has completed + if cstat.State.Terminated != nil { + if cstat.State.Terminated.ExitCode == 0 && cstat.State.Terminated.Reason == "Completed" { + return NodeOK + } + } + return NodeWarn + } + } + } + return NodeError +} + +func mapPodStatus(phase v1.PodPhase) NodeStatus { + + status := NodeUnknown + + switch phase { + case v1.PodFailed: + status = NodeError + case v1.PodRunning: + status = NodeOK + case v1.PodSucceeded: + status = NodeOK + case v1.PodPending: + status = NodeWarn + } + return status +} diff --git a/src/jetstream/plugins/kubernetes/helm/job.go b/src/jetstream/plugins/kubernetes/helm/job.go new file mode 100644 index 0000000000..523dde9f79 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/helm/job.go @@ -0,0 +1,85 @@ +package helm + +import ( + log "github.com/sirupsen/logrus" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +// KubeResourceJob = Resource(s) that we need to go and fetch +type KubeResourceJob struct { + ID string + Kind string + APIVersion string + Name string + Namespace string + Endpoint string + User string + URL string + Parent string +} + +// KubeResourceJobResult is the result from a job +type KubeResourceJobResult struct { + KubeResourceJob + StatusCode int + Data []byte +} + +// KubeAPIJob represents a set of jobs to run against the Kube API +type KubeAPIJob struct { + Jetstream interfaces.PortalProxy + Jobs []KubeResourceJob +} + +// NewKubeAPIJob returns a helper that can execute all jobs and return results +func NewKubeAPIJob(jetstream interfaces.PortalProxy, jobs []KubeResourceJob) *KubeAPIJob { + r := &KubeAPIJob{ + Jetstream: jetstream, + Jobs: jobs, + } + return r +} + +// Run will run all of the jobs +func (j *KubeAPIJob) Run() []KubeResourceJobResult { + count := len(j.Jobs) + var res []KubeResourceJobResult + kubeJobs := make(chan KubeResourceJob, count) + kubeResults := make(chan KubeResourceJobResult, count) + + for w := 1; w <= 4; w++ { + go j.restWorker(j.Jetstream, w, kubeJobs, kubeResults) + } + + for _, j := range j.Jobs { + kubeJobs <- j + } + + close(kubeJobs) + + var v KubeResourceJobResult + for a := 1; a <= count; a++ { + v = <-kubeResults + res = append(res, v) + } + + return res +} + +func (j *KubeAPIJob) restWorker(jetstream interfaces.PortalProxy, id int, jobs <-chan KubeResourceJob, results chan<- KubeResourceJobResult) { + for job := range jobs { + response, err := j.Jetstream.DoProxySingleRequest(job.Endpoint, job.User, "GET", job.URL, nil, nil) + log.Debugf("Rest Worker finished for: %s - %d", job.URL, response.StatusCode) + res := KubeResourceJobResult{ + KubeResourceJob: job, + StatusCode: response.StatusCode, + Data: response.Response, + } + + if err != nil { + log.Errorf("KubeAPIJob: Failed to run job: %+v", err) + } + results <- res + } +} diff --git a/src/jetstream/plugins/kubernetes/helm/release.go b/src/jetstream/plugins/kubernetes/helm/release.go new file mode 100644 index 0000000000..915d1c8a2d --- /dev/null +++ b/src/jetstream/plugins/kubernetes/helm/release.go @@ -0,0 +1,418 @@ +package helm + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + log "github.com/sirupsen/logrus" + "helm.sh/helm/v3/pkg/release" + appsv1 "k8s.io/api/apps/v1" + appsv1beta1 "k8s.io/api/apps/v1beta1" + appsv1beta2 "k8s.io/api/apps/v1beta2" + batchv1 "k8s.io/api/batch/v1" + v1 "k8s.io/api/core/v1" + extv1beta1 "k8s.io/api/extensions/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/yaml" +) + +var resourcesWithoutStatus = map[string]bool{ + "RoleBinding": false, + "Role": false, + "ClusterRole": false, + "ClusterRoleBinding": false, + "PodSecurityPolicy": false, +} + +// HelmRelease represents a Helm Release deployed via Helm +type HelmRelease struct { + *release.Release + Endpoint string `json:"-"` + User string `json:"-"` + Resources map[string]KubeResource `json:"resources"` + Jobs []KubeResourceJob `json:"-"` + PodJobs map[string]KubeResourceJob `json:"-"` + ManifestErrors bool `json:"-"` +} + +// KubeResource is a simple struct to pull out core common metadata for a Kube resource +type KubeResource struct { + Kind string `yaml:"kind" json:"kind"` + APIVersion string `yaml:"apiVersion" json:"apiVersion"` + Metadata struct { + Name string `yaml:"name" json:"name"` + Namespace string `yaml:"namespace" json:"namespace"` + } `yaml:"metadata" json:"metadata"` + Resource interface{} `yaml:"resource"` + Manifest bool +} + +func (r *KubeResource) getID() string { + return fmt.Sprintf("%s-%s-%s", r.Kind, r.APIVersion, r.Metadata.Name) +} + +// NewHelmRelease represents extended info about a Helm Release +func NewHelmRelease(info *release.Release, endpoint, user string, jetstream interfaces.PortalProxy) *HelmRelease { + r := &HelmRelease{ + Release: info, + Endpoint: endpoint, + User: user, + } + r.Resources = make(map[string]KubeResource) + r.PodJobs = make(map[string]KubeResourceJob) + r.parseManifest() + return r +} + +// Parse the release manifest from the Helm release +func (r *HelmRelease) parseManifest() { + r.ManifestErrors = false + reader := bytes.NewReader([]byte(r.Manifest)) + buffer := bufio.NewReader(reader) + var bufr strings.Builder + for { + line, err := buffer.ReadString('\n') + if err != nil || (err == nil && strings.TrimRight(line, "\t \n") == "---") { + data := []byte(bufr.String()) + if len(data) > 0 { + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode(data, nil, nil) + if err != nil { + // Custom Resource Definition + if strings.HasPrefix(err.Error(), "no kind") { + var t interface{} + if err := yaml.Unmarshal(data, &t); err == nil { + r.processYamlResource(t, data) + } else { + log.Errorf("Could not parse custom resource %s", err) + } + } else { + log.Error(fmt.Sprintf("Helm Manifest Parser: Error while decoding YAML object. Err was: %s", err)) + r.ManifestErrors = true + } + } else { + r.processJsonResource(obj) + } + + bufr.Reset() + line = "" + } + } + + if err != nil { + break + } + + // Ignore comments + if !strings.HasPrefix(strings.TrimSpace(line), "#") && !strings.HasPrefix(strings.TrimRight(line, "\t \n"), "---") { + bufr.WriteString(line) + } + } +} + +func getResourceIdentifier(typeMeta metav1.TypeMeta, objectMeta metav1.ObjectMeta) string { + return fmt.Sprintf("%s-%s-%s", typeMeta.Kind, typeMeta.APIVersion, objectMeta.Name) +} + +func (r *HelmRelease) setResource(res KubeResource) { + r.Resources[res.getID()] = res +} +func (r *HelmRelease) deleteResource(res KubeResource) { + delete(r.Resources, res.getID()) +} + +// GetResources gets all fo the resources for the release +func (r *HelmRelease) GetResources() []interface{} { + var resources []interface{} + for _, res := range r.Resources { + resources = append(resources, res.Resource) + } + return resources +} + +// GetPods gets the pod resources for the release +func (r *HelmRelease) GetPods() []interface{} { + var resources []interface{} + for _, res := range r.Resources { + if res.Kind == "Pod" { + resources = append(resources, res.Resource) + } + } + return resources +} + +func (r *HelmRelease) processJsonResource(obj interface{}) { + data, err := json.Marshal(obj) + if err == nil { + var t KubeResource + if err := json.Unmarshal(data, &t); err == nil { + // If this is a List, then unpack it + if t.APIVersion == "v1" && t.Kind == "List" { + var list v1.PodList + err := json.Unmarshal(data, &list) + if err == nil { + for _, item := range list.Items { + r.processJsonResource(item) + } + } else { + log.Error("Helm Release Manifest: Could not parse List resource") + } + } else { + r.processKubeResource(obj, t) + } + } else { + log.Error("Helm Release Manifest: Could not parse Kubernetes resource") + } + } else { + log.Errorf("Helm Release ManifestL Could not marshal Kubernetes resource %s", err) + } +} + +func (r *HelmRelease) processYamlResource(obj interface{}, data []byte) { + var t KubeResource + if err := yaml.Unmarshal(data, &t); err == nil { + r.processKubeResource(obj, t) + } else { + log.Error("Helm Release Manifest: Could not parse Kubernetes resource") + } +} + +// process a yaml resource from the helm manifest +//func (r *HelmRelease) processResource(obj runtime.Object) { +func (r *HelmRelease) processKubeResource(obj interface{}, t KubeResource) { + t.Resource = obj + t.Manifest = true + r.setResource(t) + log.Debugf("Got resource: %s : %s", t.Kind, t.Metadata.Name) + r.processController(t) + r.addJobForResource(r.Namespace, t.Kind, t.APIVersion, t.Metadata.Name) +} + +func (r *HelmRelease) addJobForResource(namespace, kind, apiVersion, name string) { + job := KubeResourceJob{ + ID: fmt.Sprintf("%s-%s#Pods", kind, name), + Endpoint: r.Endpoint, + User: r.User, + Namespace: namespace, + Name: name, + URL: getRestURL(namespace, kind, apiVersion, name), + APIVersion: apiVersion, + Kind: kind, + } + r.Jobs = append(r.Jobs, job) +} + +func (r *HelmRelease) processController(kres KubeResource) { + switch o := kres.Resource.(type) { + case *appsv1.Deployment: + r.processPodSelector(kres, o.Spec.Selector) + case *appsv1beta1.Deployment: + r.processPodSelector(kres, o.Spec.Selector) + case *appsv1beta2.Deployment: + r.processPodSelector(kres, o.Spec.Selector) + case *extv1beta1.Deployment: + r.processPodSelector(kres, o.Spec.Selector) + case *appsv1.StatefulSet: + r.processPodSelector(kres, o.Spec.Selector) + case *appsv1beta2.StatefulSet: + r.processPodSelector(kres, o.Spec.Selector) + case *appsv1beta1.StatefulSet: + r.processPodSelector(kres, o.Spec.Selector) + case *appsv1.DaemonSet: + r.processPodSelector(kres, o.Spec.Selector) + case *batchv1.Job: + r.processPodSelector(kres, o.Spec.Selector) + default: + // Ignore - not a controller + log.Debugf("Ignoring: non-controller type: %s", reflect.TypeOf(o)) + } +} + +func (r *HelmRelease) processPodSelector(kres KubeResource, selector *metav1.LabelSelector) { + if selector == nil { + return + } + + qs := podSelectorToQueryString(selector) + + // Add a job to get the pods in this deployment + job := KubeResourceJob{ + ID: fmt.Sprintf("%s-%s#Pods", kres.Kind, kres.Metadata.Name), + Parent: fmt.Sprintf("%s-%s", kres.Kind, kres.Metadata.Name), + URL: fmt.Sprintf("/api/v1/namespaces/%s/pods%s", r.Namespace, qs), + Endpoint: r.Endpoint, + User: r.User, + Namespace: r.Namespace, + } + r.PodJobs[job.ID] = job +} + +// UpdatePods will run the jobs needed to get the pods +// This uses the selectors to find the pods - so new pods should be picked up +func (r *HelmRelease) UpdatePods(jetstream interfaces.PortalProxy) { + var jobs []KubeResourceJob + for _, job := range r.PodJobs { + jobs = append(jobs, job) + } + + pods := make(map[string]*KubeResource) + + runner := NewKubeAPIJob(jetstream, jobs) + res := runner.Run() + for _, j := range res { + var list v1.PodList + err := json.Unmarshal(j.Data, &list) + if err == nil { + for _, pod := range list.Items { + // Add a kube resource for the pod + res := KubeResource{ + Kind: "Pod", + APIVersion: "v1", + } + res.Metadata.Name = pod.Name + res.Manifest = false + + podCopy := &v1.Pod{} + *podCopy = pod + podCopy.Kind = "Pod" + podCopy.APIVersion = "v1" + res.Resource = podCopy + pods[res.getID()] = &res + + r.setResource(res) + r.processPodOwners(pod) + } + } + } + + // Now remove all pods that have not just been retrieved + // These are stale pods + for _, res := range r.Resources { + _, exists := pods[res.getID()] + if res.Kind == "Pod" && !exists { + r.deleteResource(res) + } + } +} + +// Pods can be owned by a ReplicaSet - these are not represented in the manifest, as they +// are created as part of the Deployment resource +// Look through the pod and add the ReplicaSets to the manifest +func (r *HelmRelease) processPodOwners(pod v1.Pod) { + for _, owner := range pod.ObjectMeta.OwnerReferences { + if owner.Kind == "ReplicaSet" { + // This is an incompelte ReplicaSet, but enough for us to use to go get more metadata + resource := appsv1.ReplicaSet{} + resource.TypeMeta = metav1.TypeMeta{ + Kind: owner.Kind, + APIVersion: owner.APIVersion, + } + resource.ObjectMeta = metav1.ObjectMeta{ + Name: owner.Name, + Namespace: pod.Namespace, + } + identifier := getResourceIdentifier(resource.TypeMeta, resource.ObjectMeta) + if _, ok := r.Resources[identifier]; !ok { + // Create a Kube Resource for the ReplicaSet + res := KubeResource{ + Kind: resource.Kind, + APIVersion: resource.APIVersion, + } + res.Metadata.Name = resource.Name + res.Manifest = false + res.Resource = &resource + r.setResource(res) + + r.addJobForResource(pod.Namespace, owner.Kind, owner.APIVersion, owner.Name) + } + } else { + log.Debugf("Unexpected Pod owner kind: %s", owner.Kind) + } + } +} + +func (r *HelmRelease) UpdateResources(jetstream interfaces.PortalProxy) { + // This will be an array of resources + runner := NewKubeAPIJob(jetstream, r.Jobs) + res := runner.Run() + for _, j := range res { + + // Add a kube resource + res := KubeResource{ + Kind: j.Kind, + APIVersion: j.APIVersion, + } + res.Metadata.Name = j.Name + + // If the status was 404, then we should remove the resource + if j.StatusCode == http.StatusNotFound { + log.Debugf("Resource has been deleted - removing: %s -> %s", j.Kind, j.Name) + r.deleteResource(res) + } + + // Manifest should carry over - indicates if the resource was in the Helm manifest + // Pods are an example of a reosurce which is not in the manifest + if existing, ok := r.Resources[res.getID()]; ok { + res.Manifest = existing.Manifest + } else { + res.Manifest = false + } + + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode(j.Data, nil, nil) + if err == nil { + res.Resource = obj + r.setResource(res) + } else { + // Just decode from Yaml - could be a CRD + var obj interface{} + if err := yaml.Unmarshal(j.Data, &obj); err == nil { + res.Resource = obj + r.setResource(res) + } else { + log.Error("Could not parse resource") + } + } + + // TODO: If the resource was a job, process the selector again + r.processController(res) + } +} + +func getRestURL(namespace, kind, apiVersion, name string) string { + var restURL string + base := "api" + if len(strings.Split(apiVersion, "/")) > 1 { + base = "apis" + + v, ok := resourcesWithoutStatus[kind] + if !ok || v { + name += "/status" + } + } + + kindPlural := pluralize(strings.ToLower(kind)) + if len(namespace) == 0 { + // This is not a namespaced resource + restURL = fmt.Sprintf("/%s/%s/%s/%s", base, apiVersion, kindPlural, name) + } else { + restURL = fmt.Sprintf("/%s/%s/namespaces/%s/%s/%s", base, apiVersion, namespace, kindPlural, name) + } + + return restURL +} + +func pluralize(resource string) string { + if strings.HasSuffix(resource, "y") { + return fmt.Sprintf("%sies", resource[:len(resource)-1]) + } + + return fmt.Sprintf("%ss", resource) +} diff --git a/src/jetstream/plugins/kubernetes/helm/release_test.go b/src/jetstream/plugins/kubernetes/helm/release_test.go new file mode 100644 index 0000000000..4b0fdbaf86 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/helm/release_test.go @@ -0,0 +1,29 @@ +package helm + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + + log "github.com/sirupsen/logrus" +) + +func TestPodSelector(t *testing.T) { + + Convey("TestPodSelector", t, func() { + + t1 := KubeDeploymentResource{} + t1.Spec.Selector.MatchLabels = make(map[string]string) + t1.Spec.Selector.MatchLabels["environemnt"] = "dev" + + res := podSelectorToQueryString(t1) + + log.Info(res) + + // So(ArrayContainsString(str1, "two"), ShouldBeTrue) + // So(ArrayContainsString(str1, "four"), ShouldBeFalse) + // So(ArrayContainsString(str1, ""), ShouldBeFalse) + // So(ArrayContainsString(nil, "test"), ShouldBeFalse) + }) + +} diff --git a/src/jetstream/plugins/kubernetes/helm/util.go b/src/jetstream/plugins/kubernetes/helm/util.go new file mode 100644 index 0000000000..803791f54d --- /dev/null +++ b/src/jetstream/plugins/kubernetes/helm/util.go @@ -0,0 +1,50 @@ +package helm + +import ( + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// See: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +func podSelectorToQueryString(selector *metav1.LabelSelector) string { + + qs := "?labelSelector=" + ml := "" + sep := "" + + // Match labels + for k, v := range selector.MatchLabels { + if len(ml) > 0 { + sep = "," + } + ml = fmt.Sprintf("%s%s%s%%3D%s", ml, sep, k, v) + } + + // Now add set based match expressions + for _, v := range selector.MatchExpressions { + if len(ml) > 0 { + sep = "," + } + ml = fmt.Sprintf("%s%s%s+%s+%%28%s%%s29", ml, sep, v.Key, v.Operator, strings.Join(v.Values, "%%2C")) + } + + if len(ml) > 0 { + return fmt.Sprintf("%s%s", qs, ml) + } + + return "" +} + +// Check that the selectors maps contains everything in the find map +func labelsMatch(find, selectors map[string]string) bool { + for k, v := range find { + if sv, ok := selectors[k]; ok { + if sv != v { + return false + } + } + } + return true +} diff --git a/src/jetstream/plugins/kubernetes/helm_client.go b/src/jetstream/plugins/kubernetes/helm_client.go new file mode 100644 index 0000000000..4d1cb2217b --- /dev/null +++ b/src/jetstream/plugins/kubernetes/helm_client.go @@ -0,0 +1,165 @@ +package kubernetes + +import ( + "errors" + "io/ioutil" + "os" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/discovery" + diskcached "k8s.io/client-go/discovery/cached/disk" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + + restclient "k8s.io/client-go/rest" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/kube" + "helm.sh/helm/v3/pkg/storage" + "helm.sh/helm/v3/pkg/storage/driver" + + // Import the OIDC auth plugin + _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" +) + +// HelmConfiguration stores any resources that need to be cleaned up after use +type HelmConfiguration struct { + Folder string +} + +// Cleanup any resources associated with the Helm configuration +func (f *HelmConfiguration) Cleanup() { + if len(f.Folder) > 0 { + os.RemoveAll(f.Folder) + } +} + +// The Helm API we use is not thead safe, so use a lock to make sure only one call at a time +var lock sync.Mutex + +// GetHelmConfiguration - gets a Helm V3 client for using it as a client library +func (c *KubernetesSpecification) GetHelmConfiguration(endpointGUID, userID, namespace string) (*action.Configuration, *HelmConfiguration, error) { + // Need to get a config object for the target endpoint + var p = c.portalProxy + + hc := &HelmConfiguration{} + + cnsiRecord, err := p.GetCNSIRecord(endpointGUID) + if err != nil { + return nil, hc, errors.New("Helm: Can not get endpoint record") + } + + tokenRecord, ok := p.GetCNSITokenRecord(endpointGUID, userID) + if !ok { + return nil, hc, errors.New("Helm: Can not get user token for endpoint") + } + + lock.Lock() + defer lock.Unlock() + + kubeconfigcontents, err := c.GetKubeConfigForEndpoint(cnsiRecord.APIEndpoint.String(), tokenRecord, namespace) + if err != nil { + log.Errorf("Helm: Could not get kubeconfig for endpoint: %s", err) + return nil, hc, errors.New("Can not get Kubernetes config for specified endpoint") + } + + // TODO: Some auth schemes needs to have the token refreshed - so we should do that first + // to ensure it is valid when we use it subsequently + + hc.Folder, err = ioutil.TempDir("", "helm-client-") + if err != nil { + log.Error("Unable to create temporary folder") + } + + rcg := newJetStreamRCGetter([]byte(kubeconfigcontents), hc.Folder, namespace) + + var nopLogger = func(a string, b ...interface{}) { + log.Debugf(a, b) + } + + var actionConfig action.Configuration + + kc := kube.New(rcg) + kc.Log = nopLogger + + clientset, err := kc.Factory.KubernetesClientSet() + if err != nil { + return nil, hc, err + } + + var store *storage.Storage + d := driver.NewSecrets(clientset.CoreV1().Secrets(namespace)) + d.Log = nopLogger + store = storage.Init(d) + + actionConfig.RESTClientGetter = rcg + actionConfig.KubeClient = kc + actionConfig.Releases = store + actionConfig.Log = nopLogger + + return &actionConfig, hc, nil +} + +type jetStreamRestClientGetter struct { + clientConfig clientcmd.ClientConfig + tempFolder string +} + +func newJetStreamRCGetter(kubeconfig []byte, tempFolder string, namespace string) *jetStreamRestClientGetter { + + clientConfig, err := clientcmd.NewClientConfigFromBytes(kubeconfig) + if err != nil { + log.Error(err) + } + + f := &jetStreamRestClientGetter{ + clientConfig: clientConfig, + tempFolder: tempFolder, + } + return f +} + +// ToRESTConfig returns restconfig +func (f *jetStreamRestClientGetter) ToRESTConfig() (*restclient.Config, error) { + return f.clientConfig.ClientConfig() +} + +// ToRawKubeConfigLoader binds config flag values to config overrides +// Returns an interactive clientConfig if the password flag is enabled, +// or a non-interactive clientConfig otherwise. +func (f *jetStreamRestClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { + return f.clientConfig +} + +// ToDiscoveryClient returns discovery client +func (f *jetStreamRestClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + config, err := f.ToRESTConfig() + if err != nil { + return nil, err + } + + // The more groups you have, the more discovery requests you need to make. + // given 25 groups (our groups + a few custom resources) with one-ish version each, discovery needs to make 50 requests + // double it just so we don't end up here again for a while. This config is only used for discovery. + config.Burst = 100 + + httpCacheDir := f.tempFolder + discoveryCacheDir := f.tempFolder + return diskcached.NewCachedDiscoveryClientForConfig(config, discoveryCacheDir, httpCacheDir, time.Duration(10*time.Minute)) +} + +// ToRESTMapper returns a mapper. +func (f *jetStreamRestClientGetter) ToRESTMapper() (meta.RESTMapper, error) { + discoveryClient, err := f.ToDiscoveryClient() + if err != nil { + return nil, err + } + + mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient) + expander := restmapper.NewShortcutExpander(mapper, discoveryClient) + return expander, nil +} diff --git a/src/jetstream/plugins/kubernetes/install_release.go b/src/jetstream/plugins/kubernetes/install_release.go new file mode 100644 index 0000000000..9f01979df2 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/install_release.go @@ -0,0 +1,231 @@ +package kubernetes + +import ( + "bytes" + "encoding/json" + "fmt" + + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + "sigs.k8s.io/yaml" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +const chartCollection = "charts" + +type installRequest struct { + Endpoint string `json:"endpoint"` + MonocularEndpoint string `json:"monocularEndpoint"` + Name string `json:"releaseName"` + Namespace string `json:"releaseNamespace"` + Values string `json:"values"` + ChartURL string `json:"chartUrl"` + Chart struct { + Name string `json:"chartName"` + Repository string `json:"repo"` + Version string `json:"version"` + } `json:"chart"` +} + +type upgradeRequest struct { + MonocularEndpoint string `json:"monocularEndpoint"` + Values string `json:"values"` + ChartURL string `json:"chartUrl"` + Chart struct { + Name string `json:"name"` + Repository string `json:"repo"` + Version string `json:"version"` + } `json:"chart"` + RestartPods bool `json:"restartPods"` +} + +// InstallRelease will install a Helm 3 release +func (c *KubernetesSpecification) InstallRelease(ec echo.Context) error { + bodyReader := ec.Request().Body + buf := new(bytes.Buffer) + buf.ReadFrom(bodyReader) + + var params installRequest + err := json.Unmarshal(buf.Bytes(), ¶ms) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not get Create Release Parameters: %v+", err) + } + + // Client must give us the download URL for the chart + if len(params.ChartURL) == 0 { + return interfaces.NewJetstreamErrorf("Client did not supply Chart download URL") + } + + chart, err := c.loadChart(params.ChartURL) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not load chart: %v+", err) + } + + endpointGUID := params.Endpoint + userGUID := ec.Get("user_id").(string) + + config, hc, err := c.GetHelmConfiguration(endpointGUID, userGUID, params.Namespace) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not get Helm Configuration for endpoint: %+v", err) + } + + defer hc.Cleanup() + + userSuppliedValues := map[string]interface{}{} + if err := yaml.Unmarshal([]byte(params.Values), &userSuppliedValues); err != nil { + // Could not parse the user's values + return interfaces.NewJetstreamErrorf("Could not parse values: %+v", err) + } + + // In Helm 3, the namespace must already exist + kubeClient, _ := c.GetConfigForEndpointUser(endpointGUID, userGUID) + clientset, _ := kubernetes.NewForConfig(kubeClient) + coreclient := clientset.CoreV1() + _, err = coreclient.Namespaces().Get(params.Namespace, metav1.GetOptions{}) + if err != nil { + return interfaces.NewJetstreamErrorf("Namespace '%s' does not exist", params.Namespace) + } + + // Check release name is valid and does not already exist + statusAction := action.NewStatus(config) + _, err = statusAction.Run(params.Name) + if err == nil { + return interfaces.NewJetstreamUserError("A Release with that name already exists - please choose another") + } + + install := action.NewInstall(config) + install.ReleaseName = params.Name + install.Namespace = params.Namespace + + release, err := install.Run(chart, userSuppliedValues) + if err != nil { + return interfaces.NewJetstreamError(fmt.Sprintf("Error installing %+v", err)) + } + + return ec.JSON(200, release) +} + +// Load the Helm chart for the given repository, name and version +func (c *KubernetesSpecification) loadChart(downloadURL string) (*chart.Chart, error) { + log.Debugf("Helm Chart Download URL: %s", downloadURL) + + // NWM: Should we look up Helm Repository endpoint and use the value from that + httpClient := c.portalProxy.GetHttpClient(false) + resp, err := httpClient.Get(downloadURL) + if err != nil { + return nil, fmt.Errorf("Could not download Chart Archive: %s", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("Could not download Chart Archive: %s", resp.Status) + } + + defer resp.Body.Close() + + return loader.LoadArchive(resp.Body) +} + +// DeleteRelease will delete a release +func (c *KubernetesSpecification) DeleteRelease(ec echo.Context) error { + endpointGUID := ec.Param("endpoint") + releaseName := ec.Param("name") + namespace := ec.Param("namespace") + + userGUID := ec.Get("user_id").(string) + + config, hc, err := c.GetHelmConfiguration(endpointGUID, userGUID, namespace) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not get Helm Configuration for endpoint: %+v", err) + } + + defer hc.Cleanup() + + uninstall := action.NewUninstall(config) + deleteResponse, err := uninstall.Run(releaseName) + if err != nil { + return interfaces.NewJetstreamError("Could not delete Helm Release") + } + + return ec.JSON(200, deleteResponse) +} + +// GetReleaseHistory will get the history for a release +func (c *KubernetesSpecification) GetReleaseHistory(ec echo.Context) error { + endpointGUID := ec.Param("endpoint") + releaseName := ec.Param("name") + namespace := ec.Param("namespace") + + userGUID := ec.Get("user_id").(string) + + config, hc, err := c.GetHelmConfiguration(endpointGUID, userGUID, namespace) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not get Helm Configuration for endpoint: %+v", err) + } + + defer hc.Cleanup() + + history := action.NewHistory(config) + historyResponse, err := history.Run(releaseName) + if err != nil { + return interfaces.NewJetstreamError("Could not get history for the Helm Release") + } + + return ec.JSON(200, historyResponse) +} + +// UpgradeRelease will upgrade the specified release +func (c *KubernetesSpecification) UpgradeRelease(ec echo.Context) error { + endpointGUID := ec.Param("endpoint") + releaseName := ec.Param("name") + namespace := ec.Param("namespace") + + userGUID := ec.Get("user_id").(string) + + bodyReader := ec.Request().Body + buf := new(bytes.Buffer) + buf.ReadFrom(bodyReader) + + var params upgradeRequest + err := json.Unmarshal(buf.Bytes(), ¶ms) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not get Upgrade Release Parameters: %+v", err) + } + + // Client must give us the download URL for the chart + if len(params.ChartURL) == 0 { + return interfaces.NewJetstreamErrorf("Client did not supply Chart download URL") + } + + chart, err := c.loadChart(params.ChartURL) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not load chart for upgrade: %+v", err) + } + + config, hc, err := c.GetHelmConfiguration(endpointGUID, userGUID, namespace) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not get Helm Configuration for endpoint: %+v", err) + } + + defer hc.Cleanup() + + userSuppliedValues := map[string]interface{}{} + if err := yaml.Unmarshal([]byte(params.Values), &userSuppliedValues); err != nil { + // Could not parse the user's values + return interfaces.NewJetstreamErrorf("Could not parse values: %+v", err) + } + + upgrade := action.NewUpgrade(config) + upgradeResponse, err := upgrade.Run(releaseName, chart, userSuppliedValues) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not upgrade Helm Release: %+v", err) + } + + return ec.JSON(200, upgradeResponse) +} diff --git a/src/jetstream/plugins/kubernetes/kube_dashboard.go b/src/jetstream/plugins/kubernetes/kube_dashboard.go new file mode 100644 index 0000000000..121d31f461 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/kube_dashboard.go @@ -0,0 +1,142 @@ +package kubernetes + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + "k8s.io/client-go/rest" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/dashboard" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +// Get the config for the endpoint +func (k *KubernetesSpecification) getConfig(cnsiRecord *interfaces.CNSIRecord, tokenRecord *interfaces.TokenRecord) (*rest.Config, error) { + masterURL := cnsiRecord.APIEndpoint.String() + return k.GetConfigForEndpoint(masterURL, *tokenRecord) +} + +// Proxy the request +func (k *KubernetesSpecification) kubeDashboardProxy(c echo.Context) error { + log.Debug("kubeDashboardTest request") + var p = k.portalProxy + + cnsiGUID := c.Param("guid") + userGUID := c.Get("user_id").(string) + + cnsiRecord, err := p.GetCNSIRecord(cnsiGUID) + if err != nil { + // TODO: Use sendError + return errors.New("Could not get endpoint information") + } + + // Get token for this users + tokenRec, ok := p.GetCNSITokenRecord(cnsiGUID, userGUID) + if !ok { + // TODO: Use sendError + return errors.New("Could not get token") + } + + config, err := k.getConfig(&cnsiRecord, &tokenRec) + if err != nil { + // TODO: Use sendError + return errors.New("Could not get config for this auth type") + } + + return dashboard.KubeDashboardProxy(c, p, config) +} + +// Determine if the specified Kube endpoint has the dashboard installed and ready +func (k *KubernetesSpecification) kubeDashboardStatus(c echo.Context) error { + var p = k.portalProxy + endpointGUID := c.Param("guid") + userGUID := c.Get("user_id").(string) + + // Don't need the token if we're just checking status + status, _ := dashboard.KubeDashboardStatus(p, endpointGUID, userGUID, false) + jsonString, err := json.Marshal(status) + if err != nil { + return interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Could not Kubernetes Dashboard status", + "Could not Kubernetes Dashboard status") + } + + c.Response().Header().Set("Content-Type", "application/json") + c.Response().Write(jsonString) + return nil +} + +// Login to the kubernetes dashboard and then redirect to the UI +func (k *KubernetesSpecification) kubeDashboardLogin(c echo.Context) error { + var p = k.portalProxy + err := dashboard.KubeDashboardLogin(c, p) + return err +} + +// Creates service account for dashboard access +func (k *KubernetesSpecification) kubeDashboardCreateServiceAccount(c echo.Context) error { + var p = k.portalProxy + endpointGUID := c.Param("guid") + userGUID := c.Get("user_id").(string) + + err := dashboard.CreateServiceAccount(p, endpointGUID, userGUID) + if err != nil { + return interfaces.NewHTTPShadowError(http.StatusInternalServerError, err.Error(), err.Error()) + } + + c.Response().Header().Set("Content-Type", "application/json") + c.Response().Write([]byte("{ \"created\": true }")) + return nil +} + +// Delete service account used for Dashboard access +func (k *KubernetesSpecification) kubeDashboardDeleteServiceAccount(c echo.Context) error { + var p = k.portalProxy + endpointGUID := c.Param("guid") + userGUID := c.Get("user_id").(string) + + err := dashboard.DeleteServiceAccount(p, endpointGUID, userGUID) + if err != nil { + return interfaces.NewHTTPShadowError(http.StatusInternalServerError, err.Error(), err.Error()) + } + + c.Response().Header().Set("Content-Type", "application/json") + c.Response().Write([]byte("{ \"deleted\": true }")) + return nil +} + +// Install dashboard in a cluster +func (k *KubernetesSpecification) kubeDashboardInstallDashboard(c echo.Context) error { + var p = k.portalProxy + endpointGUID := c.Param("guid") + userGUID := c.Get("user_id").(string) + + err := dashboard.InstallDashboard(p, endpointGUID, userGUID) + if err != nil { + return interfaces.NewHTTPShadowError(http.StatusInternalServerError, err.Error(), err.Error()) + } + + c.Response().Header().Set("Content-Type", "application/json") + c.Response().Write([]byte("{ \"installation\": true }")) + return nil +} + +// Delete dashboard in a cluster +func (k *KubernetesSpecification) kubeDashboardDeleteDashboard(c echo.Context) error { + var p = k.portalProxy + endpointGUID := c.Param("guid") + userGUID := c.Get("user_id").(string) + + err := dashboard.DeleteDashboard(p, endpointGUID, userGUID) + if err != nil { + return interfaces.NewHTTPShadowError(http.StatusInternalServerError, err.Error(), err.Error()) + } + + c.Response().Header().Set("Content-Type", "application/json") + c.Response().Write([]byte("{ \"deleted\": true }")) + return nil +} diff --git a/src/jetstream/plugins/kubernetes/list_releases.go b/src/jetstream/plugins/kubernetes/list_releases.go new file mode 100644 index 0000000000..10be828efc --- /dev/null +++ b/src/jetstream/plugins/kubernetes/list_releases.go @@ -0,0 +1,63 @@ +package kubernetes + +import ( + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + + "helm.sh/helm/v3/pkg/action" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" +) + +// ListReleases will list the helm releases for all endpoints +func (c *KubernetesSpecification) ListReleases(ec echo.Context) error { + log.Debug("ListReleases") + + // Need to get a config object for the target endpoint + // endpointGUID := ec.Param("endpoint") + userID := ec.Get("user_id").(string) + + resp, err := c.ProxyKubernetesAPI(userID, c.listReleases) + if err != nil { + return err + } + return ec.JSON(200, resp) +} + +// List releases for a single endpoint +func (c *KubernetesSpecification) listReleases(ep *interfaces.ConnectedEndpoint, done chan KubeProxyResponse) { + + response := KubeProxyResponse{ + Endpoint: ep.GUID, + Result: nil, + } + + log.Debugf("listReleases: START: %s", ep.GUID) + + config, hc, err := c.GetHelmConfiguration(ep.GUID, ep.Account, "") + if err != nil { + log.Errorf("Helm: ListReleases could not get a Helm Configuration: %s", err) + done <- response + return + } + + defer hc.Cleanup() + + list := action.NewList(config) + + log.Debugf("listReleases: REQUEST: %s", ep.GUID) + + res, err := list.Run() + if err != nil { + log.Debugf("listReleases: ERROR: %s", ep.GUID) + log.Error(err) + + done <- response + return + } + + log.Debugf("listReleases: OK: %s", ep.GUID) + response.Result = res + + done <- response +} diff --git a/src/jetstream/plugins/kubernetes/main.go b/src/jetstream/plugins/kubernetes/main.go new file mode 100644 index 0000000000..ac128e98df --- /dev/null +++ b/src/jetstream/plugins/kubernetes/main.go @@ -0,0 +1,304 @@ +package kubernetes + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + + "errors" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/auth" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/terminal" +) + +// KubernetesSpecification is the endpoint that adds Kubernetes support to the backend +type KubernetesSpecification struct { + portalProxy interfaces.PortalProxy + endpointType string + kubeTerminal *terminal.KubeTerminal +} + +type KubeStatus struct { + Kind string `json:"kind"` + ApiVersion string `json:"apiVersion"` + Metadata interface{} `json:"metadata"` + Status string `json:"status"` + Message string `json:"message"` + Reason string `json:"reason"` + Details interface{} `json:"details"` + Code int `json:"code"` +} + +type kubeErrorStatus struct { + Type string `json:"type"` + Status string `json:"status"` + Message string `json:"message"` +} + +type KubeAPIVersions struct { + Kind string `json:"kind"` + Versions []string `json:"versions"` + ServerAddressByClientCIDRs []interface{} `json:"serverAddressByClientCIDRs"` +} + +const ( + kubeEndpointType = "k8s" + defaultKubeClientID = "K8S_CLIENT" + + // kubeDashboardPluginConfigSetting is config value sent back to the client to indicate if the kube dashboard ie enabled + kubeDashboardPluginConfigSetting = "kubeDashboardEnabled" + // kubeTerminalPluginConfigSetting is config value sent back to the client to indicate if the kube terminal is enabled + kubeTerminalPluginConfigSetting = "kubeTerminalEnabled" +) + +func init() { + interfaces.AddPlugin("kubernetes", nil, Init) +} + +// Init creates a new instance of the Kubernetes plugin +func Init(portalProxy interfaces.PortalProxy) (interfaces.StratosPlugin, error) { + kubeTerminal := terminal.NewKubeTerminal(portalProxy) + kube := &KubernetesSpecification{portalProxy: portalProxy, endpointType: kubeEndpointType, kubeTerminal: kubeTerminal} + if kubeTerminal != nil { + kubeTerminal.Kube = kube + } + return kube, nil +} + +func (c *KubernetesSpecification) GetEndpointPlugin() (interfaces.EndpointPlugin, error) { + return c, nil +} + +func (c *KubernetesSpecification) GetRoutePlugin() (interfaces.RoutePlugin, error) { + return c, nil +} + +func (c *KubernetesSpecification) GetMiddlewarePlugin() (interfaces.MiddlewarePlugin, error) { + return nil, errors.New("Not implemented!") +} + +func (c *KubernetesSpecification) GetType() string { + return kubeEndpointType +} + +func (c *KubernetesSpecification) GetClientId() string { + return c.portalProxy.Env().String(defaultKubeClientID, "k8s") +} + +func (c *KubernetesSpecification) Register(echoContext echo.Context) error { + log.Debug("Kubernetes Register...") + return c.portalProxy.RegisterEndpoint(echoContext, c.Info) +} + +func (c *KubernetesSpecification) Validate(userGUID string, cnsiRecord interfaces.CNSIRecord, tokenRecord interfaces.TokenRecord) error { + log.Debugf("Validating Kubernetes endpoint connection for user: %s", userGUID) + response, err := c.portalProxy.DoProxySingleRequest(cnsiRecord.GUID, userGUID, "GET", "api/v1/pods?limit=1", nil, nil) + if err != nil { + return err + } + + if response.StatusCode >= 400 { + if response.Error != nil { + return fmt.Errorf("Unable to connect to endpoint: %s", response.Error.Error()) + } + return fmt.Errorf("Unable to connect to endpoint: %d => %s", response.StatusCode, response.Status) + } + + return nil +} + +func (c *KubernetesSpecification) Connect(ec echo.Context, cnsiRecord interfaces.CNSIRecord, userID string) (*interfaces.TokenRecord, bool, error) { + log.Debug("Kubernetes Connect...") + + connectType := ec.FormValue("connect_type") + + var authProvider = c.FindAuthProvider(connectType) + if authProvider == nil { + return nil, false, errors.New("Unsupported Auth connection type for Kubernetes endpoint") + } + + tokenRecord, _, err := authProvider.FetchToken(cnsiRecord, ec) + if err != nil { + return nil, false, err + } + + return tokenRecord, false, nil +} + +// Init the Kubernetes Jetstream plugin +func (c *KubernetesSpecification) Init() error { + + c.AddAuthProvider(auth.InitGKEKubeAuth(c.portalProxy)) + c.AddAuthProvider(auth.InitAWSKubeAuth(c.portalProxy)) + c.AddAuthProvider(auth.InitCertKubeAuth(c.portalProxy)) + c.AddAuthProvider(auth.InitAzureKubeAuth(c.portalProxy)) + c.AddAuthProvider(auth.InitOIDCKubeAuth(c.portalProxy)) + c.AddAuthProvider(auth.InitKubeConfigAuth(c.portalProxy)) + c.AddAuthProvider(auth.InitKubeTokenAuth(c.portalProxy)) + c.AddAuthProvider(auth.InitKubeBasicAuth(c.portalProxy)) + + // Kube dashboard is enabled by Tech Preview mode + c.portalProxy.GetConfig().PluginConfig[kubeDashboardPluginConfigSetting] = strconv.FormatBool(c.portalProxy.GetConfig().EnableTechPreview) + + // Kube terminal is enabled by Tech Preview mode + c.portalProxy.GetConfig().PluginConfig[kubeTerminalPluginConfigSetting] = strconv.FormatBool(c.portalProxy.GetConfig().EnableTechPreview) + + // Kick off the cleanup of any old kube terminal pods + if c.kubeTerminal != nil { + c.kubeTerminal.StartCleanup() + } + + return nil +} + +func (c *KubernetesSpecification) AddAdminGroupRoutes(echoGroup *echo.Group) { + echoGroup.GET("/kube/cert", c.RequiresCert) +} + +func (c *KubernetesSpecification) AddSessionGroupRoutes(echoGroup *echo.Group) { + + // Kubernetes Dashboard Proxy + echoGroup.Any("/apps/kubedash/ui/:guid/*", c.kubeDashboardProxy) + + echoGroup.GET("/kubedash/:guid/login", c.kubeDashboardLogin) + echoGroup.GET("/kubedash/:guid/status", c.kubeDashboardStatus) + + echoGroup.POST("/kubedash/:guid/serviceAccount", c.kubeDashboardCreateServiceAccount) + echoGroup.DELETE("/kubedash/:guid/serviceAccount", c.kubeDashboardDeleteServiceAccount) + + echoGroup.POST("/kubedash/:guid/installation", c.kubeDashboardInstallDashboard) + echoGroup.DELETE("/kubedash/:guid/installation", c.kubeDashboardDeleteDashboard) + + // Helm Routes + echoGroup.GET("/helm/releases", c.ListReleases) + echoGroup.POST("/helm/install", c.InstallRelease) + echoGroup.DELETE("/helm/releases/:endpoint/:namespace/:name", c.DeleteRelease) + echoGroup.GET("/helm/releases/:endpoint/:namespace/:name/history", c.GetReleaseHistory) + echoGroup.GET("/helm/releases/:endpoint/:namespace/:name/status", c.GetReleaseStatus) + echoGroup.GET("/helm/releases/:endpoint/:namespace/:name", c.GetRelease) + echoGroup.POST("/helm/releases/:endpoint/:namespace/:name", c.UpgradeRelease) + + // Kube Terminal + if c.kubeTerminal != nil { + echoGroup.GET("/kubeterminal/:guid", c.kubeTerminal.Start) + } +} + +func (c *KubernetesSpecification) Info(apiEndpoint string, skipSSLValidation bool) (interfaces.CNSIRecord, interface{}, error) { + + log.Debug("Kubernetes Info") + var v2InfoResponse interfaces.V2Info + var newCNSI interfaces.CNSIRecord + + newCNSI.CNSIType = kubeEndpointType + + _, err := url.Parse(apiEndpoint) + if err != nil { + return newCNSI, nil, err + } + + log.Debug("Request Kube API Versions") + var httpClient = c.portalProxy.GetHttpClient(skipSSLValidation) + res, err := httpClient.Get(apiEndpoint + "/api") + if err != nil { + // This should ultimately catch 503 cert errors + return newCNSI, nil, err + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return newCNSI, nil, err + } + + if res.StatusCode < 400 { + // No auth on kube set up, expect a successful APIVersions response - KubeAPIVersions + log.Debug("Kube API Versions Succeeded") + apiVersions := KubeAPIVersions{} + err := json.Unmarshal(body, &apiVersions) + if err != nil { + return newCNSI, nil, fmt.Errorf("Failed to parse output as kube kind APIVersions: %+v", err) + } + if apiVersions.Kind != "APIVersions" { + return newCNSI, nil, fmt.Errorf("Failed to parse output as kube kind APIVersions: %+v", apiVersions) + } + } else if res.StatusCode == 403 || res.StatusCode == 401 { + err := parseErrorResponse(body) + if err != nil { + return newCNSI, nil, fmt.Errorf("Failed to parse output as kube kind status: %+v", err) + } + } else { + return newCNSI, nil, fmt.Errorf("Dissallowed response code from `/api` call: %+v", res.StatusCode) + } + + log.Debug("Kube API Versions Acceptable Response") + newCNSI.TokenEndpoint = apiEndpoint + newCNSI.AuthorizationEndpoint = apiEndpoint + + return newCNSI, v2InfoResponse, nil +} + +func parseErrorResponse(body []byte) error { + kubeStatus := KubeStatus{} + err := json.Unmarshal(body, &kubeStatus) + if err == nil { + // Expect a json message with a status + if kubeStatus.Kind == "Status" { + return nil + } + } + + // Try the other format + errorStatus := kubeErrorStatus{} + err = json.Unmarshal(body, &errorStatus) + if err == nil { + // Expect the type to be error + if errorStatus.Type == "error" { + return nil + } + } + + // Not one of the types we recognise + + log.Debug(string(body)) + return errors.New("Could not understand response from Kubernetes endpoint") +} + +func (c *KubernetesSpecification) UpdateMetadata(info *interfaces.Info, userGUID string, echoContext echo.Context) { +} + +func (c *KubernetesSpecification) RequiresCert(ec echo.Context) error { + url := ec.QueryParam("url") + + log.Debug("Request Kube API Versions") + var httpClient = c.portalProxy.GetHttpClient(false) + _, err := httpClient.Get(url + "/api") + var response struct { + Status int + Required bool + Error bool + Message string + } + if err != nil { + if strings.Contains(err.Error(), "x509: certificate") { + response.Status = http.StatusOK + response.Required = true + } else { + response.Status = http.StatusInternalServerError + response.Error = true + response.Message = fmt.Sprintf("Failed to validate Kube certificate requirement: %+v", err) + } + } else { + response.Status = http.StatusOK + response.Required = false + } + return ec.JSON(response.Status, response) +} diff --git a/src/jetstream/plugins/kubernetes/terminal/cleanup.go b/src/jetstream/plugins/kubernetes/terminal/cleanup.go new file mode 100644 index 0000000000..621921e1e5 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/terminal/cleanup.go @@ -0,0 +1,83 @@ +package terminal + +import ( + "math/rand" + "strconv" + "time" + + log "github.com/sirupsen/logrus" + + metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Wait time in minutes after random intiial wait +const waitPeriod = 10 + +// StartCleanup starts a background routine to cleanup orphaned pods +func (k *KubeTerminal) StartCleanup() { + go k.cleanup() +} + +func (k *KubeTerminal) cleanup() { + // Use a random initial wait before cleaning up + // If we had more than one backend, this helps to ensure they are not all trying to cleanup at the same time + wait := rand.Intn(30) + log.Debug("Kubernetes Terminal cleanup will start in %d minutes", wait) + + for { + time.Sleep(time.Duration(wait) * time.Minute) + log.Debug("Cleaning up stale Kubernetes Terminal pods and secrets ...") + + // Get all pods with a given label + podClient, secretClient, err := k.getClients() + if err == nil { + // Only want the pods that are kube terminals + options := metaV1.ListOptions{} + options.LabelSelector = "stratos-role=kube-terminal" + pods, err := podClient.List(options) + if err == nil { + for _, pod := range pods.Items { + if sessionID, ok := pod.Annotations[stratosSessionAnnotation]; ok { + i, err := strconv.Atoi(sessionID) + if err == nil { + isValid, err := k.PortalProxy.GetSessionDataStore().IsValidSession(i) + if err == nil && !isValid { + log.Debugf("Deleting pod %s", pod.Name) + podClient.Delete(pod.Name, nil) + } + } + } + } + } else { + log.Debug("Kube Terminal Cleanup: Could not get pods") + log.Debug(err) + } + + // Only want the secrets that are kube terminals + secrets, err := secretClient.List(options) + if err == nil { + for _, secret := range secrets.Items { + if sessionID, ok := secret.Annotations[stratosSessionAnnotation]; ok { + i, err := strconv.Atoi(sessionID) + if err == nil { + isValid, err := k.PortalProxy.GetSessionDataStore().IsValidSession(i) + if err == nil && !isValid { + log.Debugf("Deleting secret %s", secret.Name) + secretClient.Delete(secret.Name, nil) + } + } + } + } + } else { + log.Warn("Kube Terminal Cleanup: Could not get secrets") + log.Warn(err) + } + + } else { + log.Warn("Kube Terminal Cleanup: Could not get clients") + log.Warn(err) + } + + wait = waitPeriod + } +} diff --git a/src/jetstream/plugins/kubernetes/terminal/helpers.go b/src/jetstream/plugins/kubernetes/terminal/helpers.go new file mode 100644 index 0000000000..fd49e42e24 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/terminal/helpers.go @@ -0,0 +1,284 @@ +package terminal + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/labstack/echo/v4" + uuid "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/auth" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + + "github.com/gorilla/websocket" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + corev1 "k8s.io/client-go/kubernetes/typed/core/v1" +) + +const ( + helmEndpointType = "helm" + helmRepoEndpointType = "repo" + startingProgressMessage = "Waiting for Kubernetes Terminal to start up ..." +) + +// PodCreationData stores the clients and names used to create pod and secret +type PodCreationData struct { + Namespace string + PodClient corev1.PodInterface + SecretClient corev1.SecretInterface + PodName string + SecretName string +} + +func (k *KubeTerminal) getClients() (corev1.PodInterface, corev1.SecretInterface, error) { + + // Create a token record for Token Auth using the Service Account token + token := auth.NewKubeTokenAuthTokenRecord(k.PortalProxy, string(k.Token)) + config, err := k.Kube.GetConfigForEndpoint(k.APIServer, *token) + if err != nil { + return nil, nil, errors.New("Can not get Kubernetes config for specified endpoint") + } + kubeClient, err := kubernetes.NewForConfig(config) + if err != nil { + log.Error("Could not get kube client") + return nil, nil, err + } + + podClient := kubeClient.CoreV1().Pods(k.Namespace) + secretsClient := kubeClient.CoreV1().Secrets(k.Namespace) + return podClient, secretsClient, nil +} + +// Create a pod for a user to run the Kube terminal +func (k *KubeTerminal) createPod(c echo.Context, kubeConfig, kubeVersion string, ws *websocket.Conn) (*PodCreationData, error) { + // Unique ID for the secret and pod name + id := uuid.NewV4().String() + id = strings.ReplaceAll(id, "-", "") + // Names for the secret and pod + secretName := fmt.Sprintf("terminal-%s", id) + podName := secretName + podClient, secretClient, err := k.getClients() + result := &PodCreationData{} + result.Namespace = k.Namespace + + // Get the session ID + sessionID := "" + session, err := k.PortalProxy.GetSession(c) + if err == nil { + sessionID = session.ID + } + + // Create the secret + secretSpec := &v1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: k.Namespace, + }, + Type: "Opaque", + } + + sendProgressMessage(ws, startingProgressMessage) + + setResourcMetadata(&secretSpec.ObjectMeta, sessionID) + + secretSpec.Data = make(map[string][]byte) + secretSpec.Data["kubeconfig"] = []byte(kubeConfig) + + // Get Helm repository script if we have Helm repositories + helmSetup := getHelmRepoSetupScript(k.PortalProxy) + if len(helmSetup) > 0 { + secretSpec.Data["helm-setup"] = []byte(helmSetup) + } + + sendProgressMessage(ws, startingProgressMessage) + _, err = secretClient.Create(secretSpec) + if err != nil { + log.Warnf("Kubernetes Terminal: Unable to create Secret: %+v", err) + return result, err + } + + result.SecretClient = secretClient + result.SecretName = secretName + + // Pod + podSpec := &v1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: k.Namespace, + }, + } + + // Label the pod, so we can find it as a kube terminal pod + setResourcMetadata(&podSpec.ObjectMeta, sessionID) + + // Don't mount a service account token + off := false + podSpec.Spec.AutomountServiceAccountToken = &off + podSpec.Spec.EnableServiceLinks = &off + podSpec.Spec.RestartPolicy = "Never" + podSpec.Spec.DNSPolicy = "Default" + + volumeMountsSpec := make([]v1.VolumeMount, 1) + volumeMountsSpec[0].Name = "kubeconfig" + volumeMountsSpec[0].MountPath = "/home/stratos/.stratos" + volumeMountsSpec[0].ReadOnly = true + + containerSpec := make([]v1.Container, 1) + containerSpec[0].Name = consoleContainerName + containerSpec[0].Image = k.Image + containerSpec[0].ImagePullPolicy = "Always" + containerSpec[0].VolumeMounts = volumeMountsSpec + + // Add env var for kube version + containerSpec[0].Env = make([]v1.EnvVar, 1) + containerSpec[0].Env[0].Name = "K8S_VERSION" + containerSpec[0].Env[0].Value = kubeVersion + + podSpec.Spec.Containers = containerSpec + + volumesSpec := make([]v1.Volume, 1) + volumesSpec[0].Name = "kubeconfig" + volumesSpec[0].Secret = &v1.SecretVolumeSource{ + SecretName: secretName, + } + podSpec.Spec.Volumes = volumesSpec + + sendProgressMessage(ws, startingProgressMessage) + + // Create a new pod + pod, err := podClient.Create(podSpec) + if err != nil { + log.Warnf("Kubernetes Terminal: Unable to create Pod: %+v", err) + // Secret will get cleaned up by caller + return result, err + } + + result.PodClient = podClient + result.PodName = podName + + // Wait for the pod to be running + timeout := 60 + statusOptions := metav1.GetOptions{} + for { + // This ensures we keep the web socket alive while the container is creating + sendProgressMessage(ws, startingProgressMessage) + status, err := podClient.Get(pod.Name, statusOptions) + if err == nil && status.Status.Phase == "Running" { + break + } + + timeout = timeout - 1 + if timeout == 0 { + err = errors.New("Timed out waiting for pod to enter ready state") + break + } + + // Sleep + time.Sleep(1500 * time.Millisecond) + } + + return result, err +} + +func setResourcMetadata(metadata *metav1.ObjectMeta, sessionID string) { + // Label the kubeerntes resource, so we can find it as a kube terminal pod + metadata.Labels = make(map[string]string) + metadata.Labels[stratosRoleLabel] = stratosKubeTerminalRole + metadata.Annotations = make(map[string]string) + if len(sessionID) > 0 { + metadata.Annotations[stratosSessionAnnotation] = sessionID + } +} + +// Cleanup the pod and secret +func (k *KubeTerminal) cleanupPodAndSecret(podData *PodCreationData) error { + if podData == nil { + // Already been cleaned up + return nil + } + + if len(podData.PodName) > 0 { + //captureBashHistory(podData) + podData.PodClient.Delete(podData.PodName, nil) + } + + if len(podData.SecretName) > 0 { + podData.SecretClient.Delete(podData.SecretName, nil) + } + + return nil +} + +func getHelmRepoSetupScript(portalProxy interfaces.PortalProxy) string { + str := "" + + // Get all of the helm endpoints + endpoints, err := portalProxy.ListEndpoints() + if err != nil { + log.Error("Can not list Helm Repository endpoints") + return str + } + + for _, ep := range endpoints { + if ep.CNSIType == helmEndpointType && ep.SubType == helmRepoEndpointType { + // Remove spaces from the name + name := strings.ReplaceAll(ep.Name, " ", "_") + str += fmt.Sprintf("helm repo add %s %s > /dev/null\n", name, ep.APIEndpoint) + } + } + + return str +} + +func sendProgressMessage(ws *websocket.Conn, progressMsg string) { + // Send a message to say that we are creating the pod + msg := fmt.Sprintf("\033]2;%s\007", progressMsg) + bytes := fmt.Sprintf("% x\n", []byte(msg)) + if err := ws.WriteMessage(websocket.TextMessage, []byte(bytes)); err != nil { + log.Error("Could not send message to client to indicate terminal is starting") + } +} + +func (k *KubeTerminal) getKubeVersion(endpointID, userID string) (string, error) { + response, err := k.PortalProxy.DoProxySingleRequest(endpointID, userID, "GET", "/api/v1/nodes", nil, nil) + if err != nil || response.StatusCode != 200 { + return "", errors.New("Could not fetch node list") + } + + var nodes v1.NodeList + err = json.Unmarshal(response.Response, &nodes) + if err != nil { + return "", errors.New("Could not unmarshal node list") + } + + if len(nodes.Items) > 0 { + // Get the version number - remove any 'v' perfix or '+' suffix + version := nodes.Items[0].Status.NodeInfo.KubeletVersion + reg, err := regexp.Compile("[^0-9\\.]+") + if err == nil { + version = reg.ReplaceAllString(version, "") + } + parts := strings.Split(version, ".") + if len(parts) > 1 { + v := fmt.Sprintf("%s.%s", parts[0], parts[1]) + return v, nil + } + } + + return "", errors.New("Can not get Kubernetes version") +} diff --git a/src/jetstream/plugins/kubernetes/terminal/start.go b/src/jetstream/plugins/kubernetes/terminal/start.go new file mode 100644 index 0000000000..1eefe6de69 --- /dev/null +++ b/src/jetstream/plugins/kubernetes/terminal/start.go @@ -0,0 +1,230 @@ +package terminal + +import ( + "crypto/tls" + "errors" + "fmt" + + //"encoding/base64" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + + "github.com/gorilla/websocket" +) + +// TTY Resize, see: https://gitlab.cncf.ci/kubernetes/kubernetes/commit/3b21a9901bcd48bb452d3bf1a0cddc90dae142c4#9691a2f9b9c30711f0397221db0b9ac55ab0e2d1 + +// Allow connections from any Origin +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// KeyCode - JSON object that is passed from the front-end to notify of a key press or a term resize +type KeyCode struct { + Key string `json:"key"` + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +type terminalSize struct { + Width uint16 + Height uint16 +} + +const ( + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = (pongWait * 9) / 10 + + // Time to wait before force close on connection. + closeGracePeriod = 10 * time.Second +) + +// Start handles web-socket request to launch a Kubernetes Terminal +func (k *KubeTerminal) Start(c echo.Context) error { + log.Debug("Kube Terminal start request") + + endpointGUID := c.Param("guid") + userGUID := c.Get("user_id").(string) + + cnsiRecord, err := k.PortalProxy.GetCNSIRecord(endpointGUID) + if err != nil { + return errors.New("Could not get endpoint information") + } + + // Get token for this user + tokenRecord, ok := k.PortalProxy.GetCNSITokenRecord(endpointGUID, userGUID) + if !ok { + return errors.New("Could not get token") + } + + // This is the kube config for the kubernetes endpoint that we want configured in the Terminal + kubeConfig, err := k.Kube.GetKubeConfigForEndpoint(cnsiRecord.APIEndpoint.String(), tokenRecord, "") + if err != nil { + return errors.New("Can not get Kubernetes config for specified endpoint") + } + + // Determine the Kubernetes version + version, _ := k.getKubeVersion(endpointGUID, userGUID) + log.Debugf("Kubernetes Version: %s", version) + + // Upgrade the web socket for the incoming request + ws, pingTicker, err := interfaces.UpgradeToWebSocket(c) + if err != nil { + return err + } + defer ws.Close() + defer pingTicker.Stop() + + // At this point we aer using web sockets, so we can not return errors to the client as the connection + // has been upgraded to a web socket + + // We are now in web socket land - we don't want any middleware to change the HTTP response + c.Set("Stratos-WebSocket", "true") + + // Send a message to say that we are creating the pod + sendProgressMessage(ws, "Launching Kubernetes Terminal ... one moment please") + + podData, err := k.createPod(c, kubeConfig, version, ws) + + // Clear progress message + sendProgressMessage(ws, "") + + if err != nil { + log.Errorf("Kubernetes Terminal: Error creating secret or pod: %+v", err) + k.cleanupPodAndSecret(podData) + + // Send error message + sendProgressMessage(ws, "!"+err.Error()) + return nil + } + + // API Endpoint to SSH/exec into a container + target := fmt.Sprintf("%s/api/v1/namespaces/%s/pods/%s/exec?command=/bin/bash&stdin=true&stderr=true&stdout=true&tty=true", k.APIServer, k.Namespace, podData.PodName) + + dialer := &websocket.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + if strings.HasPrefix(target, "https://") { + target = "wss://" + target[8:] + } else { + target = "ws://" + target[7:] + } + + header := &http.Header{} + header.Add("Authorization", fmt.Sprintf("Bearer %s", string(k.Token))) + wsConn, _, err := dialer.Dial(target, *header) + + if err == nil { + defer wsConn.Close() + } + + if err != nil { + k.cleanupPodAndSecret(podData) + log.Warn("Kube Terminal: Could not connect to pod") + // No point returning an error - we've already upgraded to web sockets, so we can't use the HTTP response now + return nil + } + + stdoutDone := make(chan bool) + go pumpStdout(ws, wsConn, stdoutDone) + go ping(ws, stdoutDone) + + // If the downstream connection is closed, close the other web socket as well + ws.SetCloseHandler(func(code int, text string) error { + wsConn.Close() + // Cleanup + k.cleanupPodAndSecret(podData) + podData = nil + return nil + }) + + // Wait a while when reading - can take some time for the container to launch + ws.SetReadDeadline(time.Now().Add(pongWait)) + ws.SetPongHandler(func(string) error { ws.SetReadDeadline(time.Now().Add(pongWait)); return nil }) + + // Read the input from the web socket and pipe it to the SSH client + for { + _, r, err := ws.ReadMessage() + if err != nil { + // Error reading - so clean up + k.cleanupPodAndSecret(podData) + podData = nil + + ws.SetWriteDeadline(time.Now().Add(writeWait)) + ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + time.Sleep(closeGracePeriod) + ws.Close() + + // No point returning an error - we've already upgraded to web sockets, so we can't use the HTTP response now + return nil + } + + res := KeyCode{} + json.Unmarshal(r, &res) + if res.Cols == 0 { + slice := make([]byte, 1) + slice[0] = 0 + slice = append(slice, []byte(res.Key)...) + wsConn.WriteMessage(websocket.TextMessage, slice) + } else { + size := terminalSize{ + Width: uint16(res.Cols), + Height: uint16(res.Rows), + } + j, _ := json.Marshal(size) + resizeStream := []byte{4} + slice := append(resizeStream, j...) + wsConn.WriteMessage(websocket.TextMessage, slice) + } + } +} + +func pumpStdout(ws *websocket.Conn, source *websocket.Conn, done chan bool) { + for { + _, r, err := source.ReadMessage() + if err != nil { + // Close + ws.Close() + done <- true + break + } + ws.SetWriteDeadline(time.Now().Add(writeWait)) + bytes := fmt.Sprintf("% x\n", r[1:]) + if err := ws.WriteMessage(websocket.TextMessage, []byte(bytes)); err != nil { + log.Errorf("Kubernetes Terminal failed to write message: %+v", err) + ws.Close() + break + } + } +} + +func ping(ws *websocket.Conn, done chan bool) { + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := ws.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(writeWait)); err != nil { + log.Errorf("Web socket ping error: %+v", err) + } + case <-done: + return + } + } +} diff --git a/src/jetstream/plugins/kubernetes/terminal/terminal.go b/src/jetstream/plugins/kubernetes/terminal/terminal.go new file mode 100644 index 0000000000..83d259147b --- /dev/null +++ b/src/jetstream/plugins/kubernetes/terminal/terminal.go @@ -0,0 +1,85 @@ +package terminal + +import ( + "fmt" + "io/ioutil" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes/api" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces/config" + + log "github.com/sirupsen/logrus" +) + +const ( + serviceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" + serviceHostEnvVar = "KUBERNETES_SERVICE_HOST" + servicePortEnvVar = "KUBERNETES_SERVICE_PORT" + // For dev - read token from env var + serviceTokenEnvVar = "KUBE_TERMINAL_SERVICE_ACCOUNT_TOKEN" + + stratosRoleLabel = "stratos-role" + stratosKubeTerminalRole = "kube-terminal" + stratosSessionAnnotation = "stratos-session" + + consoleContainerName = "kube-terminal" +) + +// KubeTerminal supports spawning pods to provide a CLI environment to the user +type KubeTerminal struct { + PortalProxy interfaces.PortalProxy + Namespace string `configName:"STRATOS_KUBERNETES_NAMESPACE"` + Image string `configName:"STRATOS_KUBERNETES_TERMINAL_IMAGE"` + Token []byte + APIServer string + Kube api.Kubernetes +} + +// NewKubeTerminal checks that the environment is set up to support the Kube Terminal +func NewKubeTerminal(p interfaces.PortalProxy) *KubeTerminal { + // Only enabled in tech preview + if !p.GetConfig().EnableTechPreview { + log.Info("Kube Terminal not enabled - requires tech preview") + return nil + } + + kt := &KubeTerminal{ + PortalProxy: p, + } + if err := config.Load(kt, p.Env().Lookup); err != nil { + log.Warnf("Unable to load Kube Terminal configuration. %v", err) + return nil + } + + // Check that we have everything we need + if len(kt.Image) == 0 || len(kt.Namespace) == 0 { + log.Warn("Kube Terminal configuration is not complete") + return nil + } + + // Read the Kubernetes API Endpoint + host, hostFound := p.Env().Lookup(serviceHostEnvVar) + port, portFound := p.Env().Lookup(servicePortEnvVar) + if !hostFound || !portFound { + log.Warn("Kubernetes API Server configuration not found (host and/or port env vars not set)") + return nil + } + kt.APIServer = fmt.Sprintf("https://%s:%s", host, port) + + // Read the Service Account Token + token, err := ioutil.ReadFile(serviceAccountTokenFile) + if err != nil { + // Check env var + tkn, found := p.Env().Lookup(serviceTokenEnvVar) + if !found { + log.Warnf("Unable to load Service Account token. %v", err) + return nil + } + token = []byte(tkn) + } + + kt.Token = token + + log.Debug("Kubernetes Terminal configured") + return kt +} diff --git a/src/jetstream/plugins/metrics/main.go b/src/jetstream/plugins/metrics/main.go index 33c4cde87e..d6ad0d7a29 100644 --- a/src/jetstream/plugins/metrics/main.go +++ b/src/jetstream/plugins/metrics/main.go @@ -359,9 +359,7 @@ func (m *MetricsSpecification) UpdateMetadata(info *interfaces.Info, userGUID st for _, values := range info.Endpoints { for _, endpoint := range values { // Look to see if we can find the metrics provider for this URL - log.Debugf("Processing endpoint: %+v", endpoint) log.Debugf("Processing endpoint: %+v", endpoint.CNSIRecord) - if provider, ok := hasMetricsProvider(metricsProviders, endpoint.DopplerLoggingEndpoint); ok { endpoint.Metadata["metrics"] = provider.EndpointGUID endpoint.Metadata["metrics_job"] = provider.Job diff --git a/src/jetstream/plugins/monocular/20190307115300_ChartStore.go b/src/jetstream/plugins/monocular/20190307115300_ChartStore.go new file mode 100644 index 0000000000..409fb3691b --- /dev/null +++ b/src/jetstream/plugins/monocular/20190307115300_ChartStore.go @@ -0,0 +1,57 @@ +package monocular + +import ( + "database/sql" + "strings" + + "bitbucket.org/liamstask/goose/lib/goose" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore" +) + +func init() { + datastore.RegisterMigration(20190307115301, "ChartStore", func(txn *sql.Tx, conf *goose.DBConf) error { + + createChartsTable := "CREATE TABLE IF NOT EXISTS charts (" + createChartsTable += "id VARCHAR(255) NOT NULL," + createChartsTable += "name VARCHAR(255) NOT NULL," + createChartsTable += "repo_name VARCHAR(255) NOT NULL," + createChartsTable += "update_batch VARCHAR(64) NOT NULL," + createChartsTable += "content TEXT," + createChartsTable += "last_updated TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP," + createChartsTable += "PRIMARY KEY (id) );" + + _, err := txn.Exec(createChartsTable) + if err != nil { + return err + } + + createIndex := "CREATE INDEX charts_id ON charts (id);" + _, err = txn.Exec(createIndex) + if err != nil { + return err + } + + binaryDataType := "BYTEA" + if strings.Contains(conf.Driver.Name, "mysql") { + binaryDataType = "BLOB" + } + + createChartFilesTable := "CREATE TABLE IF NOT EXISTS chart_files (" + createChartFilesTable += "id VARCHAR(255) NOT NULL," + createChartFilesTable += "filename VARCHAR(64) NOT NULL," + createChartFilesTable += "chart_id VARCHAR(255) NOT NULL," + createChartFilesTable += "name VARCHAR(255) NOT NULL," + createChartFilesTable += "repo_name VARCHAR(255) NOT NULL," + createChartFilesTable += "digest VARCHAR(255) NOT NULL," + createChartFilesTable += "content " + binaryDataType + "," + createChartFilesTable += "PRIMARY KEY (id, filename) );" + + _, err = txn.Exec(createChartFilesTable) + if err != nil { + return err + } + + return nil + }) +} diff --git a/src/jetstream/plugins/monocular/20200206090600_ChartStoreRemoval.go b/src/jetstream/plugins/monocular/20200206090600_ChartStoreRemoval.go new file mode 100644 index 0000000000..8bae6ce176 --- /dev/null +++ b/src/jetstream/plugins/monocular/20200206090600_ChartStoreRemoval.go @@ -0,0 +1,28 @@ +package monocular + +import ( + "database/sql" + + "bitbucket.org/liamstask/goose/lib/goose" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore" +) + +func init() { + datastore.RegisterMigration(20200206090600, "ChartStoreRemoval", func(txn *sql.Tx, conf *goose.DBConf) error { + + dropChartsTable := "DROP TABLE IF EXISTS charts"; + _, err := txn.Exec(dropChartsTable) + if err != nil { + return err + } + + dropChartFilesTable := "DROP TABLE IF EXISTS chart_files;" + _, err = txn.Exec(dropChartFilesTable) + if err != nil { + return err + } + + return nil + }) +} diff --git a/src/jetstream/plugins/monocular/20200819184800_ChartStore.go b/src/jetstream/plugins/monocular/20200819184800_ChartStore.go new file mode 100644 index 0000000000..41ef1ebebf --- /dev/null +++ b/src/jetstream/plugins/monocular/20200819184800_ChartStore.go @@ -0,0 +1,49 @@ +package monocular + +import ( + "database/sql" + + "bitbucket.org/liamstask/goose/lib/goose" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore" +) + +func init() { + datastore.RegisterMigration(20200819184800, "HelmChartStore", func(txn *sql.Tx, conf *goose.DBConf) error { + + createChartsTable := "CREATE TABLE IF NOT EXISTS helm_charts (" + createChartsTable += "endpoint VARCHAR(64) NOT NULL," + createChartsTable += "name VARCHAR(255) NOT NULL," + createChartsTable += "repo_name VARCHAR(255) NOT NULL," + createChartsTable += "version VARCHAR(64) NOT NULL," + createChartsTable += "created TIMESTAMP NOT NULL," + createChartsTable += "app_version VARCHAR(64) NOT NULL," + createChartsTable += "description VARCHAR(255) NOT NULL," + createChartsTable += "icon_url VARCHAR(255) NOT NULL," + createChartsTable += "chart_url VARCHAR(255) NOT NULL," + createChartsTable += "source_url VARCHAR(255) NOT NULL," + createChartsTable += "digest VARCHAR(64) NOT NULL," + createChartsTable += "is_latest BOOLEAN NOT NULL DEFAULT FALSE," + createChartsTable += "update_batch VARCHAR(64) NOT NULL," + createChartsTable += "PRIMARY KEY (endpoint, name, version) );" + + _, err := txn.Exec(createChartsTable) + if err != nil { + return err + } + + createIndex := "CREATE INDEX helm_charts_endpoint ON helm_charts (endpoint, name, version);" + _, err = txn.Exec(createIndex) + if err != nil { + return err + } + + createRepoIndex := "CREATE INDEX helm_charts_repository ON helm_charts (name, repo_name, version);" + _, err = txn.Exec(createRepoIndex) + if err != nil { + return err + } + + return nil + }) +} diff --git a/src/jetstream/plugins/monocular/cache.go b/src/jetstream/plugins/monocular/cache.go new file mode 100644 index 0000000000..6e38df02ec --- /dev/null +++ b/src/jetstream/plugins/monocular/cache.go @@ -0,0 +1,341 @@ +package monocular + +import ( + "archive/tar" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "strings" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/monocular/store" + log "github.com/sirupsen/logrus" + yaml "gopkg.in/yaml.v2" +) + +// Local Helm Chart Cache + +// A local file cache stores chart files +// We only download the files for the latest version - other versions are downloaded on demand +// Within the cache there is a folder for each endpoint (helm repository) and under each of those +// a folder for each chart with chart files in ana a digest tag file + +// If there are multiple Stratos backends, then each maintains its own cache + +// The Chart index is stored in the database + +const digestFilename = "digest" + +// deleteCacheForEndpoint will delete all cached files for the given endpoint +func (m *Monocular) deleteCacheForEndpoint(endpointID string) error { + endpointCacheFolder := path.Join(m.CacheFolder, endpointID) + return os.RemoveAll(endpointCacheFolder) +} + +// cacheCharts will cache charts in the local folder cache +func (m *Monocular) cacheCharts(charts []store.ChartStoreRecord) error { + + var errorCount = 0 + log.Debug("Cacheing charts") + for _, chart := range charts { + log.Debugf("Processing: %s", chart.Name) + if err := m.cacheChart(chart); err != nil { + errorCount++ + log.Warnf("Error cacheing chart: %s - %+v", chart.Name, err) + } + if _, err := m.cacheChartIcon(chart); err != nil { + errorCount++ + log.Warnf("Error cacheing chart icon: %s - %+v", chart.Name, err) + } + + } + if errorCount > 0 { + return errors.New("Error(s) occurred caching charts") + } + + return nil +} + +// Get the cache folder path for a chart +func (m *Monocular) getChartCacheFolder(chart store.ChartStoreRecord) string { + filename := fmt.Sprintf("%s_%s", chart.Name, chart.Version) + return path.Join(m.CacheFolder, chart.EndpointID, filename) +} + +// cleanCacheFiles will Clean all files in the folder for an endpoint that are not referenced by any of the charts we have for that endpoint +func (m *Monocular) cleanCacheFiles(endpointID string, allCharts []store.ChartStoreRecord) error { + + // Build map of the valid chart folder names + validFiles := make(map[string]bool) + for _, chart := range allCharts { + validFiles[m.getChartCacheFolder(chart)] = true + } + + endpointCacheFolder := path.Join(m.CacheFolder, endpointID) + // Don't delete the top-level cache folder for the endpoint + validFiles[endpointCacheFolder] = true + errorCount := 0 + filepath.Walk(endpointCacheFolder, func(path string, info os.FileInfo, err error) error { + if err == nil && info.IsDir() { + if _, ok := validFiles[path]; !ok { + // Filename does not exist in the map of valid file names + log.Debugf("Need to delete unused cache folder: %s", path) + if err := os.RemoveAll(path); err != nil { + log.Errorf("Could not delete folder %s - %+v", path, err) + errorCount++ + } + } + } + return nil + }) + + if errorCount > 0 { + return fmt.Errorf("Error(s) occurred cleaning unused folders from the cache folder for endpoint %s", endpointID) + } + + return nil +} + +// Is there a chart digest in the given folder with the given value? +func hasDigestFile(chartCachePath, digest string) bool { + data, err := ioutil.ReadFile(path.Join(chartCachePath, digestFilename)) + if err == nil { + chk := strings.TrimSpace(string(data)) + return chk == digest + } + + return false +} + +// write the chart digest to a file +func writeDigestFile(chartCachePath, digest string) error { + return ioutil.WriteFile(path.Join(chartCachePath, digestFilename), []byte(digest), 0644) +} + +func (m *Monocular) getChartYaml(chart store.ChartStoreRecord) *ChartMetadata { + + // Cache the Chart if we don't have it already + m.cacheChart(chart) + + chartCacheYamlPath := path.Join(m.getChartCacheFolder(chart), "Chart.yaml") + if _, err := os.Stat(chartCacheYamlPath); os.IsNotExist(err) { + return nil + } + + // Check we can unmarshall the request + data, err := ioutil.ReadFile(chartCacheYamlPath) + if err != nil { + return nil + } + + // Parse as yaml + var chartYaml ChartMetadata + err = yaml.Unmarshal(data, &chartYaml) + if err != nil { + return nil + } + return &chartYaml +} + +// Get the cache file path for the chart icon +func (m *Monocular) getIconCacheFile(chart store.ChartStoreRecord) string { + ext := "" + u, err := url.Parse(chart.IconURL) + if err == nil { + parts := strings.Split(u.Path, "/") + filename := parts[len(parts)-1] + index := strings.LastIndex(filename, ".") + if index != -1 { + ext = filename[index:] + } + } + + filename := fmt.Sprintf("icon%s", ext) + return path.Join(m.getChartCacheFolder(chart), filename) +} + +func (m *Monocular) ensureFolder(path string) error { + if _, err := os.Stat(path); os.IsNotExist(err) { + return os.MkdirAll(path, os.ModePerm) + } + + return nil +} + +// Cache chart +// Check to see if we already have files for the given chart - if not, download the archive +// and extract the files we need: +// Chart.yaml, README.md, values.yaml, values.schema.json +// Download the icon as well +func (m *Monocular) cacheChart(chart store.ChartStoreRecord) error { + log.Debugf("Cacheing chart: %s, %s", chart.Name, chart.Version) + + chartCachePath := m.getChartCacheFolder(chart) + if err := m.ensureFolder(chartCachePath); err != nil { + log.Warnf("Could not create folder for chart downloads: %+v", err) + return err + } + + // Check to see if we have the same digest + if ok := hasDigestFile(chartCachePath, chart.Digest); ok { + log.Debug("Skipping download - already have archive with the same digest") + return nil + } + + archiveFile := path.Join(chartCachePath, "chart.tgz") + if err := m.downloadFile(archiveFile, chart.ChartURL); err != nil { + return fmt.Errorf("Could not download chart from: %s - %+v", chart.ChartURL, err) + } + + sum, err := getFileChecksum(archiveFile) + if err != nil { + return fmt.Errorf("Could not calculate checksum for chart archive: %s - %+v", archiveFile, err) + } + + if err := writeDigestFile(chartCachePath, sum); err != nil { + return fmt.Errorf("Could not write chart digest file in: %s - %+v", chartCachePath, err) + } + + // Now extract the files we need + filenames := []string{"Chart.yaml", "README.md", "values.schema.json", "values.yaml"} + if err := extractArchiveFiles(archiveFile, chart.Name, chartCachePath, filenames); err != nil { + return fmt.Errorf("Could not extract files from chart archive: %s - %+v", archiveFile, err) + } + + // We can delete the Chart archive - don't need it anymore + os.Remove(archiveFile) + + return nil +} + +// Cache a chart icon +func (m *Monocular) cacheChartIcon(chart store.ChartStoreRecord) (string, error) { + log.Debugf("Cacheing chart icon: %s, %s", chart.Name, chart.Version) + if len(chart.IconURL) > 0 { + log.Debugf("Downloading chart icon: %s", chart.IconURL) + // If icon file already exists then don't download again + iconFilePath := m.getIconCacheFile(chart) + if _, err := os.Stat(iconFilePath); os.IsNotExist(err) { + if err := m.ensureFolder(path.Dir(iconFilePath)); err != nil { + log.Error(err) + } else if err := m.downloadFile(iconFilePath, chart.IconURL); err != nil { + log.Errorf("Could not download chart icon: %+v", err) + return "", fmt.Errorf("Could not download Chart icon: %+v", err) + } + } + return iconFilePath, nil + } + + return "", nil +} + +// download a file form the given url sand save to the file path +func (m *Monocular) downloadFile(filepath string, url string) error { + // Get the data + httpClient := m.portalProxy.GetHttpClient(false) + resp, err := httpClient.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Error downloading icon: %s - %d:%s", url, resp.StatusCode, resp.Status) + } + + // Create the file + out, err := os.Create(filepath) + if err != nil { + return err + } + defer out.Close() + + // Write the body to file + _, err = io.Copy(out, resp.Body) + return err +} + +func extractArchiveFiles(archivePath, chartName, downloadFolder string, filenames []string) error { + // Map the filenames array into a map of path to destination file + requiredFiles := make(map[string]string) + requiredCount := len(filenames) + for _, name := range filenames { + requiredFiles[fmt.Sprintf("%s/%s", chartName, name)] = path.Join(downloadFolder, name) + } + + f, err := os.Open(archivePath) + if err != nil { + log.Error("Helm: Archive extract file: Could not open file %s - %+v", archivePath, err) + return err + } + defer f.Close() + + gzf, err := gzip.NewReader(f) + if err != nil { + log.Error("Helm: Archive extract file: Could not open zip file %s - %+v", archivePath, err) + return err + } + + tarReader := tar.NewReader(gzf) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + + if err != nil { + log.Error("Helm: Archive extract file: Could not process archive file %s - %+v", archivePath, err) + return err + } + + name := header.Name + switch header.Typeflag { + case tar.TypeDir: + continue + case tar.TypeReg: + // Is this a file we are looking for? + if downloadPath, ok := requiredFiles[name]; ok { + // Create the file + out, err := os.Create(downloadPath) + if err != nil { + return err + } + defer out.Close() + + io.Copy(out, tarReader) + + // If we have extracted all of the files we are looking for, then return early, rather than + // going through the rest of the files + requiredCount-- + if requiredCount == 0 { + return nil + } + } + } + } + + return nil +} + +// get the SHA256 checksum for a file +func getFileChecksum(file string) (string, error) { + f, err := os.Open(file) + if err != nil { + return "", err + } + defer f.Close() + hasher := sha256.New() + if _, err := io.Copy(hasher, f); err != nil { + return "", err + } + + return hex.EncodeToString(hasher.Sum(nil)), nil +} diff --git a/src/jetstream/plugins/monocular/chart.go b/src/jetstream/plugins/monocular/chart.go new file mode 100644 index 0000000000..3b486aff12 --- /dev/null +++ b/src/jetstream/plugins/monocular/chart.go @@ -0,0 +1,63 @@ +/* +Copyright (c) 2017 The Helm Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package monocular + +import ( + "time" +) + +// Repo holds the App repository details +type Repo struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// Chart is a higher-level representation of a chart package +type Chart struct { + ID string `json:"-"` + Name string `json:"name"` + Repo Repo `json:"repo"` + Description string `json:"description"` + Home string `json:"home"` + Keywords []string `json:"keywords"` + Maintainers []ChartMaintainer `json:"maintainers"` + Sources []string `json:"sources"` + Icon string `json:"icon"` + RawIcon []byte `json:"-"` + IconContentType string `json:"-"` + ChartVersions []ChartVersion `json:"-"` +} + +// ChartVersion is a representation of a specific version of a chart +type ChartVersion struct { + Version string `json:"version"` + AppVersion string `json:"app_version"` + Created time.Time `json:"created"` + Digest string `json:"digest"` + URLs []string `json:"urls"` + Readme string `json:"readme,omitempty"` + Values string `json:"values,omitempty"` + Schema string `json:"schema"` +} + +//RepoCheck describes the state of a repository in terms its current checksum and last update time. +//It is used to determine whether or not to re-sync a respository. +type RepoCheck struct { + ID string `bson:"_id"` + LastUpdate time.Time `bson:"last_update"` + Checksum string `bson:"checksum"` +} diff --git a/src/jetstream/plugins/monocular/chart_svc.go b/src/jetstream/plugins/monocular/chart_svc.go new file mode 100644 index 0000000000..da3301de63 --- /dev/null +++ b/src/jetstream/plugins/monocular/chart_svc.go @@ -0,0 +1,353 @@ +package monocular + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "os" + "path" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/monocular/store" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +// Functions to provide a Monocular compatible API with our chart store + +// List all helm Charts - gets the latest version for each Chart +func (m *Monocular) listCharts(c echo.Context) error { + log.Debug("List Charts called") + + // Check if this is a request for an external Monocular + if handled, err := m.processMonocularRequest(c); handled { + return err + } + + charts, err := m.ChartStore.GetLatestCharts() + if err != nil { + return err + } + + // Translate the list into an array of Charts + var list APIListResponse + for _, chart := range charts { + list = append(list, m.translateToChartAPIResponse(chart, nil)) + } + + meta := Meta{ + TotalPages: 1, + } + + body := BodyAPIListResponse{ + Data: &list, + Meta: meta, + } + + return c.JSON(200, body) +} + +// Get the latest version of a given chart +func (m *Monocular) getChart(c echo.Context) error { + log.Debug("Get Chart called") + + // Check if this is a request for an external Monocular + if handled, err := m.processMonocularRequest(c); handled { + return err + } + + repo := c.Param("repo") + chartName := c.Param("name") + + chart, err := m.ChartStore.GetChart(repo, chartName, "") + if err != nil { + return err + } + + chartYaml := m.getChartYaml(*chart) + body := BodyAPIResponse{ + Data: *m.translateToChartAPIResponse(chart, chartYaml), + } + return c.JSON(200, body) +} + +func (m *Monocular) getIcon(c echo.Context) error { + log.Debug("Get Icon called") + + // Check if this is a request for an external Monocular + if handled, err := m.processMonocularRequest(c); handled { + return err + } + + repo := c.Param("repo") + chartName := c.Param("chartName") + version := c.Param("version") + + if len(version) == 0 { + log.Debugf("Get icon for %s/%s", repo, chartName) + } else { + log.Debugf("Get icon for %s/%s-%s", repo, chartName, version) + } + + chart, err := m.ChartStore.GetChart(repo, chartName, version) + if err != nil { + log.Error("Can not find chart") + return errors.New("Error") + } + + // This will download and cache the icon if it is not already cached - it returns the local file path to the icon file + // or an empty string if no icon is available or could not be downloaded + iconFilePath, _ := m.cacheChartIcon(*chart) + if len(iconFilePath) == 0 { + // No icon or error downloading + http.Redirect(c.Response().Writer, c.Request(), "/core/assets/custom/placeholder.png", http.StatusTemporaryRedirect) + return nil + } + + c.File(iconFilePath) + return nil +} + +// /chartsvc/v1/charts/:repo/:name/versions/:version +// Get specific chart version +func (m *Monocular) getChartVersion(c echo.Context) error { + log.Debug("getChartAndVersion called") + + // Check if this is a request for an external Monocular + if handled, err := m.processMonocularRequest(c); handled { + return err + } + + repo := c.Param("repo") + chartName := c.Param("name") + version := c.Param("version") + + chart, err := m.ChartStore.GetChart(repo, chartName, version) + if err != nil { + return err + } + + chartYaml := m.getChartYaml(*chart) + if chartYaml == nil { + // Error - could not get chart yaml + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Can not find Chart.yaml for %s/%s-%s", repo, chartName, version)) + } + + body := BodyAPIResponse{ + Data: *m.translateToChartVersionAPIResponse(chart, chartYaml), + } + return c.JSON(200, body) +} + +// /chartsvc/v1/charts/:repo/:name/versions +// Get all chart versions for a given chart +func (m *Monocular) getChartVersions(c echo.Context) error { + + // Check if this is a request for an external Monocular + if handled, err := m.processMonocularRequest(c); handled { + return err + } + + repo := c.Param("repo") + chartName := c.Param("name") + + // Get all versions for a given chart + charts, err := m.ChartStore.GetChartVersions(repo, chartName) + if err != nil { + return err + } + + // Translate the list into an array of Charts + var list APIListResponse + for _, chart := range charts { + list = append(list, m.translateToChartVersionAPIResponse(chart, nil)) + } + + body := BodyAPIListResponse{ + Data: &list, + } + + return c.JSON(200, body) +} + +// Get a file such as the README or valyes for a given chart version +func (m *Monocular) getChartAndVersionFile(c echo.Context) error { + log.Debug("Get Chart file called") + + // Check if this is a request for an external Monocular + if handled, err := m.processMonocularRequest(c); handled { + return err + } + + repo := c.Param("repo") + chartName := c.Param("name") + version := c.Param("version") + filename := c.Param("filename") + + log.Debugf("Get chart file: %s", filename) + + chart, err := m.ChartStore.GetChart(repo, chartName, version) + if err != nil { + return err + } + + if m.cacheChart(*chart) == nil { + return c.File(path.Join(m.getChartCacheFolder(*chart), filename)) + } + + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Can not find file %s for the specified chart", filename)) +} + +func (m *Monocular) getChartValues(c echo.Context) error { + endpointID := c.Param("endpoint") + repo := c.Param("repo") + chartName := c.Param("name") + version := c.Param("version") + + // Built in Monocular + if endpointID == "default" { + filename := "values.yaml" + log.Debugf("Get chart file: %s", filename) + chart, err := m.ChartStore.GetChart(repo, chartName, version) + if err != nil { + return err + } + if m.cacheChart(*chart) == nil { + return c.File(path.Join(m.getChartCacheFolder(*chart), filename)) + } + return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Can not find file %s for the specified chart", filename)) + } + + // Helm Hub + // Change the URL and then forward on + p := fmt.Sprintf("/chartsvc/v1/assets/%s/%s/versions/%s/values.yaml", repo, chartName, version) + monocularEndpoint, err := m.validateExternalMonocularEndpoint(endpointID) + if monocularEndpoint == nil || err != nil { + return echo.NewHTTPError(http.StatusBadRequest, errors.New("No monocular endpoint")) + } + + return m.proxyToMonocularInstance(c, monocularEndpoint, p) +} + +// Check to see if the given chart URL has a schema +func (m *Monocular) checkForJsonSchema(c echo.Context) error { + log.Debug("checkForJsonSchema called") + + chartName := c.Param("name") + encodedChartURL := c.Param("encodedChartURL") + url, err := base64.StdEncoding.DecodeString(encodedChartURL) + if err != nil { + return err + } + + chartURL := string(url) + + chartCachePath := path.Join(m.CacheFolder, "schemas", encodedChartURL) + if err := m.ensureFolder(chartCachePath); err != nil { + log.Warnf("checkForJsonSchema: Could not create folder for chart downloads: %+v", err) + return err + } + + // We can delete the Chart archive - don't need it anymore + defer os.RemoveAll(chartCachePath) + + archiveFile := path.Join(chartCachePath, "chart.tgz") + if err := m.downloadFile(archiveFile, chartURL); err != nil { + return fmt.Errorf("Could not download chart from: %s - %+v", chartURL, err) + } + + // Now extract the files we need + filenames := []string{"values.schema.json"} + if err := extractArchiveFiles(archiveFile, chartName, chartCachePath, filenames); err != nil { + return fmt.Errorf("Could not extract files from chart archive: %s - %+v", archiveFile, err) + } + + return c.File(path.Join(chartCachePath, "values.schema.json")) +} + +// This is the simpler version that returns just enough data needed for the Charts list view +// This is a slight cheat - the response is not as complete as the Monocular API, but includes +// enough for the UI and saves us having to pull out all of the Chart.yaml files +func (m *Monocular) translateToChartAPIResponse(record *store.ChartStoreRecord, chartYaml *ChartMetadata) *APIResponse { + response := &APIResponse{ + ID: fmt.Sprintf("%s/%s", record.Repository, record.Name), + Type: "chart", + Relationships: make(map[string]Rel), + Attributes: m.translateToChart(record, chartYaml), + } + + response.Relationships["latestChartVersion"] = Rel{ + Data: m.translateToChartVersion(record, chartYaml), + } + return response +} + +func (m *Monocular) translateToChart(record *store.ChartStoreRecord, chartYaml *ChartMetadata) Chart { + chart := Chart{ + Name: record.Name, + Description: record.Description, + Repo: Repo{}, + Icon: fmt.Sprintf("/v1/assets/%s/%s/%s/logo", record.Repository, record.Name, record.Version), + Sources: record.Sources, + } + + chart.Repo.Name = record.Repository + + if chartYaml != nil { + chart.Keywords = chartYaml.Keywords + // Prefer the Chart Yaml description if we have it (db one might be truncated) + chart.Description = chartYaml.Description + chart.Maintainers = make([]ChartMaintainer, len(chartYaml.Maintainers)) + for index, maintainer := range chartYaml.Maintainers { + chart.Maintainers[index] = *maintainer + } + + chart.Home = chartYaml.Home + } + + return chart +} + +func (m *Monocular) translateToChartVersion(record *store.ChartStoreRecord, chartYaml *ChartMetadata) ChartVersion { + chartVersion := ChartVersion{ + Version: record.Version, + AppVersion: record.AppVersion, + Digest: record.Digest, + Created: record.Created, + URLs: make([]string, 1), + } + chartVersion.URLs[0] = record.ChartURL + if chartYaml != nil { + // If we have the Chart yaml, then we already have the chart + // Add in the files that are available + cacheFolder := m.getChartCacheFolder(*record) + chartVersion.Readme = getFileAssetURL(record.Repository, record.Name, record.Version, cacheFolder, "README.md") + chartVersion.Schema = getFileAssetURL(record.Repository, record.Name, record.Version, cacheFolder, "values.schema.json") + chartVersion.Values = getFileAssetURL(record.Repository, record.Name, record.Version, cacheFolder, "values.yaml") + } + + return chartVersion +} + +func (m *Monocular) translateToChartVersionAPIResponse(record *store.ChartStoreRecord, chartYaml *ChartMetadata) *APIResponse { + response := &APIResponse{ + ID: fmt.Sprintf("%s/%s-%s", record.Repository, record.Name, record.Version), + Type: "chartVersion", + Relationships: make(map[string]Rel), + Attributes: m.translateToChartVersion(record, chartYaml), + } + + response.Relationships["chart"] = Rel{ + Data: m.translateToChart(record, chartYaml), + } + return response +} + +func getFileAssetURL(repo, name, version, folder, filename string) string { + cachePath := path.Join(folder, filename) + if _, err := os.Stat(cachePath); os.IsNotExist(err) { + return "" + } + + return fmt.Sprintf("/v1/assets/%s/%s/versions/%s/%s", repo, name, version, filename) +} diff --git a/src/jetstream/plugins/monocular/endpoint.go b/src/jetstream/plugins/monocular/endpoint.go new file mode 100644 index 0000000000..39405807ee --- /dev/null +++ b/src/jetstream/plugins/monocular/endpoint.go @@ -0,0 +1,75 @@ +package monocular + +import ( + "errors" + "fmt" + "net/url" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +// GetType returns the endpoint type supported by this plugin +func (m *Monocular) GetType() string { + return helmEndpointType +} + +// GetClientId gets the default client ID to use +func (m *Monocular) GetClientId() string { + return "helm" +} + +// Register will register a new endpoint of the type Helm +func (m *Monocular) Register(echoContext echo.Context) error { + log.Debug("Helm Repository Register...") + return m.portalProxy.RegisterEndpoint(echoContext, m.Info) +} + +// Validate validates the connection to the endpoint - verifies that we can actually connect and call its API +func (m *Monocular) Validate(userGUID string, cnsiRecord interfaces.CNSIRecord, tokenRecord interfaces.TokenRecord) error { + return nil +} + +// Connect to the endpoint +func (m *Monocular) Connect(ec echo.Context, cnsiRecord interfaces.CNSIRecord, userId string) (*interfaces.TokenRecord, bool, error) { + // Note: Helm Repositories don't support connecting + return nil, false, errors.New("Connecting not support for a Helm Repository") +} + +// Info checks the endpoint type and fetches any metadata +func (m *Monocular) Info(apiEndpoint string, skipSSLValidation bool) (interfaces.CNSIRecord, interface{}, error) { + log.Debug("Helm Repository Info") + var v2InfoResponse interfaces.V2Info + var newCNSI interfaces.CNSIRecord + + newCNSI.CNSIType = helmEndpointType + + _, err := url.Parse(apiEndpoint) + if err != nil { + return newCNSI, nil, err + } + + // Just check that we can fetch index.yaml + var httpClient = m.portalProxy.GetHttpClient(skipSSLValidation) + res, err := httpClient.Get(apiEndpoint + "/index.yaml") + if err != nil { + // This should ultimately catch 503 cert errors + return newCNSI, nil, err + } + + if res.StatusCode >= 400 { + return newCNSI, nil, fmt.Errorf("Does not appear to be a Helm Repository (HTTP Status code: %d)", res.StatusCode) + } + + // We were able to fetch the index.yaml, so looks like a Helm Repository + // We could parse the contents and check further + newCNSI.TokenEndpoint = apiEndpoint + newCNSI.AuthorizationEndpoint = apiEndpoint + + return newCNSI, v2InfoResponse, nil +} + +// UpdateMetadata not needed for Helm endpoints +func (m *Monocular) UpdateMetadata(info *interfaces.Info, userGUID string, echoContext echo.Context) { +} diff --git a/src/jetstream/plugins/monocular/go.mod b/src/jetstream/plugins/monocular/go.mod new file mode 100644 index 0000000000..a1f6697738 --- /dev/null +++ b/src/jetstream/plugins/monocular/go.mod @@ -0,0 +1,22 @@ +module github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/monocular + +go 1.12 + +require ( + bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c + github.com/Masterminds/semver/v3 v3.1.0 // indirect + github.com/go-sql-driver/mysql v1.4.1 // indirect + github.com/kubeapps/common v0.0.0-20190508164739-10b110436c1a + github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 // indirect + github.com/labstack/echo v3.3.10+incompatible + github.com/labstack/gommon v0.3.0 // indirect + github.com/lib/pq v1.0.0 // indirect + github.com/mattn/go-sqlite3 v1.10.0 // indirect + github.com/satori/go.uuid v1.2.0 + github.com/sirupsen/logrus v1.4.2 + github.com/ziutek/mymysql v1.5.4 // indirect + golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect +) + +replace github.com/cloudfoundry-incubator/stratos/src/jetstream => ../.. diff --git a/src/jetstream/plugins/monocular/go.sum b/src/jetstream/plugins/monocular/go.sum new file mode 100644 index 0000000000..c4d22047ed --- /dev/null +++ b/src/jetstream/plugins/monocular/go.sum @@ -0,0 +1,334 @@ +bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c h1:bkb2NMGo3/Du52wvYj9Whth5KZfMV6d3O0Vbr3nz/UE= +bitbucket.org/liamstask/goose v0.0.0-20150115234039-8488cc47d90c/go.mod h1:hSVuE3qU7grINVSwrmzHfpg9k87ALBk+XaualNyUzI4= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc= +github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.1.0 h1:Y2lUDsFKVRSYGojLJ1yLxSXdMmMYTYls0rCvoqmMUQk= +github.com/Masterminds/semver/v3 v3.1.0/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/arschles/assert v1.0.0 h1:NofQbRhtxcLgP+XoKunA7J6UMJNTqX7xR/19tej8UsA= +github.com/arschles/assert v1.0.0/go.mod h1:m/u69zW43x0h8dTHcv3JJZljINyEYgBuf5fYJP6WikI= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.1.0 h1:yTUvW7Vhb89inJ+8irsUqiWjh8iT6sQPZiQzI6ReGkA= +github.com/cespare/xxhash/v2 v2.1.0/go.mod h1:dgIUBU3pDso/gPgZ1osOZ0iQf77oPR28Tjxl5dIMyVM= +github.com/cloudfoundry-community/go-cfenv v1.17.0 h1:qfxEfn8qKkaHY3ZEk/Y2noY79HBASvNgmtHK9x4+6GY= +github.com/cloudfoundry-community/go-cfenv v1.17.0/go.mod h1:2UgWvQTRXUuIZ/x3KnW6fk6CgPBhcV4UQb/UGIrUyyI= +github.com/cloudfoundry-incubator/stratos v2.0.0-beta-001+incompatible h1:UUxNbLjhv2cfymub5yNN1tjjqYkteHBBagb4jcbXEIQ= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrPujOBSCj8xRg= +github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4= +github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.0 h1:nVPXRUUQ36Z7MNf0O77UzgnOb1mkMMor7lmJMJXc/mA= +github.com/disintegration/imaging v1.6.0/go.mod h1:xuIt+sRxDFrHS0drzXUlCJthkJ8k7lkkUojDSR247MQ= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8 h1:DujepqpGd1hyOd7aW59XpK7Qymp8iy83xq74fLr21is= +github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= +github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= +github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= +github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= +github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= +github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.7.0 h1:tOSd0UKHQd6urX6ApfOn4XdBMY6Sh1MfxV3kmaazO+U= +github.com/gorilla/mux v1.7.0/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= +github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= +github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/govau/cf-common v0.0.7 h1:uhp1P6XM6GGzu1+A4C7LELLX/9mCmH6W5DpJZC0kWmo= +github.com/govau/cf-common v0.0.7/go.mod h1:5xL/OfE7wxeyHlXb7iei0rAbdQ/5v6dF18BZknPv7NQ= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/helm/monocular v1.4.0 h1:g0sOpuMe+9u+aPfd9ZO8mWV+c8W0dfGyBG9Wl23nwec= +github.com/helm/monocular v1.4.0/go.mod h1:PpkCN0v4zVVigsIHnsQdJytKFmaUkwfhxB7z33a9/gE= +github.com/heptiolabs/healthcheck v0.0.0-20180807145615-6ff867650f40 h1:GT4RsKmHh1uZyhmTkWJTDALRjSHYQp6FRKrotf0zhAs= +github.com/heptiolabs/healthcheck v0.0.0-20180807145615-6ff867650f40/go.mod h1:NtmN9h8vrTveVQRLHcX2HQ5wIPBDCsZ351TGbZWgg38= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3 h1:sHsPfNMAG70QAvKbddQ0uScZCHQoZsT5NykGRCeeeIs= +github.com/jinzhu/copier v0.0.0-20180308034124-7e38e58719c3/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= +github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a h1:zPPuIq2jAWWPTrGt70eK/BSch+gFAGrNzecsoENgu2o= +github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a/go.mod h1:yL958EeXv8Ylng6IfnvG4oflryUi3vgA3xPs9hmII1s= +github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kubeapps/common v0.0.0-20181107174310-61d8eb6f11b4 h1:wdTBUArlqtBYGN2Dd4+zsaFxFH0m4iGCHToW10jPX0k= +github.com/kubeapps/common v0.0.0-20181107174310-61d8eb6f11b4/go.mod h1:TsgmjeDpbftqhwPKInJ3v+l+xbHs4goiB6DFb2WqY9c= +github.com/kubeapps/common v0.0.0-20190508164739-10b110436c1a h1:VqeX/fehAB6FtBox0TVYcjOMXGE56INQIfbXegditX4= +github.com/kubeapps/common v0.0.0-20190508164739-10b110436c1a/go.mod h1:TsgmjeDpbftqhwPKInJ3v+l+xbHs4goiB6DFb2WqY9c= +github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 h1:mkl3tvPHIuPaWsLtmHTybJeoVEW7cbePK73Ir8VtruA= +github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28/go.mod h1:T/T7jsxVqf9k/zYOqbgNAsANsjxTd1Yq3htjDhQ1H0c= +github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg= +github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s= +github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0= +github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= +github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= +github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= +github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.6 h1:SrwhHcpV4nWrMGdNcC2kXpMfcBVYGDuTArqyhocJgvA= +github.com/mattn/go-isatty v0.0.6/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9 h1:d5US/mDsogSGW37IV293h//ZFaeajb69h+EHFsv2xGg= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= +github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.2 h1:awm861/B8OKDd2I/6o1dy3ra4BamzKhYOiGItCeZ740= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.2.1 h1:JnMpQc6ppsNgw9QPAGF6Dod479itz7lvlsMzzNayLOI= +github.com/prometheus/client_golang v1.2.1/go.mod h1:XMU6Z2MjaRKVu/dC1qupJI9SiNkDYzz3xecMgSW/F+U= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275 h1:PnBWHBf+6L0jOqq0gIVUe6Yk0/QMZ640k6NvkxcBf+8= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.7.0 h1:L+1lyG48J1zAQXA3RBX/nG/B3gjlHq0zTt2tlbJLyCY= +github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a h1:9a8MnZMP0X2nLJdBg+pBmGgkJlSaKC2KaQmTCk1XDtE= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.5 h1:3+auTFlqw+ZaQYJARz6ArODtkaIwtvBTx3N2NehQlL8= +github.com/prometheus/procfs v0.0.5/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME= +github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/unrolled/render v1.0.0 h1:XYtvhA3UkpB7PqkvhUFYmpKD55OudoIeygcfus4vcd4= +github.com/unrolled/render v1.0.0/go.mod h1:tu82oB5W2ykJRVioYsB+IQKcft7ryBr7w12qMBUPyXg= +github.com/unrolled/render v1.0.1 h1:VDDnQQVfBMsOsp3VaCJszSO0nkBIVEYoPWeRThk9spY= +github.com/unrolled/render v1.0.1/go.mod h1:gN9T0NhL4Bfbwu8ann7Ry/TGHYfosul+J0obPf6NBdM= +github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8= +github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw= +github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c h1:u40Z8hqBAAQyv+vATcGgV0YCnDjqSL7/q/JyPhhJSPk= +github.com/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= +github.com/xdg/stringprep v1.0.0 h1:d9X0esnoa3dFsV0FG35rAT0RIhYFlPq7MiP+DW89La0= +github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= +github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= +go.mongodb.org/mongo-driver v1.1.3 h1:++7u8r9adKhGR+I79NfEtYrk2ktjenErXM99PSufIoI= +go.mongodb.org/mongo-driver v1.1.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191205161847-0a08dada0ff9 h1:abxekknhS/Drh3uoQDk5Hc7BgeiyI39Crb7vhf/1j5s= +golang.org/x/crypto v0.0.0-20191205161847-0a08dada0ff9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81 h1:00VmoueYNlNz/aHIilyyQz/MHSqGoWJzpFv/HW8xpzI= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9 h1:rjwSpXsdiK0dV8/Naq3kAw9ymfAeJIyd0upUIElB+lI= +golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47 h1:/XfQ9z7ib8eEJX2hdgFTZJ/ntt0swNk5oYBziWeTCvY= +golang.org/x/sys v0.0.0-20191010194322-b09406accb47/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +k8s.io/apimachinery v0.0.0-20190221093215-450d01ad5771 h1:XMECjVNpkFVT9uY40z07scw8Xtn2mUnkxx8BC3gjwoE= +k8s.io/apimachinery v0.0.0-20190221093215-450d01ad5771/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= +k8s.io/apimachinery v0.0.0-20191203211716-adc6f4cd9e7d h1:q+OZmYewHJeMCzwpHkXlNTtk5bvaUMPCikKvf77RBlo= +k8s.io/apimachinery v0.0.0-20191203211716-adc6f4cd9e7d/go.mod h1:b9qmWdKlLuU9EBh+06BtLcSf/Mu89rWL33naRxs1uZg= +k8s.io/client-go v10.0.0+incompatible h1:F1IqCqw7oMBzDkqlcBymRq1450wD0eNqLE9jzUrIi34= +k8s.io/client-go v10.0.0+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= +k8s.io/client-go v11.0.0+incompatible h1:LBbX2+lOwY9flffWlJM7f1Ct8V2SRNiMRDFeiwnJo9o= +k8s.io/client-go v11.0.0+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= +k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/helm v2.12.3+incompatible h1:wo1cdYjOnr5Z+LFuhtwIJaeQnec6D4gcg2H5UAKzY6w= +k8s.io/helm v2.12.3+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= +k8s.io/helm v2.16.1+incompatible h1:L+k810plJlaGWEw1EszeT4deK8XVaKxac1oGcuB+WDc= +k8s.io/helm v2.16.1+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= +k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= +k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E= +sigs.k8s.io/structured-merge-diff v0.0.0-20190525122527-15d366b2352e/go.mod h1:wWxsB5ozmmv/SG7nM11ayaAW51xMvak/t1r0CSlcokI= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= diff --git a/src/jetstream/plugins/monocular/main.go b/src/jetstream/plugins/monocular/main.go new file mode 100644 index 0000000000..5222344d52 --- /dev/null +++ b/src/jetstream/plugins/monocular/main.go @@ -0,0 +1,348 @@ +package monocular + +import ( + "errors" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/monocular/store" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +const ( + helmEndpointType = "helm" + helmHubEndpointType = "hub" + helmRepoEndpointType = "repo" + stratosPrefix = "/pp/v1/" + kubeReleaseNameEnvVar = "STRATOS_HELM_RELEASE" + cacheFolderEnvVar = "HELM_CACHE_FOLDER" + defaultCacheFolder = "./.helm-cache" +) + +// Monocular is a plugin for Monocular +type Monocular struct { + portalProxy interfaces.PortalProxy + chartSvcRoutes http.Handler + ChartStore store.ChartStore + FoundationDBURL string + SyncServiceURL string + devSyncPID int + CacheFolder string +} + +type HelmHubChart struct { + APIResponse + Attributes *ChartVersion `json:"attributes"` +} + +type HelmHubChartResponse struct { + Data HelmHubChart `json:"data"` +} + +func init() { + interfaces.AddPlugin("monocular", []string{"kubernetes"}, Init) +} + +// Init creates a new Monocular +func Init(portalProxy interfaces.PortalProxy) (interfaces.StratosPlugin, error) { + store.InitRepositoryProvider(portalProxy.GetConfig().DatabaseProviderName) + return &Monocular{portalProxy: portalProxy}, nil +} + +// Init performs plugin initialization +func (m *Monocular) Init() error { + log.Debug("Monocular init .... ") + + m.CacheFolder = m.portalProxy.Env().String(cacheFolderEnvVar, defaultCacheFolder) + folder, err := filepath.Abs(m.CacheFolder) + if err != nil { + return err + } + m.CacheFolder = folder + log.Infof("Using Cache folder: %s", m.CacheFolder) + + // Check that the folder exists - try to make it, if not + if _, err := os.Stat(m.CacheFolder); os.IsNotExist(err) { + log.Info("Helm Cache folder does not exist - creating") + if err := os.MkdirAll(m.CacheFolder, os.ModePerm); err != nil { + log.Warn("Could not create folder for Helm Cache") + return err + } + } + + store, err := store.NewHelmChartDBStore(m.portalProxy.GetDatabaseConnection()) + if err != nil { + log.Errorf("Can not get Helm Chart store: %s", err) + return err + } + + m.ChartStore = store + + m.InitSync() + m.syncOnStartup() + return nil +} + +// Destroy does any cleanup for the plugin on exit +func (m *Monocular) Destroy() { + log.Debug("Monocular plugin .. destroy") +} + +func (m *Monocular) syncOnStartup() { + // Always sync all repositories on startup + + // Get all of the helm endpoints + endpoints, err := m.portalProxy.ListEndpoints() + if err != nil { + log.Errorf("Chart Repository Startup: Unable to sync repositories: %v+", err) + return + } + + helmRepos := make(map[string]bool) + for _, ep := range endpoints { + if ep.CNSIType == helmEndpointType { + if ep.SubType == helmRepoEndpointType { + helmRepos[ep.GUID] = true + m.Sync(interfaces.EndpointRegisterAction, ep) + } else { + metadata := "{}" + m.portalProxy.UpdateEndpointMetadata(ep.GUID, metadata) + } + } + } + + // Delete any endpoints left in the chart store that are no longer registered + // Get all of the endpoints that we have in the Database Chart Store + existing, err := m.ChartStore.GetEndpointIDs() + if err == nil { + for _, id := range existing { + if _, ok := helmRepos[id]; !ok { + log.Warnf("Endpoint ID %s exists in the Chart Store but does not exist as an endpoint - deleting", id) + m.deleteChartStoreForEndpoint(id) + } + } + } +} + +// ArrayContainsString checks the string array to see if it contains the specifed value +func arrayContainsString(a []string, x string) bool { + for _, n := range a { + if x == n { + return true + } + } + return false +} + +// OnEndpointNotification handles notification that endpoint has been remoevd +func (m *Monocular) OnEndpointNotification(action interfaces.EndpointAction, endpoint *interfaces.CNSIRecord) { + if endpoint.CNSIType == helmEndpointType && endpoint.SubType == helmRepoEndpointType { + m.Sync(action, endpoint) + } +} + +// GetMiddlewarePlugin gets the middleware plugin for this plugin +func (m *Monocular) GetMiddlewarePlugin() (interfaces.MiddlewarePlugin, error) { + return nil, errors.New("Not implemented") +} + +// GetEndpointPlugin gets the endpoint plugin for this plugin +func (m *Monocular) GetEndpointPlugin() (interfaces.EndpointPlugin, error) { + return m, nil +} + +// GetRoutePlugin gets the route plugin for this plugin +func (m *Monocular) GetRoutePlugin() (interfaces.RoutePlugin, error) { + return m, nil +} + +// AddAdminGroupRoutes adds the admin routes for this plugin to the Echo server +func (m *Monocular) AddAdminGroupRoutes(echoGroup *echo.Group) { + // no-op +} + +// AddSessionGroupRoutes adds the session routes for this plugin to the Echo server +func (m *Monocular) AddSessionGroupRoutes(echoGroup *echo.Group) { + + // API for Helm Chart Repositories - sync and sync status + // Reach out to a monocular instance other than Stratos (like helm hub). This is usually done via `x-cap-cnsi-list` + // however cannot be done for things like img src + echoGroup.Any("/monocular/:guid/chartsvc/*", m.handleMonocularInstance) + + echoGroup.Any("/monocular/schema/:name/:encodedChartURL", m.checkForJsonSchema) + echoGroup.Any("/monocular/values/:endpoint/:repo/:name/:version", m.getChartValues) + + echoGroup.POST("/chartrepos/:guid", m.syncRepo) + echoGroup.POST("/chartrepos/status", m.getRepoStatuses) + + // Routes for Chart Store + chartSvcGroup := echoGroup.Group("/chartsvc") + + // Routes for the internal chart store + + // Get specific chart version file (used for values.yaml) + chartSvcGroup.GET("/v1/assets/:repo/:name/versions/:version/:filename", m.getChartAndVersionFile) + + // Get specific chart version file + chartSvcGroup.GET("/v1/charts/:repo/:name/versions/:version/files/:filename", m.getChartAndVersionFile) + + // Get specific chart version of a chart + chartSvcGroup.GET("/v1/charts/:repo/:name/versions/:version", m.getChartVersion) + + // Get chart versions + chartSvcGroup.GET("/v1/charts/:repo/:name/versions", m.getChartVersions) + + // Get a chart + chartSvcGroup.GET("/v1/charts/:repo/:name", m.getChart) + + // // Get list of charts + chartSvcGroup.GET("/v1/charts", m.listCharts) + + // Get the chart icon for a specific version + chartSvcGroup.GET("/v1/assets/:repo/:chartName/:version/logo", m.getIcon) + + // Get the chart icon + chartSvcGroup.GET("/v1/assets/:repo/:chartName/logo", m.getIcon) +} + +// Check if the request if for an external Monocular instance and handle it if so +func (m *Monocular) processMonocularRequest(c echo.Context) (bool, error) { + externalMonocularEndpoint, err := m.isExternalMonocularRequest(c) + if err != nil { + return true, echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // If this request is associated with an external monocular instance forward the request on to it + if externalMonocularEndpoint != nil { + return true, m.baseHandleMonocularInstance(c, externalMonocularEndpoint) + } + return false, nil +} + +// isExternalMonocularRequest .. Should this request go out to an external monocular instance? IF so returns external monocular endpoint +func (m *Monocular) isExternalMonocularRequest(c echo.Context) (*interfaces.CNSIRecord, error) { + cnsiList := strings.Split(c.Request().Header.Get("x-cap-cnsi-list"), ",") + + // If this has a cnsi then test if it for an external monocular instance + if len(cnsiList) == 1 && len(cnsiList[0]) > 0 { + return m.validateExternalMonocularEndpoint(cnsiList[0]) + } + + return nil, nil +} + +// validateExternalMonocularEndpoint .. Is this endpoint related to an external moncular instance (not stratos's) +func (m *Monocular) validateExternalMonocularEndpoint(cnsi string) (*interfaces.CNSIRecord, error) { + endpoint, err := m.portalProxy.GetCNSIRecord(cnsi) + if err != nil { + err := errors.New("Failed to fetch endpoint") + return nil, echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + if endpoint.CNSIType == helmEndpointType && endpoint.SubType != helmRepoEndpointType { + return &endpoint, nil + } + + return nil, nil +} + +func (m *Monocular) handleMonocularInstance(c echo.Context) error { + log.Debug("handleMonocularInstance") + guid := c.Param("guid") + monocularEndpoint, err := m.validateExternalMonocularEndpoint(guid) + if monocularEndpoint == nil || err != nil { + err := errors.New("No monocular endpoint") + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + return m.baseHandleMonocularInstance(c, monocularEndpoint) +} + +func removeBreakingHeaders(oldRequest, emptyRequest *http.Request) { + for k, v := range oldRequest.Header { + switch { + // Skip these + // - "Referer" causes CF to fail with a 403 + // - "Connection", "X-Cap-*" and "Cookie" are consumed by us + // - "Accept-Encoding" must be excluded otherwise the transport will expect us to handle the encoding/compression + // - X-Forwarded-* headers - these will confuse Cloud Foundry in some cases (e.g. load balancers) + case k == "Connection", k == "Cookie", k == "Referer", k == "Accept-Encoding", + strings.HasPrefix(strings.ToLower(k), "x-cap-"), + strings.HasPrefix(strings.ToLower(k), "x-forwarded-"): + + // Forwarding everything else + default: + emptyRequest.Header[k] = v + } + } +} + +// baseHandleMonocularInstance .. Forward request to monocular of endpoint +func (m *Monocular) baseHandleMonocularInstance(c echo.Context, monocularEndpoint *interfaces.CNSIRecord) error { + log.Debug("baseHandleMonocularInstance") + // Generic proxy is handled last, after plugins. + + if monocularEndpoint == nil { + err := errors.New("No monocular endpoint") + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + + // We should be able to use `DoProxySingleRequest`, which goes through to `doRequest`, however the actual forwarded request is handled + // by the 'authHandler' associated with the endpoint OR defaults to an OAuth request. For this case there's no auth at all so falls over. + // Tracked in https://github.com/SUSE/stratos/issues/466 + + path := c.Request().URL.Path + log.Debug("URL to monocular requested: %v", path) + if strings.Index(path, stratosPrefix) == 0 { + // drop stratos pp/v1 + path = path[len(stratosPrefix)-1:] + + // drop leading slash + if path[0] == '/' { + path = path[1:] + } + + // drop monocular/:guid + parts := strings.Split(path, "/") + if parts[0] == "monocular" { + parts = parts[2:] + } + + path = "/" + strings.Join(parts, "/") + } + + return m.proxyToMonocularInstance(c, monocularEndpoint, path) +} + +func (m *Monocular) proxyToMonocularInstance(c echo.Context, monocularEndpoint *interfaces.CNSIRecord, path string) error { + url := monocularEndpoint.APIEndpoint + log.Debugf("URL to monocular: %v", url.String()) + url.Path += path + + req, err := http.NewRequest(c.Request().Method, url.String(), nil) + removeBreakingHeaders(c.Request(), req) + + client := &http.Client{Timeout: 30 * time.Second} + res, err := client.Do(req) + + if err != nil { + c.Response().Status = 500 + c.Response().Write([]byte(err.Error())) + } else if res.Body != nil { + c.Response().Status = res.StatusCode + c.Response().Header().Set("Content-Type", res.Header.Get("Content-Type")) + body, _ := ioutil.ReadAll(res.Body) + c.Response().Write(body) + defer res.Body.Close() + } else { + c.Response().Status = 200 + } + + return nil +} diff --git a/src/jetstream/plugins/monocular/repository.go b/src/jetstream/plugins/monocular/repository.go new file mode 100644 index 0000000000..01b2862bab --- /dev/null +++ b/src/jetstream/plugins/monocular/repository.go @@ -0,0 +1,46 @@ +package monocular + +import ( + "encoding/json" + "io/ioutil" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +type helmStatusInfo map[string]bool + +// getRepoStatuses will get the status of the Helm Endpoints requested +func (m *Monocular) getRepoStatuses(c echo.Context) error { + log.Debug("getRepoStatuses") + + // Get the list of endpoints we are looking at + // Need to extract the parameters from the request body + req := c.Request() + defer req.Body.Close() + body, err := ioutil.ReadAll(req.Body) + if err != nil { + return interfaces.NewJetstreamError("Could not read request body") + } + + info := helmStatusInfo{} + if err := json.Unmarshal(body, &info); err == nil { + for guid := range info { + newVal := false + if endpoint, err := m.portalProxy.GetCNSIRecord(guid); err == nil { + if len(endpoint.Metadata) > 0 { + status := SyncMetadata{} + if err = json.Unmarshal([]byte(endpoint.Metadata), &status); err == nil { + newVal = status.Busy + } + } + } + info[guid] = newVal + } + } else { + return interfaces.NewJetstreamError("Could not parse Helm Endpoint IDs") + } + + return c.JSON(200, info) +} diff --git a/src/jetstream/plugins/monocular/responses.go b/src/jetstream/plugins/monocular/responses.go new file mode 100644 index 0000000000..036c9aa658 --- /dev/null +++ b/src/jetstream/plugins/monocular/responses.go @@ -0,0 +1,59 @@ +/* +Copyright (c) 2019 The Helm Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package monocular + +//BodyAPIListResponse is an API body response in list format including the number of results pages +type BodyAPIListResponse struct { + Data *APIListResponse `json:"data"` + Meta Meta `json:"meta,omitempty"` +} + +//BodyAPIResponse is an API body response in non-list format +type BodyAPIResponse struct { + Data APIResponse `json:"data"` +} + +//APIResponse is an API response in non-list format +type APIResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes interface{} `json:"attributes"` + Links interface{} `json:"links"` + Relationships RelMap `json:"relationships"` +} + +//APIListResponse is an API response in list format +type APIListResponse []*APIResponse + +//SelfLink the self-referencing URL to a chart in a response +type SelfLink struct { + Self string `json:"self"` +} + +//RelMap maps elements e.g. Charts to other elements of a response e.g. Chart Versions +type RelMap map[string]Rel + +//Rel describes a relationship between element(s) in a response +type Rel struct { + Data interface{} `json:"data"` + Links SelfLink `json:"links"` +} + +//Meta the number of pages in the response +type Meta struct { + TotalPages int `json:"totalPages"` +} diff --git a/src/jetstream/plugins/monocular/store/chart_store_db.go b/src/jetstream/plugins/monocular/store/chart_store_db.go new file mode 100644 index 0000000000..5a7561898f --- /dev/null +++ b/src/jetstream/plugins/monocular/store/chart_store_db.go @@ -0,0 +1,232 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + "sort" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/datastore" + log "github.com/sirupsen/logrus" +) + +var ( + saveChartVersion = `INSERT INTO helm_charts (endpoint, name, repo_name, version, created, app_version, description, icon_url, chart_url, source_url, digest, is_latest, update_batch) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)` + updateChartVersion = `UPDATE helm_charts SET created=$1, app_version=$2, description=$3, icon_url=$4, chart_url=$5, source_url=$6, digest=$7, is_latest=$8, update_batch=$9 WHERE endpoint=$10 AND name=$11 AND repo_name=$12 AND version=$13` + deleteChartVersion = `DELETE FROM helm_charts WHERE endpoint = $1 AND name = $2 and version = $3` + deleteForEndpoint = `DELETE FROM helm_charts WHERE endpoint = $1` + deleteForBatch = `DELETE FROM helm_charts WHERE endpoint = $1 AND name = $2 and update_batch != $3` + renameEndpoint = `UPDATE helm_charts SET repo_name=$1 WHERE endpoint=$2` + getLatestCharts = `SELECT endpoint, name, repo_name, version, created, app_version, description, icon_url, chart_url, source_url, digest, is_latest FROM helm_charts WHERE is_latest = true` + getLatestChart = `SELECT endpoint, name, repo_name, version, created, app_version, description, icon_url, chart_url, source_url, digest, is_latest FROM helm_charts WHERE repo_name = $1 AND name = $2 AND is_latest = true` + getChartVersion = `SELECT endpoint, name, repo_name, version, created, app_version, description, icon_url, chart_url, source_url, digest, is_latest FROM helm_charts WHERE repo_name = $1 AND name = $2 AND version = $3` + getChartVersions = `SELECT endpoint, name, repo_name, version, created, app_version, description, icon_url, chart_url, source_url, digest, is_latest FROM helm_charts WHERE repo_name = $1 AND name = $2` + getEndpointIDs = `SELECT DISTINCT endpoint FROM helm_charts` + updateChartDigest = `UPDATE helm_charts SET created=$1, is_latest=$2, update_batch=$3 WHERE endpoint=$4 AND name=$5 AND repo_name=$6 AND version=$7` +) + +// InitRepositoryProvider - One time init for the given DB Provider +func InitRepositoryProvider(databaseProvider string) { + saveChartVersion = datastore.ModifySQLStatement(saveChartVersion, databaseProvider) + updateChartVersion = datastore.ModifySQLStatement(updateChartVersion, databaseProvider) + updateChartVersion = datastore.ModifySQLStatement(updateChartVersion, databaseProvider) + updateChartDigest = datastore.ModifySQLStatement(updateChartDigest, databaseProvider) + deleteForEndpoint = datastore.ModifySQLStatement(deleteForEndpoint, databaseProvider) + deleteForBatch = datastore.ModifySQLStatement(deleteForBatch, databaseProvider) + renameEndpoint = datastore.ModifySQLStatement(renameEndpoint, databaseProvider) + getLatestCharts = datastore.ModifySQLStatement(getLatestCharts, databaseProvider) + getLatestChart = datastore.ModifySQLStatement(getLatestChart, databaseProvider) + getChartVersion = datastore.ModifySQLStatement(getChartVersion, databaseProvider) + getChartVersions = datastore.ModifySQLStatement(getChartVersions, databaseProvider) + getEndpointIDs = datastore.ModifySQLStatement(getEndpointIDs, databaseProvider) +} + +// HelmChartDBStore is a DB-backed Helm Chart repository +type HelmChartDBStore struct { + db *sql.DB +} + +// NewHelmChartDBStore will create a new instance of the AnalysisDBStore +func NewHelmChartDBStore(dcp *sql.DB) (ChartStore, error) { + return &HelmChartDBStore{db: dcp}, nil +} + +func truncate(in string) string { + return fmt.Sprintf("%.255s", in) +} + +// Save a Helm Chart to the database +func (p *HelmChartDBStore) Save(chart ChartStoreRecord, batchID string) error { + + sourceURL := "" + if len(chart.Sources) > 0 { + sourceURL = chart.Sources[0] + } + + // Get the existing record - if it has the same digest, then no need to store it + record, err := p.GetChart(chart.Repository, chart.Name, chart.Version) + if err == nil && record.Digest == chart.Digest { + log.Debugf("Chart already exists %s/%s-%s with digest %s", chart.Repository, chart.Name, chart.Version, chart.Digest) + _, err := p.db.Exec(updateChartDigest, chart.Created, chart.IsLatest, batchID, chart.EndpointID, chart.Name, chart.Repository, chart.Version) + return err + } + + if err == nil { + log.Debugf("Chart already exists %s/%s-%s with different digest %s", chart.Repository, chart.Name, chart.Version, chart.Digest) + // The record already exists, so update it + _, err := p.db.Exec(updateChartVersion, chart.Created, chart.AppVersion, truncate(chart.Description), truncate(chart.IconURL), truncate(chart.ChartURL), truncate(sourceURL), chart.Digest, chart.IsLatest, batchID, chart.EndpointID, chart.Name, chart.Repository, chart.Version) + return err + } + + if _, err := p.db.Exec(saveChartVersion, chart.EndpointID, chart.Name, chart.Repository, chart.Version, chart.Created, chart.AppVersion, truncate(chart.Description), truncate(chart.IconURL), truncate(chart.ChartURL), truncate(sourceURL), chart.Digest, chart.IsLatest, batchID); err != nil { + return fmt.Errorf("Unable to save Helm Chart Version: %v", err) + } + return nil +} + +// DeleteBatch will remove all chart versions not with the given batch id +func (p *HelmChartDBStore) DeleteBatch(endpointID, chart, batchID string) error { + if _, err := p.db.Exec(deleteForBatch, endpointID, chart, batchID); err != nil { + return fmt.Errorf("Unable to delete Helm Chart Versions for batch ID: %s %v", batchID, err) + } + return nil +} + +// DeleteForEndpoint will remove all Helm Charts for a given endpoint guid +func (p *HelmChartDBStore) DeleteForEndpoint(endpointID string) error { + if _, err := p.db.Exec(deleteForEndpoint, endpointID); err != nil { + return fmt.Errorf("Unable to delete Helm Charts for endpoint: %s %v", endpointID, err) + } + return nil +} + +// RenameEndpoint will update all charts for a given endpoint to have the new repository name +func (p *HelmChartDBStore) RenameEndpoint(endpointID, name string) error { + if _, err := p.db.Exec(renameEndpoint, name, endpointID); err != nil { + return fmt.Errorf("Unable to rename Helm Chart repository for endpoint: %s %v", endpointID, err) + } + return nil +} + +// GetLatestCharts will get only the info for the latest version of each chart +func (p *HelmChartDBStore) GetLatestCharts() ([]*ChartStoreRecord, error) { + + rows, err := p.db.Query(getLatestCharts) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve Helm Charts: %v", err) + } + defer rows.Close() + + var chartList []*ChartStoreRecord + + for rows.Next() { + chart := new(ChartStoreRecord) + sourceURL := "" + err := rows.Scan(&chart.EndpointID, &chart.Name, &chart.Repository, &chart.Version, &chart.Created, &chart.AppVersion, &chart.Description, &chart.IconURL, &chart.ChartURL, &sourceURL, &chart.Digest, &chart.IsLatest) + if err != nil { + return nil, fmt.Errorf("Unable to scan Helm Chart records: %v", err) + } + chart.SemVer = NewSemanticVersion(chart.Version) + addSources(chart, sourceURL) + chartList = append(chartList, chart) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("Unable to list Helm Chart records: %v", err) + } + + return chartList, nil +} + +// GetChart gets a single helm chart +func (p *HelmChartDBStore) GetChart(repo, name, version string) (*ChartStoreRecord, error) { + + var row *sql.Row + chart := new(ChartStoreRecord) + + if len(version) == 0 { + row = p.db.QueryRow(getLatestChart, repo, name) + } else { + row = p.db.QueryRow(getChartVersion, repo, name, version) + } + + sourceURL := "" + err := row.Scan(&chart.EndpointID, &chart.Name, &chart.Repository, &chart.Version, &chart.Created, &chart.AppVersion, &chart.Description, &chart.IconURL, &chart.ChartURL, &sourceURL, &chart.Digest, &chart.IsLatest) + switch { + case err == sql.ErrNoRows: + return chart, errors.New("No match for that chart") + case err != nil: + return chart, fmt.Errorf("Error trying to find chart record: %v", err) + default: + // do nothing + } + + chart.SemVer = NewSemanticVersion(chart.Version) + addSources(chart, sourceURL) + + return chart, nil +} + +// GetChartVersions will get all of the versions for a given chart +func (p *HelmChartDBStore) GetChartVersions(repo, name string) ([]*ChartStoreRecord, error) { + rows, err := p.db.Query(getChartVersions, repo, name) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve Helm Charts: %v", err) + } + defer rows.Close() + + var chartList ChartStoreRecordList + + for rows.Next() { + chart := new(ChartStoreRecord) + sourceURL := "" + err := rows.Scan(&chart.EndpointID, &chart.Name, &chart.Repository, &chart.Version, &chart.Created, &chart.AppVersion, &chart.Description, &chart.IconURL, &chart.ChartURL, &sourceURL, &chart.Digest, &chart.IsLatest) + if err != nil { + return nil, fmt.Errorf("Unable to scan Helm Chart records: %v", err) + } + chart.SemVer = NewSemanticVersion(chart.Version) + addSources(chart, sourceURL) + chartList = append(chartList, chart) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("Unable to list Helm Chart records: %v", err) + } + + // Sort list by version + sort.Sort(chartList) + return chartList, nil +} + +// GetEndpointIDs will get all unique endpoint IDs from the database +func (p *HelmChartDBStore) GetEndpointIDs() ([]string, error) { + rows, err := p.db.Query(getEndpointIDs) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve Endpoint IDs: %v", err) + } + defer rows.Close() + + list := make([]string, 0) + + for rows.Next() { + var endpoint string + err := rows.Scan(&endpoint) + if err != nil { + return nil, fmt.Errorf("Unable to scan Helm Chart records for endpoints: %v", err) + } + list = append(list, endpoint) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("Unable to list Helm Chart endpoints: %v", err) + } + + return list, nil +} + +func addSources(record *ChartStoreRecord, sourceURL string) { + record.Sources = make([]string, 0) + if len(sourceURL) > 0 { + record.Sources = append(record.Sources, sourceURL) + } +} diff --git a/src/jetstream/plugins/monocular/store/main.go b/src/jetstream/plugins/monocular/store/main.go new file mode 100644 index 0000000000..68501e5f0f --- /dev/null +++ b/src/jetstream/plugins/monocular/store/main.go @@ -0,0 +1,28 @@ +package store + +// ChartStore is the Helm Chart Store repository +type ChartStore interface { + // This will add or update the given chart + Save(chart ChartStoreRecord, batchID string) error + + // Delete chart versions for a given batch + DeleteBatch(endpoint, chart, batchID string) error + + // Delete all charts for the given endpoint + DeleteForEndpoint(endpoint string) error + + // RenameEndpoint renames an endpoint (==renames helm repository) + RenameEndpoint(endpointID, name string) error + + // GetLatestCharts gets all of the latest charts + GetLatestCharts() ([]*ChartStoreRecord, error) + + // Version is optional - empty means get latest + GetChart(repo, name, version string) (*ChartStoreRecord, error) + + // Get Chart Versions + GetChartVersions(repo, name string) ([]*ChartStoreRecord, error) + + // Get Endopoint IDs stored in the chart store + GetEndpointIDs() ([]string, error) +} diff --git a/src/jetstream/plugins/monocular/store/types.go b/src/jetstream/plugins/monocular/store/types.go new file mode 100644 index 0000000000..65e5881d50 --- /dev/null +++ b/src/jetstream/plugins/monocular/store/types.go @@ -0,0 +1,38 @@ +package store + +import ( + "time" +) + +// ChartStoreRecord represents a Helm Chart Version record +type ChartStoreRecord struct { + EndpointID string `json:"endpoint"` + Name string `json:"name"` + Repository string `json:"repo_name"` + Version string `json:"version"` + AppVersion string `json:"app_version"` + Description string `json:"description"` + IconURL string `json:"icon_url"` + ChartURL string `json:"chart_url"` + Sources []string `json:"sources"` + Created time.Time `json:"created"` + Digest string `json:"digest"` + IsLatest bool `json:"is_latest"` + SemVer SemanticVersion `json:"-"` +} + +type ChartStoreRecordList []*ChartStoreRecord + +func (r ChartStoreRecordList) Len() int { + return len(r) +} + +func (r ChartStoreRecordList) Swap(i, j int) { + r[i], r[j] = r[j], r[i] +} +func (r ChartStoreRecordList) Less(i, j int) bool { + ci := r[i].SemVer + cj := r[j].SemVer + + return ci.LessThan(&cj) +} diff --git a/src/jetstream/plugins/monocular/store/version.go b/src/jetstream/plugins/monocular/store/version.go new file mode 100644 index 0000000000..76a08f5f19 --- /dev/null +++ b/src/jetstream/plugins/monocular/store/version.go @@ -0,0 +1,60 @@ +package store + +import ( + semver "github.com/Masterminds/semver/v3" +) + +// SemanticVersion is a semver with support for a plain text version +// Uses the semver library - which errors if the version can not be parsed +// This wrapper ensures that if a version can not be parsed as a semver +// it is treated as a string + +type SemanticVersion struct { + Version *semver.Version + Text string + Valid bool +} + +// NewSemanticVersion parses and returns a Semantic Version +func NewSemanticVersion(version string) SemanticVersion { + + v := SemanticVersion{ + Text: version, + } + + sv, err := semver.NewVersion(version) + v.Version = sv + v.Valid = err == nil + + return v +} + +func (s *SemanticVersion) LessThan(d *SemanticVersion) bool { + if d == nil { + return true + } + if s.Valid && d.Valid { + return !s.Version.LessThan(d.Version) + } else if s.Valid && !d.Valid { + return true + } else if !s.Valid && d.Valid { + return false + } + + return s.Text < d.Text +} + +func (s *SemanticVersion) LessThanReleaseVersions(d *SemanticVersion) bool { + if d == nil { + return true + } + if s.Valid && d.Valid { + // Check release versions + if len(d.Version.Prerelease()) > 0 { + return true + } + return !s.Version.LessThan(d.Version) + } + + return s.LessThan(d) +} diff --git a/src/jetstream/plugins/monocular/sync.go b/src/jetstream/plugins/monocular/sync.go new file mode 100644 index 0000000000..ba1331778a --- /dev/null +++ b/src/jetstream/plugins/monocular/sync.go @@ -0,0 +1,129 @@ +package monocular + +import ( + "encoding/json" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/labstack/echo/v4" + log "github.com/sirupsen/logrus" +) + +type SyncJob struct { + Action interfaces.EndpointAction + Endpoint *interfaces.CNSIRecord +} + +type SyncMetadata struct { + Status string `json:"status"` + Busy bool `json:"busy"` +} + +// Sync Channel +var syncChan = make(chan SyncJob, 100) + +// InitSync starts the go routine that will sync repositories in the background +func (m *Monocular) InitSync() { + go m.processSyncRequests() +} + +// syncRepo is endpoint to force a re-sync of a given Helm Repository +func (m *Monocular) syncRepo(c echo.Context) error { + log.Debug("syncRepo") + + // Lookup repository by GUID + var p = m.portalProxy + guid := c.Param("guid") + endpoint, err := p.GetCNSIRecord(guid) + if err != nil { + return interfaces.NewJetstreamErrorf("Could not find Helm Repository: %v+", err) + } + + m.Sync(interfaces.EndpointRegisterAction, &endpoint) + + response := "OK" + return c.JSON(200, response) +} + +// Sync schedules a sync action for the given endpoint +func (m *Monocular) Sync(action interfaces.EndpointAction, endpoint *interfaces.CNSIRecord) { + // Delete and Update are Synchronously handled + // Add (Sync) is handled Asynchronously via a SyncJob + if action == 0 { + // If the sync job is busy, it won't update the status of this new job until it completes the previous one + // Set the status to indicate it is pending + metadata := SyncMetadata{ + Status: "Pending", + Busy: true, + } + m.portalProxy.UpdateEndpointMetadata(endpoint.GUID, marshalSyncMetadata(metadata)) + + // Add the job to the queue to be processed + job := SyncJob{ + Action: action, + Endpoint: endpoint, + } + + // Schedula a sync job + syncChan <- job + } else if action == 1 { + log.Debugf("Deleting Helm Repository: %s", endpoint.Name) + m.deleteChartStoreForEndpoint(endpoint.GUID) + } else if action == 2 { + log.Debugf("Helm Repository has been updated - renaming the Helm repository field in the associated charts") + if err := m.ChartStore.RenameEndpoint(endpoint.GUID, endpoint.Name); err != nil { + log.Errorf("An error occurred renameing the Helm Repository for endpoint %s to %s - %+v", endpoint.GUID, endpoint.Name, err) + } + } +} + +func (m *Monocular) deleteChartStoreForEndpoint(id string) { + // Delete the records from the database + if err := m.ChartStore.DeleteForEndpoint(id); err != nil { + log.Warnf("Unable to delete Helm Charts for endpoint %s - %+v", id, err) + } + + // Delete files from the cache + if err := m.deleteCacheForEndpoint(id); err != nil { + log.Warnf("Unable to delete Helm Chart Cache for endpoint %s - %+v", err) + } +} + +func (m *Monocular) processSyncRequests() { + log.Info("Helm Repository Sync init") + for job := range syncChan { + log.Debugf("Processing Helm Repository Sync Job: %s", job.Endpoint.Name) + metadata := SyncMetadata{ + Status: "Synchronizing", + Busy: true, + } + m.portalProxy.UpdateEndpointMetadata(job.Endpoint.GUID, marshalSyncMetadata(metadata)) + + chartIndexURL := job.Endpoint.APIEndpoint.String() + metadata.Status = "Synchronized" + metadata.Busy = false + err := m.syncHelmRepository(job.Endpoint.GUID, job.Endpoint.Name, chartIndexURL) + if err != nil { + log.Warn("Helm Repository sync repository failed for repository %s - %v", job.Endpoint.GUID, err) + metadata.Status = "Sync Failed" + } + + // Update the job status + m.updateMetadata(job.Endpoint.GUID, metadata) + } + log.Debug("processSyncRequests finished") +} + +func (m *Monocular) updateMetadata(endpoint string, metadata SyncMetadata) { + err := m.portalProxy.UpdateEndpointMetadata(endpoint, marshalSyncMetadata(metadata)) + if err != nil { + log.Errorf("Failed to update endpoint metadata: %v+", err) + } +} + +func marshalSyncMetadata(metadata SyncMetadata) string { + jsonString, err := json.Marshal(metadata) + if err != nil { + return "" + } + return string(jsonString) +} diff --git a/src/jetstream/plugins/monocular/sync_worker.go b/src/jetstream/plugins/monocular/sync_worker.go new file mode 100644 index 0000000000..962c899669 --- /dev/null +++ b/src/jetstream/plugins/monocular/sync_worker.go @@ -0,0 +1,147 @@ +package monocular + +import ( + "fmt" + "strings" + "time" + + yaml "gopkg.in/yaml.v2" + + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/monocular/store" + uuid "github.com/satori/go.uuid" + log "github.com/sirupsen/logrus" +) + +type syncResult struct { + Charts []store.ChartStoreRecord + Latest store.ChartStoreRecord +} + +func (m *Monocular) syncHelmRepository(endpointID, repoName, url string) error { + + // Add index.yaml to the URL + var downloadURL string + + // Append "index.yaml" to the Chart Repository URL + if strings.HasSuffix(url, "/") { + downloadURL = fmt.Sprintf("%sindex.yaml", url) + } else { + downloadURL = fmt.Sprintf("%s/index.yaml", url) + } + + // Read the index.html file from the repository + httpClient := m.portalProxy.GetHttpClient(false) + resp, err := httpClient.Get(downloadURL) + if err != nil { + return fmt.Errorf("Could not download Helm Repository Index: %s", err) + } + if resp.StatusCode != 200 { + return fmt.Errorf("Could not download Helm Repository Index: %s", resp.Status) + } + + defer resp.Body.Close() + + // Marshal to the index structure + var index IndexFile + + decoder := yaml.NewDecoder(resp.Body) + err = decoder.Decode(&index) + if err != nil { + return fmt.Errorf("Error marshalling Helm Repository Index: %+v", err) + } + + var latestCharts []store.ChartStoreRecord + var allCharts []store.ChartStoreRecord + + log.Infof("Helm Repository sync started for %s", repoName) + start := time.Now() + + // Iterate over each chart in the index + for name, chartVersions := range index.Entries { + log.Debugf("Helm Repository Sync: Processing chart: %s", name) + syncRsult := m.procesChartVersions(endpointID, repoName, name, chartVersions) + latestCharts = append(latestCharts, syncRsult.Latest) + allCharts = append(allCharts, syncRsult.Charts...) + } + + // Cache latest charts + if err = m.cacheCharts(latestCharts); err != nil { + log.Warnf("Error caching helm charts: %+v", err) + } + + // Finally, delete all files that are no longer referenced in the database + if err = m.cleanCacheFiles(endpointID, allCharts); err != nil { + log.Errorf("%s", err) + } + + elapsed := time.Since(start).Round(time.Second) + log.Infof("Helm Repository sync completed for %s (%s)", repoName, elapsed) + + return nil +} + +func (m *Monocular) procesChartVersions(endpoint, repoName, name string, chartVersions []IndexFileMetadata) syncResult { + + result := syncResult{} + + // Find the newest version + var latestSemVer *store.SemanticVersion + for _, chartVersion := range chartVersions { + sv := store.NewSemanticVersion(chartVersion.Version) + if sv.LessThanReleaseVersions(latestSemVer) { + latestSemVer = &sv + } + } + + latestVersion := latestSemVer.Text + + // Generate a new batch update id - we use this to remove any charts that we not updated in this sync - these + // will have an old batch update id afetr processing + batchID := uuid.NewV4().String() + + // Write all versions database + for _, chartVersion := range chartVersions { + if len(chartVersion.URLs) == 0 { + log.Warnf("Can not index Chart %s, Version %s - Chart does not have any Chart URLs", chartVersion.Name, chartVersion.Version) + } else { + if len(chartVersion.URLs) > 1 { + log.Warnf("Chart %s, Version %s - Chart has more than 1 Chart URL - only using the first URL", chartVersion.Name, chartVersion.Version) + } + + // Create a record for the Chart Version that we will store in the database + record := store.ChartStoreRecord{ + EndpointID: endpoint, + Name: chartVersion.Name, + Repository: repoName, + Version: chartVersion.Version, + AppVersion: chartVersion.AppVersion, + Description: chartVersion.Description, + IconURL: chartVersion.Icon, + ChartURL: chartVersion.URLs[0], + Sources: chartVersion.Sources, + Created: chartVersion.Created, + Digest: chartVersion.Digest, + IsLatest: chartVersion.Version == latestVersion, + } + + result.Charts = append(result.Charts, record) + if record.IsLatest { + result.Latest = record + } + + if err := m.ChartStore.Save(record, batchID); err != nil { + log.Warnf("Error saving Chart %s, Version %s to the database: %+v", record.Name, record.Version, err) + } + + // Small delay mainly for SQLite so we don't hog the database connection + time.Sleep(2 * time.Millisecond) + } + } + + // Delete versions not updated in this batch + if err := m.ChartStore.DeleteBatch(endpoint, name, batchID); err != nil { + log.Warnf("Error deleting old Chart batches: Name %s, Batch ID %s, error: %+v", name, batchID, err) + } + + return result +} diff --git a/src/jetstream/plugins/monocular/types.go b/src/jetstream/plugins/monocular/types.go new file mode 100644 index 0000000000..0c828daa13 --- /dev/null +++ b/src/jetstream/plugins/monocular/types.go @@ -0,0 +1,70 @@ +package monocular + +import "time" + +// IndexFile represents the index.yaml structure for a Helm Repository +type IndexFile struct { + APIVersion string `json:"apiVersion,omitempty"` + Entries map[string][]IndexFileMetadata `json:"entries,omitempty"` +} + +// IndexFileMetadata represents the metadata for a single chart version +type IndexFileMetadata struct { + Name string `json:"name,omitempty"` + AppVersion string `json:"appVersion" yaml:"appVersion"` + Description string `json:"description,omitempty"` + Digest string `json:"digest,omitempty"` + Version string `json:"version,omitempty"` + Created time.Time `json:"created"` + Icon string `json:"icon,omitempty"` + URLs []string `json:"-" yaml:"urls"` + Sources []string `json:"-" yaml:"sources"` + APIVersion string `json:"-" yaml:"apiVersion"` +} + +// ChartMaintainer describes a Chart maintainer. +type ChartMaintainer struct { + // Name is a user name or organization name + Name string `json:"name,omitempty"` + // Email is an optional email address to contact the named maintainer + Email string `json:"email,omitempty"` + // URL is an optional URL to an address for the named maintainer + URL string `json:"url,omitempty"` +} + +// ChartMetadata for a Chart file. This models the structure of a Chart.yaml file. +type ChartMetadata struct { + // The name of the chart + Name string `json:"name,omitempty"` + // The URL to a relevant project page, git repo, or contact person + Home string `json:"home,omitempty"` + // Source is the URL to the source code of this chart + Sources []string `json:"sources,omitempty"` + // A SemVer 2 conformant version string of the chart + Version string `json:"version,omitempty"` + // A one-sentence description of the chart + Description string `json:"description,omitempty"` + // A list of string keywords + Keywords []string `json:"keywords,omitempty"` + // A list of name and URL/email address combinations for the maintainer(s) + Maintainers []*ChartMaintainer `json:"maintainers,omitempty"` + // The URL to an icon file. + Icon string `json:"icon,omitempty"` + // The API Version of this chart. + APIVersion string `json:"apiVersion,omitempty"` + // The condition to check to enable chart + Condition string `json:"condition,omitempty"` + // The tags to check to enable chart + Tags string `json:"tags,omitempty"` + // The version of the application enclosed inside of this chart. + AppVersion string `json:"appVersion,omitempty"` + // Whether or not this chart is deprecated + Deprecated bool `json:"deprecated,omitempty"` + // Annotations are additional mappings uninterpreted by Helm, + // made available for inspection by other applications. + Annotations map[string]string `json:"annotations,omitempty"` + // KubeVersion is a SemVer constraint specifying the version of Kubernetes required. + KubeVersion string `json:"kubeVersion,omitempty"` + // Specifies the chart type: application or library + Type string `json:"type,omitempty"` +} diff --git a/src/jetstream/repository/interfaces/endpoints.go b/src/jetstream/repository/interfaces/endpoints.go index 675d4c2e9b..7d2388fb4f 100644 --- a/src/jetstream/repository/interfaces/endpoints.go +++ b/src/jetstream/repository/interfaces/endpoints.go @@ -18,9 +18,14 @@ type RoutePlugin interface { AddAdminGroupRoutes(echoContext *echo.Group) } +// EndpointAction identifies the type of action for an endpoint notification type EndpointAction int const ( + // EndpointRegisterAction is for when an endpoint is registered EndpointRegisterAction EndpointAction = iota + // EndpointUnregisterAction is for when an endpoint is unregistered EndpointUnregisterAction + // EndpointUpdateAction is for when an endpoint is updated (e.g. renamed) + EndpointUpdateAction ) diff --git a/src/test-e2e/apikeys/apikeys-e2e.spec.ts b/src/test-e2e/apikeys/apikeys-e2e.spec.ts index 63409b1f73..53f86ed420 100644 --- a/src/test-e2e/apikeys/apikeys-e2e.spec.ts +++ b/src/test-e2e/apikeys/apikeys-e2e.spec.ts @@ -27,8 +27,8 @@ describe('API Keys -', () => { .getInfo(ConsoleUserType.admin); helper = new ApiKeyE2eHelper(setup); - newKeysComment = E2EHelpers.createCustomName(customApiKeyLabel).toLowerCase() - }) + newKeysComment = E2EHelpers.createCustomName(customApiKeyLabel).toLowerCase(); + }); // Should be ran in sequence describe('Ordered Tests - ', () => { @@ -41,7 +41,7 @@ describe('API Keys -', () => { it('Navigate to api key page', () => { page.header.clickUserMenuItem('API Keys'); page.waitForPage(); - }) + }); it('New key does not exist', () => { // Validation check @@ -51,9 +51,9 @@ describe('API Keys -', () => { currentKeysCount = page.list.table.getRowCount(); } else { const noContentComponent = new MessageNoContentPo(); - expect(noContentComponent.isDisplayed()).toBeTruthy() + expect(noContentComponent.isDisplayed()).toBeTruthy(); } - }) + }); }); describe('Add Dialog - ', () => { @@ -84,7 +84,7 @@ describe('API Keys -', () => { dialog.form.fill({ comment: newKeysComment - }) + }); expect(dialog.canClose()).toBeTruthy(); expect(dialog.canCreate()).toBeTruthy(); @@ -92,19 +92,19 @@ describe('API Keys -', () => { dialog.create(); dialog.waitUntilNotShown(); }); - }) + }); it('New key has a secret', () => { const secret = new Component(page.getKeySecret()); secret.waitUntilShown(); expect(secret.getComponent().getText()).toBeDefined(); page.closeKeySecret(); - secret.waitUntilNotShown() - }) + secret.waitUntilNotShown(); + }); it('New key is in updated table', () => { expect(page.list.table.findRow('description', newKeysComment, true)).toBeGreaterThanOrEqual(0); - }) + }); it('Delete new key', () => { return page.list.table.findRow('description', newKeysComment, true) @@ -119,10 +119,10 @@ describe('API Keys -', () => { page.list.waitForNoLoadingIndicator(); expect(page.list.table.getRowCount()).toEqual(currentKeysCount); } else { - expect(0).toEqual(currentKeysCount) + expect(0).toEqual(currentKeysCount); } - }) - }) - }) + }); + }); + }); }); \ No newline at end of file diff --git a/src/test-e2e/application/application-autoscaler-e2e.spec.ts b/src/test-e2e/application/application-autoscaler-e2e.spec.ts index 3f62eb3eb6..8c88c4aeb2 100644 --- a/src/test-e2e/application/application-autoscaler-e2e.spec.ts +++ b/src/test-e2e/application/application-autoscaler-e2e.spec.ts @@ -562,6 +562,11 @@ describe('Autoscaler -', () => { } }); browser.wait(() => sub.closed); + // Fail the test if the retry count made it down to 0 + if (retries === 0) { + e2e.debugLog('Timed out waiting for event row'); + fail('Timed out waiting for event row'); + } } it('Go to events page', () => { diff --git a/src/test-e2e/e2e.ts b/src/test-e2e/e2e.ts index a4215aaa53..9b73e8048e 100644 --- a/src/test-e2e/e2e.ts +++ b/src/test-e2e/e2e.ts @@ -21,7 +21,7 @@ export class E2E { */ public static customization: CustomizationsMetadata = { alwaysShowNavForEndpointTypes: (epType) => true - } + }; // General helpers public helper = new E2EHelpers(); @@ -198,7 +198,7 @@ export class E2ESetup { E2E.debugLog('Create session as user: ' + (userType === ConsoleUserType.admin ? 'admin' : 'user')); return this.reqHelper.createSession(req, userType); }); - } + }; private getReq(userType: ConsoleUserType) { if (userType === ConsoleUserType.admin) { diff --git a/src/tsconfig.json b/src/tsconfig.json index b42f612ef1..beb5202e85 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -13,7 +13,7 @@ "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, - "esModuleInterop": true, + "esModuleInterop": true, "importHelpers": true, "target": "es2015", "module": "esnext", @@ -38,6 +38,7 @@ "@stratosui/cloud-foundry": ["frontend/packages/cloud-foundry/src/public_api.ts"], "@stratosui/cf-autoscaler": ["frontend/packages/cf-autoscaler/src/public_api.ts"], "@example/extensions": ["frontend/packages/example-extensions/src/public-api.ts"], + "@stratosui/kubernetes": ["frontend/packages/kubernetes/src/public-api.ts"], } } }