From 4717ba6bdac569794a9640f6306a097db1146f95 Mon Sep 17 00:00:00 2001 From: Neil MacDougall Date: Fri, 24 Jul 2020 15:23:47 +0100 Subject: [PATCH] Security observability (#398) * Work in progress Analysis tools * Thursday updates * Friday fixes and improvements * Friday code * Separate analyzers into a separate container * Wire in analyzers container in the Helm chart * Hide analysis UI features when not enabled * Fix sidepanel bug with fallback metadata * Bug fix for change in way tab links are hidden * Remove debug logging * Add refresh button to report selector drop down. Change no reports icon * Add support for adding breadcrumbs in the sub nav bar * Fix unit tests * Fix format issues * Final front-end unit test fixes * Fix issues when deploying via Helm with mariadb * Analyzers container fix. Allow helm chart to be packaged. * Build script fixes * Remove file * WIP: Add support for Clair image scanning * Use klar * Remove binary * Add clair helm chart for dev * Fixes * USe end var for clair server address * Latest updates * Improvements * Minor fixes * Tweak * Fix 1.16 detecton issue with the analyzers * Chart fixes * Changes following first run of script * Changes following npm install * Update custom-src to new model - expose custom module's module's - Add routing module - Tweak stratos.config.ts log output - remove custom-src dir * update naming... custom extensions --> suse extensions * A few tidyups to help review * Fix build issue due to merge * Fixes following merge from upstream * Remove clair from this PR * Ignore example packages when there's a stratos config file * Changes following review * Changes following merge * Update dir names, remove examples folder * Add back in custom-src deploy content, also add product version to config * Revert change needed downstream... (only needed when suse extension is included) * Remove unused wip report viewers * Fix after merge * Move new terminal & config code to plugin, fix more build files * Fix imports and add doc * Fix compilation issues * Change following merge * Tweaks to logging * Fix bug where report can not be deleted * Fix kube config connect after merge, also fix subtype & error on connect * Fix e2e * Improve drop-down menu * Remove strange merge artifacts * Remove build file * Fix graph overview * Numerous improvements to graph parsing and presentation * Remove logging. Add no reports message to workload analysis * Add support for CRDs. KubeCF renders correctly. * Allow which engines are enabled to be configured * Fix issue where reports are not filtered by endpoint * Minor changes following review * Fixes for a few more issues * Add Analyzers image build to Concourse CI * Multiple small fixes - fix text search in analysis list - fix title of links in analysers info page - handle slow connections by only polling analysis list when not already * Fix kubeGuid for helm world * Add AnalysisReportRunnerComponent - Still need to add this to other places * Delete reports when endpoint is unregistered * Buf fixes. Use breadcrumbs in sub-nav * Add run analysis button to workload analyis and graph tabs * Fix select of overlay in workload graphs page * Change default sort order of analysis list to age * Ensure table cell links update on row change * Align table action's icon better * Use a side panel for analyzer info * Add actions/effects for all used analyis actions - Add new ResetPaginationOfType action, like ResetPagination but applies to all types - Allows user to refresh reports list after kicking off new report on namespace & workload tabs - Handle missing report param in reports returned from get all reports * Remove some console.logs, converted some to console.info * Update Kube Dashboard, allow download link to be configurable - Default download link updated to v2.0.3 - Can configured link by setting env var `STRATOS_KUBERNETES_DASHBOARD_IMAGE` - Can configure env var in helm via `console.kubeDashboardImage` - Kube Dashboard now expanded by default (to show namespace drop down) * Fix after merge * Changes following review * Fix expand of kube dashboard header by default * Changes following review * Fix json-viewer dark mode * Fix profile page and side nav top position following header diet - Fix side nav top position - Update fix for profile page to also work in non-desktop mode * WIP Wire in alerts to workload graph - need to understand if namespace should be checked when matching node/resource to alert - need to apply correct colour * Fix workload security analysis overlay slide in * Hide analysis headers info in tech preview & tie in tech preview check to analysisService.hideAnalysis$ - Q should the backend plugins be available in tech preview, see TODO * Hide the Workload Graph view if in tech preview * Fix disable of analysis plugin when tech preview is switched off * Adderss PR feedback * Minor tidy ups, fix analysis in graph - apply typing to many places - handle kube resources that we fail to fetch/parse - wire in analysis overlay to graphs and resource slide in * Remove debug code Co-authored-by: Richard Cox --- .gitignore | 1 + deploy/ci/suse-console-dev-releases.yml | 26 +- .../console/templates/analyzers.yaml | 62 ++++ deploy/kubernetes/console/values.yaml | 2 + deploy/kubernetes/custom/__stratos.tpl | 2 + deploy/kubernetes/custom/custom-build.sh | 5 + docs/extensions.md | 2 +- ...terminal-dev.md => suse-extensions-dev.md} | 22 +- package-lock.json | 347 ++++++++++++++---- .../application-tabs-base.component.ts | 2 +- .../sass/components/text-status.theme.scss | 9 + .../packages/core/sass/mat-desktop.scss | 7 + .../dashboard-base.component.html | 14 +- .../dashboard-base.component.scss | 36 +- .../dashboard-base.component.ts | 9 +- .../page-side-nav/page-side-nav.component.ts | 3 +- .../card-number-metric.component.html | 14 + .../card-number-metric.component.scss | 22 ++ .../card-number-metric.component.theme.scss | 20 +- .../card-number-metric.component.ts | 47 ++- .../app-table-cell-default.component.ts | 6 +- .../table-cell-actions.component.html | 5 +- .../page-sub-nav/page-sub-nav.component.ts | 9 +- .../ssh-viewer/ssh-viewer.component.ts | 22 +- .../src/shared/services/session.service.ts | 20 + .../src/shared/services/snackbar.service.ts | 15 +- .../packages/core/src/shared/shared.module.ts | 2 + src/frontend/packages/core/tab-nav.service.ts | 17 +- .../store/src/actions/pagination.actions.ts | 8 + .../entity-pagination-request-pipeline.ts | 2 +- .../pagination-reducer-create-pagination.ts | 2 +- .../pagination-reducer-reset-pagination.ts | 70 +++- .../pagination-reducer/pagination.reducer.ts | 5 + .../suse-extensions/sass/_all-theme.scss | 6 + .../analysis-report-runner.component.html | 18 + .../analysis-report-runner.component.scss | 0 .../analysis-report-runner.component.spec.ts | 25 ++ .../analysis-report-runner.component.ts | 49 +++ .../analysis-report-selector.component.html | 22 ++ .../analysis-report-selector.component.scss | 3 + ...analysis-report-selector.component.spec.ts | 38 ++ .../analysis-report-selector.component.ts | 87 +++++ .../analysis-report-viewer.component.html | 1 + .../analysis-report-viewer.component.scss | 0 .../analysis-report-viewer.component.spec.ts | 29 ++ .../analysis-report-viewer.component.ts | 76 ++++ .../kube-score-report-viewer.component.html | 18 + .../kube-score-report-viewer.component.scss | 23 ++ ...kube-score-report-viewer.component.spec.ts | 38 ++ .../kube-score-report-viewer.component.ts | 53 +++ .../popeye-report-viewer.component.html | 60 +++ .../popeye-report-viewer.component.scss | 43 +++ .../popeye-report-viewer.component.spec.ts | 38 ++ .../popeye-report-viewer.component.ts | 59 +++ .../resource-alert-preview.component.html | 5 + .../resource-alert-preview.component.scss | 14 + .../resource-alert-preview.component.spec.ts | 34 ++ .../resource-alert-preview.component.ts | 23 ++ .../resource-alert-view.component.html | 16 + .../resource-alert-view.component.scss | 14 + .../resource-alert-view.component.spec.ts | 38 ++ .../resource-alert-view.component.ts | 46 +++ .../kubernetes/kubernetes-entity-catalog.ts | 3 + .../kubernetes/kubernetes-entity-factory.ts | 8 + .../kubernetes/kubernetes-entity-generator.ts | 16 + ...s-namespace-analysis-report.component.html | 13 + ...s-namespace-analysis-report.component.scss | 0 ...amespace-analysis-report.component.spec.ts | 46 +++ ...tes-namespace-analysis-report.component.ts | 50 +++ .../kubernetes-namespace.component.ts | 18 +- .../kubernetes-resource-viewer.component.html | 7 +- ...bernetes-resource-viewer.component.spec.ts | 3 +- .../kubernetes-resource-viewer.component.ts | 56 ++- .../kubernetes-tab-base.component.ts | 23 +- .../custom/kubernetes/kubernetes.module.ts | 62 +++- .../custom/kubernetes/kubernetes.routing.ts | 23 +- .../kubernetes/kubernetes.store.module.ts | 4 +- .../analysis-reports-list-config.service.ts | 123 +++++++ .../analysis-reports-list-source.ts | 63 ++++ .../analysis-status-cell.component.html | 8 + .../analysis-status-cell.component.scss | 22 ++ .../analysis-status-cell.component.spec.ts | 32 ++ .../analysis-status-cell.component.ts | 16 + .../kubernetes-labels-cell.component.spec.ts | 2 +- .../services/analysis-report.types.ts | 22 ++ .../services/kubernetes.analysis.service.ts | 164 +++++++++ .../kubernetes/services/kubernetes.service.ts | 19 +- .../services/kubescore-report.helper.ts | 57 +++ .../services/popeye-report.helper.ts | 69 ++++ .../kubernetes/services/route.helper.ts | 11 + .../action-builders/kube.action-builders.ts | 39 ++ .../kubernetes/store/analysis.effects.ts | 264 +++++++++++++ .../kubernetes/store/anaylsis.actions.ts | 69 ++++ .../custom/kubernetes/store/kube.getIds.ts | 2 +- .../src/custom/kubernetes/store/kube.types.ts | 27 ++ .../kubernetes/store/kubernetes.actions.ts | 3 - .../kubernetes/store/kubernetes.effects.ts | 2 +- .../analysis-info-card.component.html | 4 + .../analysis-info-card.component.scss | 21 ++ .../analysis-info-card.component.spec.ts | 29 ++ .../analysis-info-card.component.theme.scss | 21 ++ .../analysis-info-card.component.ts | 55 +++ .../kubernetes-analysis-info.component.html | 7 + .../kubernetes-analysis-info.component.scss | 5 + ...kubernetes-analysis-info.component.spec.ts | 38 ++ .../kubernetes-analysis-info.component.ts | 23 ++ .../kubernetes-analysis-report.component.html | 7 + .../kubernetes-analysis-report.component.scss | 43 +++ ...bernetes-analysis-report.component.spec.ts | 32 ++ ...netes-analysis-report.component.theme.scss | 13 + .../kubernetes-analysis-report.component.ts | 80 ++++ .../kubernetes-analysis-tab.component.html | 5 + .../kubernetes-analysis-tab.component.scss | 0 .../kubernetes-analysis-tab.component.spec.ts | 42 +++ .../kubernetes-analysis-tab.component.ts | 24 ++ .../helm-release-tab-base.component.spec.ts | 9 +- .../helm-release-tab-base.component.ts | 50 +-- .../workloads/release/icon-helper.ts | 77 ++++ .../helm-release-analysis-tab.component.html | 12 + .../helm-release-analysis-tab.component.scss | 0 ...elm-release-analysis-tab.component.spec.ts | 42 +++ .../helm-release-analysis-tab.component.ts | 42 +++ .../tabs/helm-release-helper.service.ts | 4 +- ...helm-release-resource-graph.component.html | 51 ++- ...helm-release-resource-graph.component.scss | 6 + ...m-release-resource-graph.component.spec.ts | 9 +- .../helm-release-resource-graph.component.ts | 171 +++++++-- .../helm-release-summary-tab.component.html | 15 +- ...helm-release-summary-tab.component.spec.ts | 14 +- .../helm-release-summary-tab.component.ts | 162 ++++---- .../store/workloads-entity-factory.ts | 4 +- .../store/workloads-entity-generator.ts | 4 +- .../kubernetes/workloads/workload.types.ts | 40 +- .../workloads/workloads-entity-catalog.ts | 4 +- .../kubernetes/workloads/workloads.module.ts | 2 + .../kubernetes/workloads/workloads.routing.ts | 4 +- .../assets/core/custom/kubescore.md | 7 + .../assets/core/custom/kubescore.png | Bin 0 -> 36127 bytes .../suse-theme/assets/core/custom/popeye.md | 7 + .../suse-theme/assets/core/custom/popeye.png | Bin 0 -> 124519 bytes .../suse-theme/assets/core/custom/sonobuoy.md | 5 + .../assets/core/custom/sonobuoy.png | Bin 0 -> 46789 bytes .../assets/core/custom/sonobuoy.svg | 81 ++++ src/frontend/packages/theme/_helper.scss | 2 +- src/jetstream/config.example | 5 +- src/jetstream/go.mod | 1 + src/jetstream/go.sum | 14 + src/jetstream/load_plugins.go | 3 + .../analysis/20200210105400_Analysis.go | 43 +++ .../plugins/analysis/container/Dockerfile | 61 +++ .../plugins/analysis/container/go.mod | 12 + .../plugins/analysis/container/go.sum | 48 +++ .../plugins/analysis/container/kubescore.go | 59 +++ .../plugins/analysis/container/main.go | 171 +++++++++ .../plugins/analysis/container/popeye.go | 108 ++++++ .../plugins/analysis/container/routes.go | 90 +++++ .../plugins/analysis/container/run.go | 130 +++++++ .../container/scripts/kubescore-runner.sh | 16 + .../container/scripts/sonobuoy-runner.sh | 19 + .../plugins/analysis/container/sonobuoy.go_ | 92 +++++ .../plugins/analysis/container/status.go | 74 ++++ .../plugins/analysis/container/types.go | 48 +++ src/jetstream/plugins/analysis/list.go | 228 ++++++++++++ src/jetstream/plugins/analysis/main.go | 139 +++++++ src/jetstream/plugins/analysis/run.go | 188 ++++++++++ src/jetstream/plugins/analysis/status.go | 109 ++++++ .../analysis/store/analysis_store_db.go | 164 +++++++++ src/jetstream/plugins/analysis/store/main.go | 39 ++ .../plugins/kubernetes/endpoint_config.go | 19 +- .../plugins/kubernetes/get_release.go | 2 +- src/jetstream/plugins/kubernetes/go.mod | 1 - src/jetstream/plugins/kubernetes/go.sum | 23 +- .../plugins/kubernetes/helm/graph.go | 38 +- .../plugins/kubernetes/helm/release.go | 140 +++++-- 174 files changed, 6005 insertions(+), 409 deletions(-) create mode 100644 deploy/kubernetes/console/templates/analyzers.yaml rename docs/suse/{kube-terminal-dev.md => suse-extensions-dev.md} (60%) create mode 100644 src/frontend/packages/core/src/shared/services/session.service.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-reports-list-config.service.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-reports-list-source.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/services/analysis-report.types.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/services/kubernetes.analysis.service.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/services/kubescore-report.helper.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/services/popeye-report.helper.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/services/route.helper.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/store/analysis.effects.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/store/anaylsis.actions.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.theme.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.theme.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/icon-helper.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.html create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.scss create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.spec.ts create mode 100644 src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.ts create mode 100644 src/frontend/packages/suse-theme/assets/core/custom/kubescore.md create mode 100644 src/frontend/packages/suse-theme/assets/core/custom/kubescore.png create mode 100644 src/frontend/packages/suse-theme/assets/core/custom/popeye.md create mode 100644 src/frontend/packages/suse-theme/assets/core/custom/popeye.png create mode 100644 src/frontend/packages/suse-theme/assets/core/custom/sonobuoy.md create mode 100644 src/frontend/packages/suse-theme/assets/core/custom/sonobuoy.png create mode 100644 src/frontend/packages/suse-theme/assets/core/custom/sonobuoy.svg create mode 100644 src/jetstream/plugins/analysis/20200210105400_Analysis.go create mode 100644 src/jetstream/plugins/analysis/container/Dockerfile create mode 100644 src/jetstream/plugins/analysis/container/go.mod create mode 100644 src/jetstream/plugins/analysis/container/go.sum create mode 100644 src/jetstream/plugins/analysis/container/kubescore.go create mode 100644 src/jetstream/plugins/analysis/container/main.go create mode 100644 src/jetstream/plugins/analysis/container/popeye.go create mode 100644 src/jetstream/plugins/analysis/container/routes.go create mode 100644 src/jetstream/plugins/analysis/container/run.go create mode 100755 src/jetstream/plugins/analysis/container/scripts/kubescore-runner.sh create mode 100755 src/jetstream/plugins/analysis/container/scripts/sonobuoy-runner.sh create mode 100644 src/jetstream/plugins/analysis/container/sonobuoy.go_ create mode 100644 src/jetstream/plugins/analysis/container/status.go create mode 100644 src/jetstream/plugins/analysis/container/types.go create mode 100644 src/jetstream/plugins/analysis/list.go create mode 100644 src/jetstream/plugins/analysis/main.go create mode 100644 src/jetstream/plugins/analysis/run.go create mode 100644 src/jetstream/plugins/analysis/status.go create mode 100644 src/jetstream/plugins/analysis/store/analysis_store_db.go create mode 100644 src/jetstream/plugins/analysis/store/main.go diff --git a/.gitignore b/.gitignore index b34c80c94b..fd305dfd93 100644 --- a/.gitignore +++ b/.gitignore @@ -95,6 +95,7 @@ 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 # Customisations - these can be removed in the future # Left in for now to prevent these files being checked-in, if they are still present diff --git a/deploy/ci/suse-console-dev-releases.yml b/deploy/ci/suse-console-dev-releases.yml index ef2247a720..be33e148f5 100644 --- a/deploy/ci/suse-console-dev-releases.yml +++ b/deploy/ci/suse-console-dev-releases.yml @@ -66,12 +66,18 @@ resources: password: ((docker-password)) repository: ((docker-repository))/stratos-chartsync - name: kube-terminal-image - type: docker-image - source: - username: ((docker-username)) - password: ((docker-password)) - repository: ((docker-repository))/stratos-kube-terminal - + 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 @@ -199,6 +205,14 @@ jobs: tag: image-tag/v2-alpha-tag patch_base_reg: ((patch-base-reg)) patch_base_tag: ((patch-base-tag)) + - put: analyzers-image + params: + dockerfile: stratos/src/jetstream/plugins/analysis/container/Dockerfile + build: stratos/src/jetstream/plugins/analysis/container/ + tag: image-tag/v2-alpha-tag + patch_base_reg: ((patch-base-reg)) + patch_base_tag: ((patch-base-tag)) + - name: create-chart plan: - get: stratos diff --git a/deploy/kubernetes/console/templates/analyzers.yaml b/deploy/kubernetes/console/templates/analyzers.yaml new file mode 100644 index 0000000000..ca4d5a0d6d --- /dev/null +++ b/deploy/kubernetes/console/templates/analyzers.yaml @@ -0,0 +1,62 @@ +--- +{{- 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: + 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 }}" + 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: ANALYSIS_SCRIPTS_DIR + value: "/scripts" + - name: ANALYSIS_REPORTS_DIR + value: "/reports" +--- +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" diff --git a/deploy/kubernetes/console/values.yaml b/deploy/kubernetes/console/values.yaml index 2335e6ccbb..5dbf69f47d 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: @@ -120,6 +121,7 @@ images: fdbserver: stratos-fdbserver fdbdoclayer: stratos-fdbdoclayer chartsync: stratos-chartsync + analyzers: stratos-analyzers # Specify which storage class should be used for PVCs #storageClass: default diff --git a/deploy/kubernetes/custom/__stratos.tpl b/deploy/kubernetes/custom/__stratos.tpl index 732932914d..d5eb091928 100644 --- a/deploy/kubernetes/custom/__stratos.tpl +++ b/deploy/kubernetes/custom/__stratos.tpl @@ -12,6 +12,8 @@ value: "mongodb://{{ .Release.Name }}-fdbdoclayer:27016" - name: SYNC_SERVER_URL value: "http://{{ .Release.Name }}-chartsync:8080" +- name: ANALYSIS_SERVICES_API + value: "http://{{ .Release.Name }}-analyzers:8090" - name: STRATOS_KUBERNETES_NAMESPACE value: "{{ .Release.Namespace }}" - name: STRATOS_KUBERNETES_TERMINAL_IMAGE diff --git a/deploy/kubernetes/custom/custom-build.sh b/deploy/kubernetes/custom/custom-build.sh index 35d6202755..b44f9fbd41 100644 --- a/deploy/kubernetes/custom/custom-build.sh +++ b/deploy/kubernetes/custom/custom-build.sh @@ -25,4 +25,9 @@ function custom_image_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" + } \ No newline at end of file diff --git a/docs/extensions.md b/docs/extensions.md index 918c25df18..64c182e6a0 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -174,7 +174,7 @@ First, create the custom-src folder structure - from the top-level of the Strato ``` mkdir -p custom-src/frontend/app/custom -mkdir -p custom-src/frontend/assets/custom +mkdir -p /frontend/assets/custom ``` Next, run the customize task: diff --git a/docs/suse/kube-terminal-dev.md b/docs/suse/suse-extensions-dev.md similarity index 60% rename from docs/suse/kube-terminal-dev.md rename to docs/suse/suse-extensions-dev.md index fe552b064c..9ac1e4e7b3 100644 --- a/docs/suse/kube-terminal-dev.md +++ b/docs/suse/suse-extensions-dev.md @@ -18,4 +18,24 @@ you will need to edit the `src/jetstream/config.properties` file and set these t The Jetstream backend should be configured. -> Note: Ensure you set `ENABLE_TECH_PREVIEW=true` to enable the Kubernetes Terminal feature. \ No newline at end of file +> Note: Ensure you set `ENABLE_TECH_PREVIEW=true` to enable the Kubernetes Terminal feature. + + +# Enabling Security Obvervability Analyzers in local development + +You need to build the docker image for the analyzers container. + +``` +cd src/jetstream/plugins/analysis/container +docker build . -t stratos-analyzers +``` + +Now run this container - this will provide the analysis engines to Stratos: + +`docker run -d -p 8090:8090 stratos-analyzers` + +Edit your Jetstream `config.properties` file and add the following lines: + +``` +ANALYSIS_SERVICES_API=http://127.0.0.1:8090 +``` \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ae80a769d8..8bbb6d91b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1610,9 +1610,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": { @@ -1756,6 +1756,146 @@ } } }, + "@babel/helpers": { + "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.4", + "@babel/traverse": "^7.10.4", + "@babel/types": "^7.10.4" + }, + "dependencies": { + "@babel/code-frame": { + "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.4" + } + }, + "@babel/generator": { + "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.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.13", + "source-map": "^0.5.0" + } + }, + "@babel/helper-function-name": { + "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.4", + "@babel/template": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/helper-get-function-arity": { + "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.4" + } + }, + "@babel/helper-split-export-declaration": { + "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.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/highlight": { + "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.4", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "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.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.4", + "@babel/parser": "^7.10.4", + "@babel/types": "^7.10.4" + } + }, + "@babel/traverse": { + "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.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.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.4", + "lodash": "^4.17.13", + "to-fast-properties": "^2.0.0" + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "@babel/highlight": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz", @@ -2163,11 +2303,7 @@ "@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==", - "dev": true, - "requires": { - "@babel/highlight": "^7.10.1" - } + "integrity": "sha512-IGhtTmpjGbYzcEDOw7DcQtbQSXcG9ftmAXtWTu9V936vDye4xjjekktFAtgZsWpzTj/X01jocB46mTywm/4SZw==" }, "@babel/helper-function-name": { "version": "7.10.1", @@ -2181,58 +2317,110 @@ } }, "@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-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==", - "dev": true + "integrity": "sha512-5vW/JXLALhczRCWP0PnFDMCJAchlBvM7f4uk/jXritBnIa6E1KmqmtrS3yn1LAnxFBypQ3eneLuXjsnfQsgILw==" }, "@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==", - "dev": true, + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.9.0.tgz", + "integrity": "sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ==", "requires": { - "@babel/helper-validator-identifier": "^7.10.1", + "@babel/helper-validator-identifier": "^7.9.0", "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" + }, + "dependencies": { + "@babel/code-frame": { + "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.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/highlight": { + "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.4", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + } } }, "@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" + }, + "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 + } + } + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==" + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" } } }, @@ -2398,7 +2586,7 @@ "integrity": "sha512-AR0E/lZMfLstScFwztApGeyTHJ5u3JUKMjneqRItWeEqDdHWZwAOKycvQNCasCK/3r5YXsuNG25funcJDu7Y2g==", "dev": true, "requires": { - "regenerator-transform": "^0.14.2" + "@babel/helper-plugin-utils": "^7.10.1" } }, "@babel/plugin-transform-spread": { @@ -2436,7 +2624,6 @@ "integrity": "sha512-qX8KZcmbvA23zDi+lk9s6hC1FM7jgLHYIjuLgULgc8QtYnmB3tAVIYkNoKRQ75qWBeyzcoMoK8ZQmogGtC/w0g==", "dev": true, "requires": { - "@babel/helper-create-regexp-features-plugin": "^7.10.1", "@babel/helper-plugin-utils": "^7.10.1" } }, @@ -2642,6 +2829,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": { @@ -2858,12 +3053,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": { @@ -3445,12 +3640,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz", "integrity": "sha512-VV083zwR+VTrIWWtgIUpqfvVdK4ff38loRmrdDBgBT8ADXYsEZ5mPQ4Nde90N3UYatHdYoDIFb7oHzMncI02tA==", "dev": true, - "optional": true - }, - "angular2-virtual-scroll": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/angular2-virtual-scroll/-/angular2-virtual-scroll-0.4.16.tgz", - "integrity": "sha512-6NWk0DjCh4ebU8+LgfBoKYyp3McxDA/k5vTnEiV32VpVnyhN//eThZpVpggI1D2fJBqgTAY09C8v++qXHHLP7A==", "requires": { "@webassemblyjs/ast": "1.8.5", "@webassemblyjs/helper-buffer": "1.8.5", @@ -3800,7 +3989,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -4078,6 +4266,16 @@ "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", "dev": true }, + "supports-color": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", + "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==" + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==" + }, "util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", @@ -4086,21 +4284,6 @@ "requires": { "inherits": "2.0.1" } - }, - "supports-color": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", - "integrity": "sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - }, - "type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true } } }, @@ -4763,6 +4946,25 @@ "safe-buffer": "^5.2.0" }, "dependencies": { + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + }, + "normalize-url": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz", + "integrity": "sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==" + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "readable-stream": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", @@ -5090,16 +5292,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" - } } } }, @@ -5202,7 +5394,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -5213,7 +5404,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -5542,7 +5732,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "requires": { "color-name": "1.1.3" } @@ -5550,8 +5739,7 @@ "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", - "dev": true + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" }, "color-string": { "version": "1.5.3", @@ -7410,8 +7598,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { "version": "1.8.1", @@ -9315,8 +9502,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-glob": { "version": "1.0.0", @@ -10921,8 +11107,7 @@ "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "js-yaml": { "version": "3.13.1", diff --git a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts index b7af4a19ea..12db72f9ba 100644 --- a/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts +++ b/src/frontend/packages/cloud-foundry/src/features/applications/application/application-tabs-base/application-tabs-base.component.ts @@ -89,7 +89,7 @@ export class ApplicationTabsBaseComponent implements OnInit, OnDestroy { switchMap(space => this.currentUserPermissionsService.can(CfCurrentUserPermissions.APPLICATION_VIEW_ENV_VARS, this.applicationService.cfGuid, space.metadata.guid) ), - map(can => !can) + map(can => !can), ); this.tabLinks = [ diff --git a/src/frontend/packages/core/sass/components/text-status.theme.scss b/src/frontend/packages/core/sass/components/text-status.theme.scss index 68b3d1821b..a054375175 100644 --- a/src/frontend/packages/core/sass/components/text-status.theme.scss +++ b/src/frontend/packages/core/sass/components/text-status.theme.scss @@ -6,6 +6,7 @@ $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); .text-success { color: $status-success; @@ -23,6 +24,10 @@ color: $status-tentative; } + .text-info { + color: $status-info; + } + // Border colors .border-success { @@ -41,4 +46,8 @@ border-color: $status-tentative; } + .border-info { + border-color: $status-info; + } + } diff --git a/src/frontend/packages/core/sass/mat-desktop.scss b/src/frontend/packages/core/sass/mat-desktop.scss index 3538518e13..cf474347e0 100644 --- a/src/frontend/packages/core/sass/mat-desktop.scss +++ b/src/frontend/packages/core/sass/mat-desktop.scss @@ -17,6 +17,13 @@ $desktop-toggle-button-item-height: $desktop-menu-item-height - 2px; line-height: $desktop-menu-item-height; min-width: 128px; padding: 0 24px; + + &.hasIcon { + padding-left: 16px; + .mat-icon { + margin-right: 12px; + } + } } .mat-menu-panel { diff --git a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.html b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.html index 2c4b59dc38..d7ca4b06f5 100644 --- a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.html +++ b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.html @@ -16,10 +16,18 @@
-
+
-
- {{ activeTabLabel }} +
+ {{ data[0] }} +
+
+ + {{ breadcrumbDef.value }} + {{ breadcrumbDef.value }} + chevron_right +
diff --git a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.scss b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.scss index 26a2d129ce..34b3de01a3 100644 --- a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.scss +++ b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.scss @@ -79,6 +79,11 @@ $app-header-height: 56px; } .page-header-sub-nav { + $breadcrumb-opacity: .7; + $breadcrumb-hover-opacity: 1; + $breadcrumb-padding: 3px; + $font-size: 20px; + align-items: center; border-bottom: 1px solid rgba(0, 0, 0, .1); display: flex; @@ -91,7 +96,6 @@ $app-header-height: 56px; } &__title { display: none; - $font-size: 20px; font-size: $font-size; font-weight: bold; line-height: $font-size; @@ -106,4 +110,34 @@ $app-header-height: 56px; opacity: .6; width: 100%; } + &__breadcrumb { + font-size: $font-size; + font-weight: bold; + line-height: $font-size; + } + &__breadcrumb, + &__breadcrumb-separator { + opacity: $breadcrumb-opacity; + } + &__breadcrumb-separator { + font-size: 24px; + margin: 0 $breadcrumb-padding; + user-select: none; + } + &__breadcrumbs { + align-items: center; + display: flex; + justify-content: center; + } + &__breadcrumb-nolink { + opacity: $breadcrumb-hover-opacity; + } + &__breadcrumb-link { + cursor: pointer; + outline: none; + &:hover { + opacity: $breadcrumb-hover-opacity; + } + } + } diff --git a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts index 8310e6723e..42a8998120 100644 --- a/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts +++ b/src/frontend/packages/core/src/features/dashboard/dashboard-base/dashboard-base.component.ts @@ -17,7 +17,9 @@ import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-ca import { TabNavService } from '../../../../tab-nav.service'; import { CustomizationService } from '../../../core/customizations.types'; import { EndpointsService } from '../../../core/endpoints.service'; +import { IHeaderBreadcrumbLink } from '../../../shared/components/page-header/page-header.types'; import { SidePanelService } from '../../../shared/services/side-panel.service'; +import { IPageSideNavTab } from '../page-side-nav/page-side-nav.component'; import { PageHeaderService } from './../../../core/page-header-service/page-header.service'; import { SideNavItem } from './../side-nav/side-nav.component'; @@ -30,7 +32,7 @@ import { SideNavItem } from './../side-nav/side-nav.component'; export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit { public activeTabLabel$: Observable; - public subNavData$: Observable<[string, Portal]>; + public subNavData$: Observable<[string, Portal, IPageSideNavTab, IHeaderBreadcrumbLink[]]>; public isMobile$: Observable; public sideNavMode$: Observable; public sideNavMode: string; @@ -133,8 +135,9 @@ export class DashboardBaseComponent implements OnInit, OnDestroy, AfterViewInit this.tabNavService.getCurrentTabHeaderObservable().pipe( startWith(null) ), - this.tabNavService.tabSubNav$ - ); + this.tabNavService.tabSubNav$, + this.tabNavService.tabSubNavBreadcrumbs$ + ).pipe(map(([tabNav, tabSubNav, tabSubNavBreadcrumb]) => [tabNav ? tabNav.label : null, tabSubNav, tabNav, tabSubNavBreadcrumb])); // Register all health checks for endpoint types that support this entityCatalog.getAllEndpointTypes().forEach(epType => { diff --git a/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.ts b/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.ts index b74dbed831..bab4cd21dc 100644 --- a/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.ts +++ b/src/frontend/packages/core/src/features/dashboard/page-side-nav/page-side-nav.component.ts @@ -10,6 +10,7 @@ import { TabNavService } from '../../../../tab-nav.service'; import { StratosTabMetadata } from '../../../core/extension/extension-service'; import { CurrentUserPermissionsService } from '../../../core/permissions/current-user-permissions.service'; import { IBreadcrumb } from '../../../shared/components/breadcrumbs/breadcrumbs.types'; +import { map } from 'rxjs/operators'; @@ -54,7 +55,7 @@ export class PageSideNavComponent implements OnInit { } ngOnInit() { - this.activeTab$ = this.tabNavService.getCurrentTabHeaderObservable(); + this.activeTab$ = this.tabNavService.getCurrentTabHeaderObservable().pipe(map(item => item ? item.label : null)); } } diff --git a/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.html b/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.html index ea0067edbc..b0747565c1 100644 --- a/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.html +++ b/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.html @@ -14,6 +14,20 @@
{{ labelSingular && value === '1' ? labelSingular : label }}
+
+
+ info + {{ alertInfo.info }} +
+
+ warning +
{{ alertInfo.warning }}
+
+
+ error + {{ alertInfo.error }} +
+
diff --git a/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.scss b/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.scss index 482e94a189..78aa3599eb 100644 --- a/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.scss +++ b/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.scss @@ -40,4 +40,26 @@ &__limit { font-size: 18px; } + &__alerts { + cursor: pointer; + display: flex; + flex: 0; + flex-direction: column; + } + &__alert-badge { + align-items: center; + border-radius: 4px; + color: #fff; + display: flex; + font-size: 14px; + margin-bottom: 2px; + padding: 2px 4px; + + &> mat-icon { + font-size: 16px; + height: 16px; + margin-right: 2px; + width: 16x; + } + } } diff --git a/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.theme.scss b/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.theme.scss index eba218e2e2..2493db6d56 100644 --- a/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.theme.scss +++ b/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.theme.scss @@ -1,6 +1,12 @@ @mixin app-card-number-metric-theme($theme, $app-theme) { $status-colors: map-get($app-theme, status); $subdued: mat-color($app-theme, subdued-color); + + $status-colors: map-get($app-theme, status); + $status-warning: map-get($status-colors, warning); + $status-danger: map-get($status-colors, danger); + $status-info: map-get($status-colors, info); + .number-metric-card { &__icon, &__anchor, @@ -9,4 +15,16 @@ color: $subdued; } } -} + + .number-metric-card__alert-badge { + &-error { + background-color: $status-danger; + } + &-info { + background-color: $status-info; + } + &-warning { + background-color: $status-warning; + } + } +} \ No newline at end of file diff --git a/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.ts b/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.ts index f0170d28b4..fa02ab447e 100644 --- a/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.ts +++ b/src/frontend/packages/core/src/shared/components/cards/card-number-metric/card-number-metric.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; import { Store } from '@ngrx/store'; import { BehaviorSubject } from 'rxjs'; @@ -8,6 +8,14 @@ import { StratosStatus } from '../../../../../../store/src/types/shared.types'; import { UtilsService } from '../../../../core/utils.service'; import { determineCardStatus } from '../card-status/card-status.component'; +enum AlertLevel { + OK = 0, + Info, + Warning, + Error, + Unknown, +} + @Component({ selector: 'app-card-number-metric', templateUrl: './card-number-metric.component.html', @@ -26,6 +34,16 @@ export class CardNumberMetricComponent implements OnInit, OnChanges { @Input() textOnly = false; @Input() labelAtTop = false; @Input() link: () => void | string; + @Output() showAlerts = new EventEmitter(); + + @Input('alerts') + set alerts(alerts) { + if (alerts) { + this.processAlerts(alerts); + } + } + + alertInfo: any; formattedValue: string; formattedLimit: string; @@ -102,4 +120,31 @@ export class CardNumberMetricComponent implements OnInit, OnChanges { this.link(); } } + + processAlerts(alerts) { + this.alertInfo = { + info: 0, + warning: 0, + error: 0 + }; + + alerts.forEach((alert) => { + switch (alert.level as AlertLevel) { + case AlertLevel.Warning: + this.alertInfo.warning++; + break; + case AlertLevel.Error: + this.alertInfo.error++; + break; + case AlertLevel.Info: + this.alertInfo.info++; + break; + } + }); + } + + public alertsClicked() { + this.showAlerts.emit(this.alertInfo); + } + } diff --git a/src/frontend/packages/core/src/shared/components/list/list-table/app-table-cell-default/app-table-cell-default.component.ts b/src/frontend/packages/core/src/shared/components/list/list-table/app-table-cell-default/app-table-cell-default.component.ts index 979c260eb6..5d12e93d83 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-table/app-table-cell-default/app-table-cell-default.component.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-table/app-table-cell-default/app-table-cell-default.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnDestroy } from '@angular/core'; -import { Subscription, Observable } from 'rxjs'; +import { Observable, Subscription } from 'rxjs'; import { objectHelper } from '../../../../../core/helper-classes/object.helpers'; import { pathGet } from '../../../../../core/utils.service'; @@ -22,6 +22,7 @@ export class TableCellDefaultComponent extends TableCellCustom implements this.pRow = row; if (row) { this.setValue(row, this.schemaKey); + this.setSyncLink(); } } @@ -32,6 +33,7 @@ export class TableCellDefaultComponent extends TableCellCustom implements this.pSchemaKey = schemaKey; if (this.row) { this.setValue(this.row, schemaKey); + this.setSyncLink(); } } @@ -63,7 +65,7 @@ export class TableCellDefaultComponent extends TableCellCustom implements } private setSyncLink() { - if (!this.cellDefinition.getLink) { + if (!this.cellDefinition || !this.cellDefinition.getLink) { return; } const linkValue = this.cellDefinition.getLink(this.row); diff --git a/src/frontend/packages/core/src/shared/components/list/list-table/table-cell-actions/table-cell-actions.component.html b/src/frontend/packages/core/src/shared/components/list/list-table/table-cell-actions/table-cell-actions.component.html index 01680e8f60..107f5c8149 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-table/table-cell-actions/table-cell-actions.component.html +++ b/src/frontend/packages/core/src/shared/components/list/list-table/table-cell-actions/table-cell-actions.component.html @@ -3,8 +3,9 @@ - diff --git a/src/frontend/packages/core/src/shared/components/page-sub-nav/page-sub-nav.component.ts b/src/frontend/packages/core/src/shared/components/page-sub-nav/page-sub-nav.component.ts index 3ca1675980..8c66274b02 100644 --- a/src/frontend/packages/core/src/shared/components/page-sub-nav/page-sub-nav.component.ts +++ b/src/frontend/packages/core/src/shared/components/page-sub-nav/page-sub-nav.component.ts @@ -1,7 +1,8 @@ import { TemplatePortal } from '@angular/cdk/portal'; -import { AfterViewInit, Component, OnDestroy, TemplateRef, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, Input, OnDestroy, TemplateRef, ViewChild } from '@angular/core'; import { TabNavService } from '../../../../tab-nav.service'; +import { IHeaderBreadcrumbLink } from '../page-header/page-header.types'; @Component({ selector: 'app-page-sub-nav', @@ -9,6 +10,12 @@ import { TabNavService } from '../../../../tab-nav.service'; styleUrls: ['./page-sub-nav.component.scss'] }) export class PageSubNavComponent implements AfterViewInit, OnDestroy { + + @Input('breadcrumbs') + set breadcrumbs(crumbs: IHeaderBreadcrumbLink[]) { + this.tabNavService.setSubNavBreadcrumbs(crumbs); + } + @ViewChild('subNavTmpl', { static: true }) subNavTmpl: TemplateRef; constructor(private tabNavService: TabNavService) { } diff --git a/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.ts b/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.ts index 8d9cedccb8..8c075008f8 100644 --- a/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.ts +++ b/src/frontend/packages/core/src/shared/components/ssh-viewer/ssh-viewer.component.ts @@ -122,7 +122,7 @@ export class SshViewerComponent implements OnInit, OnDestroy { this.xterm.write(String.fromCharCode(parseInt(c, 16))); } } else { - console.log('Error') + console.error('Error: ', this.errorMessage) const eMsg = this.errorMessage; this.errorMessage = eMsg; } @@ -145,17 +145,17 @@ export class SshViewerComponent implements OnInit, OnDestroy { parseInt(chars[1], 16) === 93 && parseInt(chars[2], 16) === 50 && parseInt(chars[3], 16) === 59) { - let title = ''; - for (let i = 4; i < chars.length - 1; i++) { - title += String.fromCharCode(parseInt(chars[i], 16)); - } - if (title.length > 0 && title.charAt(0) === '!') { - this.errorMessage = title.substr(1); - console.log(this.errorMessage); - return true; - } - this.message = title; + let title = ''; + for (let i = 4; i < chars.length - 1; i++) { + title += String.fromCharCode(parseInt(chars[i], 16)); } + if (title.length > 0 && title.charAt(0) === '!') { + this.errorMessage = title.substr(1); + console.error(this.errorMessage); + return true; + } + this.message = title; + } return false; } } diff --git a/src/frontend/packages/core/src/shared/services/session.service.ts b/src/frontend/packages/core/src/shared/services/session.service.ts new file mode 100644 index 0000000000..1c133f2106 --- /dev/null +++ b/src/frontend/packages/core/src/shared/services/session.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { first, map } from 'rxjs/operators'; + +import { GeneralEntityAppState } from '../../../../store/src/app-state'; +import { selectSessionData } from '../../../../store/src/reducers/auth.reducer'; + +@Injectable() +export class SessionService { + + constructor(private store: Store) { } + + isTechPreview(): Observable { + return this.store.select(selectSessionData()).pipe( + first(), + map(sessionData => sessionData.config.enableTechPreview || false) + ) + } +} \ No newline at end of file diff --git a/src/frontend/packages/core/src/shared/services/snackbar.service.ts b/src/frontend/packages/core/src/shared/services/snackbar.service.ts index 13ba4eb2c6..bfa94ce28e 100644 --- a/src/frontend/packages/core/src/shared/services/snackbar.service.ts +++ b/src/frontend/packages/core/src/shared/services/snackbar.service.ts @@ -4,26 +4,31 @@ import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material/s import { SnackBarReturnComponent } from '../components/snackbar-return/snackbar-return.component'; /** - * Servicve for showing snackbars + * Service for showing snackbars */ @Injectable({ providedIn: 'root', }) export class SnackBarService { - constructor(public snackBar: MatSnackBar) {} + constructor(public snackBar: MatSnackBar) { } private snackBars: MatSnackBarRef[] = []; public show(message: string, closeMessage?: string, duration: number = 5000) { this.snackBars.push(this.snackBar.open(message, closeMessage, { - duration: closeMessage ? null :duration + duration: closeMessage ? null : duration })); } - public showReturn(message: string, returnUrl: string, returnLabel: string) { + public showReturn(message: string, returnUrl: string | string[], returnLabel: string, duration?: number) { this.snackBars.push(this.snackBar.openFromComponent(SnackBarReturnComponent, { - data: { message, returnUrl, returnLabel } + duration, + data: { + message, + returnUrl, + returnLabel, + } })); } diff --git a/src/frontend/packages/core/src/shared/shared.module.ts b/src/frontend/packages/core/src/shared/shared.module.ts index e5915d0af6..160c4d90a7 100644 --- a/src/frontend/packages/core/src/shared/shared.module.ts +++ b/src/frontend/packages/core/src/shared/shared.module.ts @@ -117,6 +117,7 @@ import { UsageBytesPipe } from './pipes/usage-bytes.pipe'; import { ValuesPipe } from './pipes/values.pipe'; import { LongRunningOperationsService } from './services/long-running-op.service'; import { MetricsRangeSelectorService } from './services/metrics-range-selector.service'; +import { SessionService } from './services/session.service'; import { UserPermissionDirective } from './user-permission.directive'; @@ -322,6 +323,7 @@ import { UserPermissionDirective } from './user-permission.directive'; InternalEventMonitorFactory, MetricsRangeSelectorService, LongRunningOperationsService, + SessionService ] }) export class SharedModule { } diff --git a/src/frontend/packages/core/tab-nav.service.ts b/src/frontend/packages/core/tab-nav.service.ts index fbdad8cad7..d11ed06666 100644 --- a/src/frontend/packages/core/tab-nav.service.ts +++ b/src/frontend/packages/core/tab-nav.service.ts @@ -5,6 +5,7 @@ import { asapScheduler, BehaviorSubject, combineLatest, Observable, Subject } fr import { filter, map, observeOn, publishReplay, refCount, startWith } from 'rxjs/operators'; import { IPageSideNavTab } from './src/features/dashboard/page-side-nav/page-side-nav.component'; +import { IHeaderBreadcrumbLink } from './src/shared/components/page-header/page-header.types'; @Injectable() @@ -21,6 +22,9 @@ export class TabNavService { private tabSubNavSubject: BehaviorSubject>; public tabSubNav$: Observable>; + private tabSubNavBreadcrumbsSubject: BehaviorSubject; + public tabSubNavBreadcrumbs$: Observable; + private pageHeaderSubject: BehaviorSubject>; public pageHeader$: Observable>; @@ -36,6 +40,10 @@ export class TabNavService { this.tabSubNavSubject.next(portal); } + public setSubNavBreadcrumbs(breadcrumbs: IHeaderBreadcrumbLink[]) { + this.tabSubNavBreadcrumbsSubject.next(breadcrumbs); + } + public setPageHeader(portal: Portal) { this.pageHeaderSubject.next(portal); } @@ -43,12 +51,13 @@ export class TabNavService { public clear() { this.tabNavsSubject.next(undefined); this.tabHeaderSubject.next(undefined); - this.tabSubNavSubject.next(undefined); + this.clearSubNav(); this.pageHeaderSubject.next(undefined); } public clearSubNav() { this.tabSubNavSubject.next(undefined); + this.tabSubNavBreadcrumbsSubject.next(undefined); } public getCurrentTabHeaderObservable() { @@ -63,7 +72,7 @@ export class TabNavService { ); } - public getCurrentTabHeader = (tabs: IPageSideNavTab[]) => { + private getCurrentTabHeader = (tabs: IPageSideNavTab[]) => { if (!tabs) { return null; } @@ -74,7 +83,7 @@ export class TabNavService { if (!activeTab) { return null; } - return activeTab.label; + return activeTab; } private observeSubject(subject: Subject) { @@ -93,6 +102,8 @@ export class TabNavService { this.tabHeader$ = this.observeSubject(this.tabHeaderSubject); this.tabSubNavSubject = new BehaviorSubject(undefined); this.tabSubNav$ = this.observeSubject(this.tabSubNavSubject); + this.tabSubNavBreadcrumbsSubject = new BehaviorSubject(undefined); + this.tabSubNavBreadcrumbs$ = this.observeSubject(this.tabSubNavBreadcrumbsSubject); this.pageHeaderSubject = new BehaviorSubject(undefined); this.pageHeader$ = this.observeSubject(this.pageHeaderSubject); } diff --git a/src/frontend/packages/store/src/actions/pagination.actions.ts b/src/frontend/packages/store/src/actions/pagination.actions.ts index b36603d1e6..0a6f540eec 100644 --- a/src/frontend/packages/store/src/actions/pagination.actions.ts +++ b/src/frontend/packages/store/src/actions/pagination.actions.ts @@ -6,6 +6,7 @@ import { PaginationClientFilter, PaginationParam } from '../types/pagination.typ export const CLEAR_PAGINATION_OF_TYPE = '[Pagination] Clear all pages of type'; export const CLEAR_PAGINATION_OF_ENTITY = '[Pagination] Clear pagination of entity'; export const RESET_PAGINATION = '[Pagination] Reset pagination'; +export const RESET_PAGINATION_OF_TYPE = '[Pagination] Reset pagination of type'; export const CREATE_PAGINATION = '[Pagination] Create pagination'; export const CLEAR_PAGES = '[Pagination] Clear pages only'; export const SET_PAGE = '[Pagination] Set page'; @@ -57,6 +58,13 @@ export class ResetPagination extends BasePaginationAction implements Action { type = RESET_PAGINATION; } +export class ResetPaginationOfType extends BasePaginationAction implements Action { + constructor(pEntityConfig: Partial) { + super(pEntityConfig); + } + type = RESET_PAGINATION_OF_TYPE; +} + export class CreatePagination extends BasePaginationAction implements Action { /** * @param seed The pagination key for the section we should use as a seed when creating the new pagination section. diff --git a/src/frontend/packages/store/src/entity-request-pipeline/entity-pagination-request-pipeline.ts b/src/frontend/packages/store/src/entity-request-pipeline/entity-pagination-request-pipeline.ts index fa82840e99..509b5f661d 100644 --- a/src/frontend/packages/store/src/entity-request-pipeline/entity-pagination-request-pipeline.ts +++ b/src/frontend/packages/store/src/entity-request-pipeline/entity-pagination-request-pipeline.ts @@ -109,7 +109,7 @@ export const basePaginatedRequestPipeline: EntityRequestPipeline = ( ); // Keep, helpful for debugging below chain via tap - // const debug = (val, location) => console.log(`${entity.endpointType}:${entity.entityKey}:${location}: `, val); + // const debug = (val, location) => console.warn(`${entity.endpointType}:${entity.entityKey}:${location}: `, val); return getRequestObjectObservable(request).pipe( first(), diff --git a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-create-pagination.ts b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-create-pagination.ts index 7e57e52957..a34f1d65cb 100644 --- a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-create-pagination.ts +++ b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-create-pagination.ts @@ -1,6 +1,6 @@ +import { CreatePagination } from '../../actions/pagination.actions'; import { entityCatalog } from '../../entity-catalog/entity-catalog'; import { EntityCatalogEntityConfig } from '../../entity-catalog/entity-catalog.types'; -import { CreatePagination } from '../../actions/pagination.actions'; import { PaginationEntityState, PaginationState } from '../../types/pagination.types'; import { spreadClientPagination } from './pagination-reducer.helper'; diff --git a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination.ts b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination.ts index 603b2b767f..18c1566775 100644 --- a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination.ts +++ b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination-reducer-reset-pagination.ts @@ -37,26 +37,68 @@ export function getDefaultPaginationEntityState(ignoreMaxed?: boolean): Paginati }; } -export function paginationResetPagination(state: PaginationState, action: ResetPagination): PaginationState { + +export function paginationResetPagination(state: PaginationState, action: ResetPagination, allTypes = false): PaginationState { const entityKey = entityCatalog.getEntityKey(action.entityConfig); - if (!state[entityKey] || !state[entityKey][action.paginationKey]) { + + if (!state[entityKey]) { return state; } - const { ids, pageRequests, pageCount, currentPage, totalResults } = getDefaultPaginationEntityState(); + + const entityState = allTypes ? + paginationResetAllPaginationSections(state, entityKey) : + paginationResetPaginationSection(state, action.paginationKey, entityKey); + + if (!entityState) { + return state; + } + const newState = { ...state }; - const entityState = { - ...newState[entityKey], - [action.paginationKey]: { - ...newState[entityKey][action.paginationKey], - ids, - pageRequests, - pageCount, - currentPage, - totalResults, - } - } as PaginationEntityTypeState; return { ...newState, [entityKey]: entityState }; } + +/** + * Reset all pagination sections of an entity type + */ +function paginationResetAllPaginationSections(state: PaginationState, entityKey: string): PaginationEntityTypeState { + return Object.entries(state[entityKey]).reduce((res, [paginationKey, paginationSection]) => { + res[paginationKey] = paginationResetPaginationState(paginationSection); + return res; + }, {} as PaginationEntityTypeState); +} + +/** + * Reset a single pagination section of an entity type + */ +function paginationResetPaginationSection(state: PaginationState, paginationKey: string, entityKey: string): PaginationEntityTypeState { + + const paginationSection = state[entityKey][paginationKey] + if (!paginationSection) { + return; + } + + const entityState: PaginationEntityTypeState = { + ...state[entityKey], + [paginationKey]: paginationResetPaginationState(paginationSection) + }; + return entityState; +} + +/** + * Reset a pagination section (retain initial/user sort/filter/etc) + */ +function paginationResetPaginationState(oldEntityState: PaginationEntityState) { + const { ids, pageRequests, pageCount, currentPage, totalResults } = getDefaultPaginationEntityState(); + const entityState: PaginationEntityState = { + ...oldEntityState, + ids, + pageRequests, + pageCount, + currentPage, + totalResults, + } + return entityState; +} diff --git a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination.reducer.ts b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination.reducer.ts index f39aa853f0..d24616c794 100644 --- a/src/frontend/packages/store/src/reducers/pagination-reducer/pagination.reducer.ts +++ b/src/frontend/packages/store/src/reducers/pagination-reducer/pagination.reducer.ts @@ -14,6 +14,7 @@ import { IgnorePaginationMaxedState, REMOVE_PARAMS, RESET_PAGINATION, + RESET_PAGINATION_OF_TYPE, SET_CLIENT_FILTER, SET_CLIENT_FILTER_KEY, SET_CLIENT_PAGE, @@ -121,6 +122,10 @@ function paginate(action, state = {}, updatePagination) { return paginationResetPagination(state, action); } + if (action.type === RESET_PAGINATION_OF_TYPE && !action.keepPages) { + return paginationResetPagination(state, action, true); + } + if (action.type === CLEAR_PAGINATION_OF_TYPE) { const clearAction = action as ClearPaginationOfType; const clearEntityType = entityCatalog.getEntityKey(clearAction.entityConfig.endpointType, clearAction.entityConfig.entityType); diff --git a/src/frontend/packages/suse-extensions/sass/_all-theme.scss b/src/frontend/packages/suse-extensions/sass/_all-theme.scss index b6b53feafa..9f21f4317f 100644 --- a/src/frontend/packages/suse-extensions/sass/_all-theme.scss +++ b/src/frontend/packages/suse-extensions/sass/_all-theme.scss @@ -1,3 +1,7 @@ +// Theming for the copmponents in the Kubernetes package + +@import '../src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.theme'; +@import '../src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.theme'; @import '../src/custom/helm/list-types/monocular-chart-card/monocular-chart-card.component.theme'; @mixin apply-theme-suse-extensions($stratos-theme) { @@ -5,6 +9,8 @@ $theme: map-get($stratos-theme, theme); $app-theme: map-get($stratos-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); } diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.spec.ts new file mode 100644 index 0000000000..f2d53545a3 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AnalysisReportRunnerComponent } from './analysis-report-runner.component'; + +describe('AnalysisReportRunnerComponent', () => { + let component: AnalysisReportRunnerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AnalysisReportRunnerComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AnalysisReportRunnerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-runner/analysis-report-runner.component.ts b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.spec.ts new file mode 100644 index 0000000000..d23be84d77 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/md.module'; + +import { AnalysisReportSelectorComponent } from './analysis-report-selector.component'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../../kubernetes.testing.module'; + +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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-selector/analysis-report-selector.component.ts new file mode 100644 index 0000000000..ffb13fa10c --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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 * as 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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.html new file mode 100644 index 0000000000..c527914e5a --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.html @@ -0,0 +1 @@ + diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.scss b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.spec.ts new file mode 100644 index 0000000000..ed39ab0acf --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.spec.ts @@ -0,0 +1,29 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AnalysisReportViewerComponent } from './analysis-report-viewer.component'; +import { KubernetesBaseTestModules } from '../kubernetes.testing.module'; + +describe('AnalysisReportViewerComponent', () => { + let component: AnalysisReportViewerComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ AnalysisReportViewerComponent ], + imports: [ + KubernetesBaseTestModules, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AnalysisReportViewerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/analysis-report-viewer.component.ts new file mode 100644 index 0000000000..735ff9b6ce --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.spec.ts new file mode 100644 index 0000000000..a93f22f618 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/md.module'; + +import { KubeScoreReportViewerComponent } from './kube-score-report-viewer.component'; +import { KubernetesBaseTestModules, KubeBaseGuidMock } from '../../kubernetes.testing.module'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; + +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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/kube-score-report-viewer/kube-score-report-viewer.component.ts b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.spec.ts new file mode 100644 index 0000000000..ccbe5c5081 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/md.module'; + +import { PopeyeReportViewerComponent } from './popeye-report-viewer.component'; +import { KubernetesBaseTestModules, KubeBaseGuidMock } from '../../kubernetes.testing.module'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; + +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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/popeye-report-viewer/popeye-report-viewer.component.ts b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.html @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-preview.component.ts new file mode 100644 index 0000000000..5abfac176e --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.spec.ts new file mode 100644 index 0000000000..0ffbf5f5ff --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/md.module'; + +import { ResourceAlertViewComponent } from './resource-alert-view.component'; +import { KubernetesBaseTestModules, KubeBaseGuidMock } from '../../../kubernetes.testing.module'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; + +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/suse-extensions/src/custom/kubernetes/analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component.ts b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/kubernetes-entity-catalog.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-catalog.ts index 816ab0bd47..e41dfd779d 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-catalog.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-catalog.ts @@ -4,6 +4,7 @@ import { } 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, @@ -13,6 +14,7 @@ import { KubeStatefulSetsActionBuilders, } from './store/action-builders/kube.action-builders'; import { + AnalysisReport, KubernetesDeployment, KubernetesNamespace, KubernetesNode, @@ -34,6 +36,7 @@ export class KubeEntityCatalog { public namespace: StratosCatalogEntity; public service: StratosCatalogEntity public dashboard: StratosCatalogEntity; + public analysisReport: StratosCatalogEntity; } /** diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-factory.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-factory.ts index 1fed3c1b20..02637d2f5a 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-factory.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-factory.ts @@ -22,6 +22,7 @@ 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; @@ -104,6 +105,13 @@ entityCache[kubernetesDashboardEntityType] = new KubernetesEntitySchema( { 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) { diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-generator.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-generator.ts index 1d8dc13dac..814f8a3e1f 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-generator.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-entity-generator.ts @@ -24,6 +24,7 @@ import { KubernetesGKEAuthFormComponent } from './auth-forms/kubernetes-gke-auth import { KubeConfigRegistrationComponent } from './kube-config-registration/kube-config-registration.component'; import { kubeEntityCatalog } from './kubernetes-entity-catalog'; import { + analysisReportEntityType, KUBERNETES_ENDPOINT_TYPE, kubernetesDashboardEntityType, kubernetesDeploymentsEntityType, @@ -35,6 +36,8 @@ import { kubernetesStatefulSetsEntityType, } from './kubernetes-entity-factory'; import { + AnalysisReportsActionBuilders, + analysisReportsActionBuilders, KubeDashboardActionBuilders, kubeDashboardActionBuilders, KubeDeploymentActionBuilders, @@ -186,6 +189,7 @@ export function generateKubernetesEntities(): StratosBaseCatalogEntity[] { generateNamespacesEntity(endpointDefinition), generateServicesEntity(endpointDefinition), generateDashboardEntity(endpointDefinition), + generateAnalysisReportsEntity(endpointDefinition), generateMetricEntity(endpointDefinition), ...generateWorkloadsEntities(endpointDefinition) ]; @@ -283,6 +287,18 @@ function generateDashboardEntity(endpointDefinition: StratosEndpointExtensionDef 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, diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.spec.ts new file mode 100644 index 0000000000..5e5fdfaf98 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/md.module'; + +import { KubernetesNamespaceAnalysisReportComponent } from './kubernetes-namespace-analysis-report.component'; +import { KubernetesBaseTestModules, KubeBaseGuidMock } from '../../kubernetes.testing.module'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.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 { KubernetesNamespaceService } from '../../services/kubernetes-namespace.service'; +import { TabNavService } from 'frontend/packages/core/tab-nav.service'; + +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/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace-analysis-report/kubernetes-namespace-analysis-report.component.ts b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace.component.ts index 3847972a17..45ab5eb4a9 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-namespace/kubernetes-namespace.component.ts @@ -8,6 +8,7 @@ import { BaseKubeGuid } from '../kubernetes-page.types'; import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; import { KubernetesNamespaceService } from '../services/kubernetes-namespace.service'; import { KubernetesService } from '../services/kubernetes.service'; +import { KubernetesAnalysisService } from '../services/kubernetes.analysis.service'; @Component({ selector: 'app-kubernetes-namespace', @@ -27,21 +28,20 @@ import { KubernetesService } from '../services/kubernetes.service'; }, KubernetesService, KubernetesEndpointService, - KubernetesNamespaceService + KubernetesNamespaceService, + KubernetesAnalysisService, ] }) export class KubernetesNamespaceComponent { - tabLinks = [ - { link: 'pods', label: 'Pods', icon: 'pod', iconFont: 'stratos-icons' }, - { link: 'services', label: 'Services', icon: 'service', iconFont: 'stratos-icons' } - ]; + tabLinks = []; public breadcrumbs$: Observable; constructor( public kubeEndpointService: KubernetesEndpointService, - public kubeNamespaceService: KubernetesNamespaceService + public kubeNamespaceService: KubernetesNamespaceService, + public analysisService: KubernetesAnalysisService, ) { this.breadcrumbs$ = kubeEndpointService.endpoint$.pipe( map(endpoint => ([{ @@ -51,5 +51,11 @@ export class KubernetesNamespaceComponent { }]) ) ); + + 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/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html index cb780e3041..c3ebbdd9e1 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.html @@ -13,7 +13,7 @@ {{ resource.age }}
- +
{{ label.name }}
{{ label.value }}
@@ -27,7 +27,10 @@
- + + + +
\ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts index 5df03aea30..75fb79c994 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.spec.ts @@ -5,6 +5,7 @@ import { SidePanelService } from '../../../shared/services/side-panel.service'; import { KubeBaseGuidMock, KubernetesBaseTestModules } from '../kubernetes.testing.module'; import { KubernetesEndpointService } from '../services/kubernetes-endpoint.service'; import { KubernetesResourceViewerComponent } from './kubernetes-resource-viewer.component'; +import { ResourceAlertViewComponent } from './../analysis-report-viewer/resource-alert-preview/resource-alert-view/resource-alert-view.component'; describe('KubernetesResourceViewerComponent', () => { let component: KubernetesResourceViewerComponent; @@ -12,7 +13,7 @@ describe('KubernetesResourceViewerComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [KubernetesResourceViewerComponent], + declarations: [KubernetesResourceViewerComponent, KubernetesResourceViewerComponent, ResourceAlertViewComponent], imports: KubernetesBaseTestModules, providers: [ KubernetesEndpointService, diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts index 871f4e9a17..4672958d66 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-resource-viewer/kubernetes-resource-viewer.component.ts @@ -6,10 +6,11 @@ import { filter, first, map, publishReplay, refCount, switchMap } from 'rxjs/ope 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 } from '../store/kube.types'; +import { BasicKubeAPIResource, KubeAPIResource, KubeStatus } from '../store/kube.types'; export interface KubernetesResourceViewerConfig { title: string; + analysis?: any; resource$: Observable; resourceKind: string; } @@ -44,32 +45,42 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent { 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( - map((item: any) => {// KubeAPIResource + 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') { + if (k !== 'endpointId' && k !== 'releaseTitle' && k !== 'expandedStatus' && k !== '_metadata') { newItem[k] = item[k]; } }); resource.jsonView = newItem; - resource.age = moment(item.metadata.creationTimestamp).fromNow(true); - resource.creationTimestamp = item.metadata.creationTimestamp; - - resource.labels = []; - Object.keys(item.metadata.labels || []).forEach(labelName => { - resource.labels.push({ - name: labelName, - value: item.metadata.labels[labelName] + + 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 = []; @@ -81,8 +92,13 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent { }); } - resource.kind = item.kind || props.resourceKind; - resource.apiVersion = item.apiVersion || this.getVersionFromSelfLink(item.metadata.selfLink); + resource.kind = item['kind'] || fallback.kind || props.resourceKind; + 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), @@ -123,4 +139,14 @@ export class KubernetesResourceViewerComponent implements PreviewableComponent { 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/suse-extensions/src/custom/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts index a24a892eb2..fcd9c0ba5f 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes-tab-base/kubernetes-tab-base.component.ts @@ -7,6 +7,7 @@ import { FavoritesConfigMapper } from '../../../../../store/src/favorite-config- 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({ @@ -27,16 +28,12 @@ import { KubernetesService } from '../services/kubernetes.service'; }, KubernetesService, KubernetesEndpointService, + KubernetesAnalysisService, ] }) export class KubernetesTabBaseComponent implements OnInit { - tabLinks = [ - { link: 'summary', label: 'Summary', icon: 'kubernetes', iconFont: 'stratos-icons' }, - { link: 'nodes', label: 'Nodes', icon: 'node', iconFont: 'stratos-icons' }, - { link: 'namespaces', label: 'Namespaces', icon: 'namespace', iconFont: 'stratos-icons' }, - { link: 'pods', label: 'Pods', icon: 'pod', iconFont: 'stratos-icons' }, - ]; + tabLinks = []; public isFetching$: Observable; public favorite$: Observable; @@ -44,7 +41,19 @@ export class KubernetesTabBaseComponent implements OnInit { constructor( public kubeEndpointService: KubernetesEndpointService, - public favoritesConfigMapper: FavoritesConfigMapper) { } + 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( diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.module.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.module.ts index faa30bd980..9d5c1db436 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.module.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.module.ts @@ -4,10 +4,31 @@ 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'; @@ -32,6 +53,7 @@ import { KubernetesResourceViewerComponent } from './kubernetes-resource-viewer/ 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, @@ -87,14 +109,22 @@ 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'; -import { KubeConsoleComponent } from './kube-terminal/kube-console.component'; /* tslint:disable:max-line-length */ - /* tslint:enable */ @NgModule({ @@ -115,6 +145,7 @@ import { KubeConsoleComponent } from './kube-terminal/kube-console.component'; KubernetesNamespacesTabComponent, KubernetesDashboardTabComponent, KubernetesSummaryTabComponent, + KubernetesAnalysisTabComponent, PodMetricsComponent, KubernetesNodeLinkComponent, KubernetesNodeIpsComponent, @@ -149,6 +180,18 @@ import { KubeConsoleComponent } from './kube-terminal/kube-console.component'; KubeServiceCardComponent, KubedashConfigurationComponent, KubernetesPodContainersComponent, + KubernetesAnalysisReportComponent, + KubernetesAnalysisInfoComponent, + AnalysisInfoCardComponent, + AnalysisReportViewerComponent, + PopeyeReportViewerComponent, + AnalysisReportSelectorComponent, + AnalysisReportRunnerComponent, + ResourceAlertPreviewComponent, + ResourceAlertViewComponent, + KubeScoreReportViewerComponent, + AnalysisStatusCellComponent, + KubernetesNamespaceAnalysisReportComponent, ], providers: [ KubernetesService, @@ -173,9 +216,22 @@ import { KubeConsoleComponent } from './kube-terminal/kube-console.component'; KubeServiceCardComponent, KubernetesResourceViewerComponent, KubernetesPodContainersComponent, + PopeyeReportViewerComponent, + KubeScoreReportViewerComponent, + AnalysisReportSelectorComponent, + ResourceAlertPreviewComponent, + AnalysisStatusCellComponent, ], exports: [ - KubernetesResourceViewerComponent + KubernetesResourceViewerComponent, + AnalysisReportViewerComponent, + PopeyeReportViewerComponent, + KubeScoreReportViewerComponent, + AnalysisReportSelectorComponent, + AnalysisReportRunnerComponent, + ResourceAlertPreviewComponent, + ResourceAlertViewComponent, + AnalysisStatusCellComponent, ] }) export class KubernetesModule { } diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.routing.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.routing.ts index c7681161ce..35c15386f8 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.routing.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.routing.ts @@ -1,3 +1,4 @@ +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'; @@ -24,6 +25,11 @@ import { KubernetesPodsTabComponent } from './tabs/kubernetes-pods-tab/kubernete 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: '', @@ -81,7 +87,10 @@ const kubernetes: Routes = [{ path: 'services', component: KubernetesNamespaceServicesComponent }, - + { + path: 'analysis', + component: KubernetesNamespaceAnalysisReportComponent + } ] }, { @@ -109,6 +118,18 @@ const kubernetes: Routes = [{ path: 'pods', component: KubernetesPodsTabComponent }, + { + path: 'analysis', + component: KubernetesAnalysisTabComponent + }, + { + path: 'analysis/report/:id', + component: KubernetesAnalysisReportComponent + }, + { + path: 'analysis/info', + component: KubernetesAnalysisInfoComponent + }, ] }, { diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.store.module.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.store.module.ts index f0a76a9017..4b37169198 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.store.module.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/kubernetes.store.module.ts @@ -1,12 +1,14 @@ 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([ - KubernetesEffects + AnalysisEffects, + KubernetesEffects, ]) ] }) diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-reports-list-config.service.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-reports-list-config.service.ts new file mode 100644 index 0000000000..9e68483e80 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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 * as moment from 'moment'; +import { of } from 'rxjs'; + +import { ListView } from '../../../../../store/src/actions/list.actions'; +import { AppState } from '../../../../../store/src/app-state'; +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/suse-extensions/src/custom/kubernetes/list-types/analysis-reports-list-source.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-reports-list-source.ts new file mode 100644 index 0000000000..6ccd5e3d98 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/app-state'; +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/anaylsis.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(isFetchingPage => { + if (!isFetchingPage) { + ngZone.run(() => { + store.dispatch(this.analysisAction); + }); + } + }) + } +} diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.html new file mode 100644 index 0000000000..6ff4563a31 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.scss b/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.scss new file mode 100644 index 0000000000..27dcfcf08b --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.spec.ts new file mode 100644 index 0000000000..3c1fb10915 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/md.module'; +import { AnalysisStatusCellComponent } from './analysis-status-cell.component'; +import { + AnalysisReportSelectorComponent +} from './../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.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/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/analysis-status-cell/analysis-status-cell.component.ts new file mode 100644 index 0000000000..acc74ee6d6 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.spec.ts index e70ac26fdf..68f0f28e01 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.spec.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/list-types/kubernetes-labels-cell/kubernetes-labels-cell.component.spec.ts @@ -1,8 +1,8 @@ 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'; +import { KubernetesStatus } from '../../store/kube.types'; describe('KubernetesLabelsCellComponent', () => { let component: KubernetesLabelsCellComponent; diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/analysis-report.types.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/analysis-report.types.ts new file mode 100644 index 0000000000..620640c6c9 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/services/kubernetes.analysis.service.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/kubernetes.analysis.service.ts new file mode 100644 index 0000000000..14749dc1c8 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/anaylsis.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/suse-extensions/src/custom/kubernetes/services/kubernetes.service.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/kubernetes.service.ts index 678b9eceed..09c03d154e 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/kubernetes.service.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/kubernetes.service.ts @@ -2,11 +2,11 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map, shareReplay } from 'rxjs/operators'; -import { endpointEntityType, stratosEntityFactory } from '../../../../../store/src/helpers/stratos-entity-factory'; import { PaginationMonitor } from '../../../../../store/src/monitors/pagination-monitor'; -import { PaginationMonitorFactory } from '../../../../../store/src/monitors/pagination-monitor.factory'; +import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-catalog'; import { APIResource, EntityInfo } from '../../../../../store/src/types/api.types'; -import { endpointListKey, EndpointModel } from '../../../../../store/src/types/endpoint.types'; +import { EndpointModel } from '../../../../../store/src/types/endpoint.types'; +import { KUBERNETES_ENDPOINT_TYPE } from '../kubernetes-entity-factory'; @Injectable() export class KubernetesService { @@ -14,18 +14,11 @@ export class KubernetesService { kubeEndpointsMonitor: PaginationMonitor; waitForAppEntity$: Observable>; - constructor( - private paginationMonitorFactory: PaginationMonitorFactory - ) { - // TODO: RC update with stratos entity catalog - this.kubeEndpointsMonitor = this.paginationMonitorFactory.create( - endpointListKey, - stratosEntityFactory(endpointEntityType), - true - ); + constructor() { + this.kubeEndpointsMonitor = stratosEntityCatalog.endpoint.store.getAll.getPaginationMonitor() this.kubeEndpoints$ = this.kubeEndpointsMonitor.currentPage$.pipe( - map(endpoints => endpoints.filter(e => e.cnsi_type === 'k8s')), + map(endpoints => endpoints.filter(e => e.cnsi_type === KUBERNETES_ENDPOINT_TYPE)), shareReplay(1) ); } diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/kubescore-report.helper.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/kubescore-report.helper.ts new file mode 100644 index 0000000000..b07c0e7abf --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/services/popeye-report.helper.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/popeye-report.helper.ts new file mode 100644 index 0000000000..12de4c61c1 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/services/route.helper.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/services/route.helper.ts new file mode 100644 index 0000000000..ccbb8f7bc1 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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; + }; + let res = route.snapshot.pathFromRoot.reduce(reducer, '').split('/'); + res.splice(-removeLastParts, removeLastParts); + return res.join('/'); +} diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/action-builders/kube.action-builders.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/action-builders/kube.action-builders.ts index 58ad75827c..362c418497 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/action-builders/kube.action-builders.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/action-builders/kube.action-builders.ts @@ -2,6 +2,13 @@ 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 '../anaylsis.actions'; import { CreateKubernetesNamespace, GeKubernetesDeployments, @@ -145,3 +152,35 @@ export interface KubeDashboardActionBuilders extends OrchestratedActionBuilders 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/suse-extensions/src/custom/kubernetes/store/analysis.effects.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/analysis.effects.ts new file mode 100644 index 0000000000..4f8a7d24c2 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/analysis.effects.ts @@ -0,0 +1,264 @@ +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 } from '../../../../../store/src/app-state'; +import { entityCatalog } from '../../../../../store/src/entity-catalog/entity-catalog'; +import { ApiRequestTypes } from '../../../../../store/src/reducers/api-request-reducer/request-helpers'; +import { NormalizedResponse } from '../../../../../store/src/types/api.types'; +import { + StartRequestAction, + WrapperRequestActionFailed, + WrapperRequestActionSuccess, +} 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 './anaylsis.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/suse-extensions/src/custom/kubernetes/store/anaylsis.actions.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/anaylsis.actions.ts new file mode 100644 index 0000000000..e9da8d7a27 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/anaylsis.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/suse-extensions/src/custom/kubernetes/store/kube.getIds.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kube.getIds.ts index f08ff16fb3..898062b926 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kube.getIds.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kube.getIds.ts @@ -14,7 +14,7 @@ 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.log(`Kube entity does not have a kubeId, this is probably a bug: `, entity); + console.warn(`Kube entity does not have a kubeId, this is probably a bug: `, entity); } return func(...args); } diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kube.types.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kube.types.ts index b6b2721e86..3a4a5e60b9 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kube.types.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kube.types.ts @@ -484,3 +484,30 @@ export interface HostPath { 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/suse-extensions/src/custom/kubernetes/store/kubernetes.actions.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kubernetes.actions.ts index cec88d6f34..b35b07a92b 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kubernetes.actions.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kubernetes.actions.ts @@ -81,7 +81,6 @@ 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' @@ -371,5 +370,3 @@ export class FetchKubernetesChartMetricsAction extends MetricsChartAction { ); } } - - diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kubernetes.effects.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kubernetes.effects.ts index 5f47b4a890..e839283471 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kubernetes.effects.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/store/kubernetes.effects.ts @@ -127,7 +127,7 @@ export class KubernetesEffects { endpointIds: [action.kubeGuid], url: error.url || url, eventCode: error.status ? error.status + '' : '500', - message: 'Kubernetes API request error', + message: 'Kubernetes Dashboard request error', error }) ])); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.theme.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/analysis-info-card/analysis-info-card.component.ts new file mode 100644 index 0000000000..56fcba9997 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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 * as 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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.html @@ -0,0 +1,7 @@ + +
+
+ +
+
+
diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.spec.ts new file mode 100644 index 0000000000..0a35b86689 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.spec.ts @@ -0,0 +1,38 @@ + +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesAnalysisInfoComponent } from './kubernetes-analysis-info.component'; +import { AnalysisInfoCardComponent } from './analysis-info-card/analysis-info-card.component'; +import { KubernetesBaseTestModules, KubeBaseGuidMock } from '../../../kubernetes.testing.module'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; + +describe('KubernetesAnalysisInfoComponent', () => { + let component: KubernetesAnalysisInfoComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ KubernetesAnalysisInfoComponent, AnalysisInfoCardComponent ], + imports: [ + KubernetesBaseTestModules, + ], + providers: [ + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesAnalysisInfoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-info/kubernetes-analysis-info.component.ts new file mode 100644 index 0000000000..76cce96ccc --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.spec.ts new file mode 100644 index 0000000000..f1f2d4fd8c --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.spec.ts @@ -0,0 +1,32 @@ + +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { KubernetesAnalysisReportComponent } from './kubernetes-analysis-report.component'; +import { KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { AnalysisReportViewerComponent } from './../../../analysis-report-viewer/analysis-report-viewer.component'; + +describe('KubernetesAnalysisReportComponent', () => { + let component: KubernetesAnalysisReportComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ KubernetesAnalysisReportComponent, AnalysisReportViewerComponent ], + imports: [ + KubernetesBaseTestModules, +// MDAppModule + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(KubernetesAnalysisReportComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.theme.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.ts new file mode 100644 index 0000000000..4ca7a3fc19 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-report/kubernetes-analysis-report.component.ts @@ -0,0 +1,80 @@ +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() { + 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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.html new file mode 100644 index 0000000000..42ebd5c5d8 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.scss b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.spec.ts new file mode 100644 index 0000000000..b13da9b832 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/md.module'; + +import { KubernetesAnalysisTabComponent } from './kubernetes-analysis-tab.component'; +import { KubernetesBaseTestModules, KubeBaseGuidMock } from '../../kubernetes.testing.module'; +import { KubernetesAnalysisService } from '../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../services/kubernetes-endpoint.service'; +import { AnalysisReportViewerComponent } from './../../analysis-report-viewer/analysis-report-viewer.component'; +import { TabNavService } from 'frontend/packages/core/tab-nav.service'; + +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/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/tabs/kubernetes-analysis-tab/kubernetes-analysis-tab.component.ts new file mode 100644 index 0000000000..f0db6bbd91 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.spec.ts index 4777afe99b..96996ee411 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.spec.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.spec.ts @@ -1,8 +1,10 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TabNavService } from 'frontend/packages/core/tab-nav.service'; -import { HelmReleaseProviders, KubernetesBaseTestModules } from '../../../kubernetes.testing.module'; +import { HelmReleaseProviders, KubernetesBaseTestModules, KubeBaseGuidMock } from '../../../kubernetes.testing.module'; import { HelmReleaseTabBaseComponent } from './helm-release-tab-base.component'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../../services/kubernetes-endpoint.service'; describe('HelmReleaseTabBaseComponent', () => { @@ -15,7 +17,10 @@ describe('HelmReleaseTabBaseComponent', () => { declarations: [HelmReleaseTabBaseComponent], providers: [ ...HelmReleaseProviders, - TabNavService + TabNavService, + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, ] }) .compileComponents(); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts index 9a838e889f..b84d708c7c 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/helm-release-tab-base/helm-release-tab-base.component.ts @@ -7,12 +7,14 @@ import { catchError, map, share, switchMap } from 'rxjs/operators'; import { LoggerService } from '../../../../../../../core/src/core/logger.service'; 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 { AppState } from '../../../../../../../store/src/app-state'; import { entityCatalog } from '../../../../../../../store/src/entity-catalog/entity-catalog'; import { EntityRequestAction, WrapperRequestActionSuccess } from '../../../../../../../store/src/types/request.types'; import { kubeEntityCatalog } from '../../../kubernetes-entity-catalog'; import { KubernetesPodExpandedStatusHelper } from '../../../services/kubernetes-expanded-state'; +import { KubernetesAnalysisService } from '../../../services/kubernetes.analysis.service'; import { KubernetesPod, KubeService } from '../../../store/kube.types'; import { KubePaginationAction } from '../../../store/kubernetes.actions'; import { HelmReleaseGraph, HelmReleaseGuid, HelmReleasePod, HelmReleaseService } from '../../workload.types'; @@ -26,6 +28,7 @@ import { HelmReleaseHelperService } from '../tabs/helm-release-helper.service'; styleUrls: ['./helm-release-tab-base.component.scss'], providers: [ HelmReleaseHelperService, + KubernetesAnalysisService, { provide: HelmReleaseGuid, useFactory: (activatedRoute: ActivatedRoute) => ({ @@ -43,8 +46,6 @@ export class HelmReleaseTabBaseComponent implements OnDestroy { private sub: Subscription; - // private connection: Connection; - public breadcrumbs = [{ breadcrumbs: [ { value: 'Workloads', routerLink: '/workloads' } @@ -53,23 +54,28 @@ export class HelmReleaseTabBaseComponent implements OnDestroy { public title = ''; - tabLinks: IPageSideNavTab[] = [ - { link: 'summary', label: 'Summary', icon: 'helm', iconFont: 'stratos-icons' }, - { link: 'notes', label: 'Notes', icon: 'subject' }, - { link: 'values', label: 'Values', icon: 'list' }, - { link: '-', label: 'Resources' }, - // { link: 'graph', label: 'Overview', icon: 'share' }, - { link: 'pods', label: 'Pods', icon: 'pod', iconFont: 'stratos-icons' }, - { link: 'services', label: 'Services', icon: 'service', iconFont: 'stratos-icons' } - ]; + tabLinks: IPageSideNavTab[]; + constructor( public helmReleaseHelper: HelmReleaseHelperService, private store: Store, private logService: LoggerService, - private snackbarService: SnackBarService + private analysisService: KubernetesAnalysisService, + private snackbarService: SnackBarService, + sessionService: SessionService ) { 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: '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' } + ]; const releaseRef = this.helmReleaseHelper.guidAsUrlFragment(); const host = window.location.host; @@ -132,21 +138,23 @@ export class HelmReleaseTabBaseComponent implements OnDestroy { const releaseServicesAction = kubeEntityCatalog.service.actions.getInWorkload( this.helmReleaseHelper.releaseTitle, this.helmReleaseHelper.endpointGuid, - ) + ); this.populateList(releaseServicesAction, svcs); } - const resources = { ...manifest }; - resources.endpointId = this.helmReleaseHelper.endpointGuid; - resources.releaseTitle = this.helmReleaseHelper.releaseTitle; + // 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( - resources.releaseTitle, - resources.endpointId, + this.helmReleaseHelper.releaseTitle, + this.helmReleaseHelper.endpointGuid, ); - this.addResource(releaseResourceAction, resources); + 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') + this.snackbarService.show('Errors were found when parsing this workload. Not all resources may be shown', 'Dismiss'); } } } @@ -181,7 +189,7 @@ export class HelmReleaseTabBaseComponent implements OnDestroy { 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) + const entityId = action.entity[0].getId(resource); newResources[entityId] = newResource; }); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/icon-helper.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/icon-helper.ts new file mode 100644 index 0000000000..ea2d9e6df0 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/icon-helper.ts @@ -0,0 +1,77 @@ +export function getIcon(kind: string) { + const rkind = kind || 'Pod'; + if (iconMappings[rkind]) { + return iconMappings[rkind]; + } else { + return iconMappings.default; + } +} + +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: 'config_map', + font: 'stratos-icons' + }, + ServiceAccount: { + name: 'lock', + font: 'Material Icons', + fontSet: 'material-icons' + }, + default: { + name: 'collocation', + font: 'stratos-icons' + } +}; \ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.html b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.scss b/src/frontend/packages/suse-extensions/src/custom/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/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.spec.ts new file mode 100644 index 0000000000..a2c29af062 --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.spec.ts @@ -0,0 +1,42 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HelmReleaseAnalysisTabComponent } from './helm-release-analysis-tab.component'; +import { AnalysisReportSelectorComponent } from './../../../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { KubernetesBaseTestModules, KubeBaseGuidMock } from '../../../../kubernetes.testing.module'; +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { AnalysisReportViewerComponent } from '../../../../analysis-report-viewer/analysis-report-viewer.component'; +import { HelmReleaseProviders } from '../../../../kubernetes.testing.module'; +import { TabNavService } from 'frontend/packages/core/tab-nav.service'; + +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/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.ts new file mode 100644 index 0000000000..beb9436e4c --- /dev/null +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component.ts @@ -0,0 +1,42 @@ +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/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-helper.service.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-helper.service.ts index a7dd8aaa99..13b0507c23 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-helper.service.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-helper.service.ts @@ -10,7 +10,7 @@ import { HelmReleaseChartData, HelmReleaseGraph, HelmReleaseGuid, - HelmReleaseResource, + HelmReleaseResources, } from '../../workload.types'; import { workloadsEntityCatalog } from '../../workloads-entity-catalog'; @@ -69,7 +69,7 @@ export class HelmReleaseHelperService { ); } - public fetchReleaseResources(): Observable { + public fetchReleaseResources(): Observable { // Get helm release const action = workloadsEntityCatalog.resource.actions.get(this.releaseTitle, this.endpointGuid) return workloadsEntityCatalog.resource.store.getEntityMonitor( diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.html index 6dd8d86bda..1070d1925c 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.html +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.html @@ -3,23 +3,52 @@ all_out Fit - + + + + + - + - - /> - + + + {{ node.data.icon.name }} + {{node.label}} + {{node.data.kind}} + + + + + - \ No newline at end of file + +
+
+ Loading resources + + +
+
\ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.scss b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.scss index ec3bfddc2f..beb8c49098 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.scss +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.scss @@ -3,3 +3,9 @@ height: 100%; width: 100%; } + +@keyframes dash { + to { + stroke-dashoffset: 1000; + } +} \ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.spec.ts index 238acbf3c5..99e5683d26 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.spec.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.spec.ts @@ -5,6 +5,10 @@ import { TabNavService } from 'frontend/packages/core/tab-nav.service'; import { HelmReleaseProviders, KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; import { HelmReleaseResourceGraphComponent } from './helm-release-resource-graph.component'; +import { AnalysisReportSelectorComponent } from './../../../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { KubeBaseGuidMock } from './../../../../kubernetes.testing.module'; describe('HelmReleaseResourceGraphComponent', () => { let component: HelmReleaseResourceGraphComponent; @@ -16,11 +20,14 @@ describe('HelmReleaseResourceGraphComponent', () => { ...KubernetesBaseTestModules, NgxGraphModule ], - declarations: [HelmReleaseResourceGraphComponent], + declarations: [HelmReleaseResourceGraphComponent, AnalysisReportSelectorComponent], providers: [ ...HelmReleaseProviders, SidePanelService, TabNavService, + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, ] }) .compileComponents(); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts index d47848393b..9e73f79e75 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-resource-graph/helm-release-resource-graph.component.ts @@ -1,13 +1,22 @@ import { Component, ComponentFactoryResolver, OnDestroy, OnInit } from '@angular/core'; -import { Edge, Node } from '@swimlane/ngx-graph'; +import { Edge } from '@swimlane/ngx-graph'; import { SidePanelService } from 'frontend/packages/core/src/shared/services/side-panel.service'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; -import { filter, first, map } from 'rxjs/operators'; +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 { KubeAPIResource } from '../../../../store/kube.types'; +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'; @@ -22,6 +31,26 @@ const layouts = [ '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', @@ -31,7 +60,7 @@ export class HelmReleaseResourceGraphComponent implements OnInit, OnDestroy { // see: https://swimlane.github.io/ngx-graph/#/#quick-start - public nodes: Node[] = []; + public nodes: CustomHelmReleaseGraphNode[] = []; public links: Edge[] = []; update$: BehaviorSubject = new BehaviorSubject(false); @@ -44,31 +73,62 @@ export class HelmReleaseResourceGraphComponent implements OnInit, OnDestroy { 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, - private previewPanel: SidePanelService) { } + public analyzerService: KubernetesAnalysisService, + private previewPanel: SidePanelService) { + this.path = `${this.helper.namespace}/${this.helper.releaseTitle}`; + } ngOnInit() { // Listen for the graph - this.graph = this.helper.fetchReleaseGraph().subscribe(g => { - const newNodes = []; - Object.values(g.nodes).forEach((node: any) => { + 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); - newNodes.push({ + 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 + text: colors.fg, + icon: icon, + alerts: null, + alertSummary: {} }, - }); + }; + + // Does this node have any alerts? + this.applyAlertToNode(newNode, report) + + newNodes.push(newNode); }); this.nodes = newNodes; - const newLinks = []; + const newLinks: HelmReleaseGraphLink[] = []; Object.values(g.links).forEach((link: any) => { newLinks.push({ id: link.id, @@ -79,9 +139,48 @@ export class HelmReleaseResourceGraphComponent implements OnInit, OnDestroy { }); 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(); @@ -89,15 +188,20 @@ export class HelmReleaseResourceGraphComponent implements OnInit, OnDestroy { } // Open side panel when node is clicked - public onNodeClick(node: any) { - this.previewPanel.show( - KubernetesResourceViewerComponent, - { - title: 'Helm Release Resource Preview', - resource$: this.getResource(node) - }, - this.componentFactoryResolver - ); + 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() { @@ -138,12 +242,31 @@ export class HelmReleaseResourceGraphComponent implements OnInit, OnDestroy { } } - private getResource(node: any): Observable { + private getResource(node: CustomHelmReleaseGraphNode): Observable { return this.helper.fetchReleaseResources().pipe( filter(r => !!r), - map((r: any[]) => Object.values(r).find((res: any) => res.metadata.name === node.label && res.metadata.kind === node.kind)), + // tap(r => { + // console.log(node); + // console.log(r); + // }), + map((r: HelmReleaseResources) => Object.values(r.data).find((res) => { + // if (!res.metadata) { + // console.log(node, res); + // } + return 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/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.html b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.html index f69faf6ea9..0bfbe0568a 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.html +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.html @@ -1,8 +1,15 @@ - + + + + + + @@ -59,7 +66,8 @@
+ [alerts]="res.alerts" (showAlerts)="showAlerts($event, res)" + iconFont="{{ res.icon.fontSet || res.icon.font }}" value="{{ res.count }}">
@@ -76,12 +84,11 @@
- Loading Resources + Loading resources
-
\ No newline at end of file diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.spec.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.spec.ts index 60540500e7..ebe873f1d2 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.spec.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.spec.ts @@ -1,8 +1,12 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TabNavService } from 'frontend/packages/core/tab-nav.service'; -import { HelmReleaseProviders, KubernetesBaseTestModules } from '../../../../kubernetes.testing.module'; +import { HelmReleaseProviders, KubernetesBaseTestModules, KubeBaseGuidMock } from '../../../../kubernetes.testing.module'; import { HelmReleaseSummaryTabComponent } from './helm-release-summary-tab.component'; +import { KubernetesAnalysisService } from '../../../../services/kubernetes.analysis.service'; +import { KubernetesEndpointService } from '../../../../services/kubernetes-endpoint.service'; +import { AnalysisReportSelectorComponent } from './../../../../analysis-report-viewer/analysis-report-selector/analysis-report-selector.component'; +import { SidePanelService } from './../../../../../../shared/services/side-panel.service'; describe('HelmReleaseSummaryTabComponent', () => { let component: HelmReleaseSummaryTabComponent; @@ -13,10 +17,14 @@ describe('HelmReleaseSummaryTabComponent', () => { imports: [ ...KubernetesBaseTestModules ], - declarations: [HelmReleaseSummaryTabComponent], + declarations: [HelmReleaseSummaryTabComponent, AnalysisReportSelectorComponent], providers: [ ...HelmReleaseProviders, - TabNavService + KubernetesAnalysisService, + KubernetesEndpointService, + KubeBaseGuidMock, + TabNavService, + SidePanelService ] }) .compileComponents(); diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts index a302a6fd2a..3b3d8ce7c8 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/release/tabs/helm-release-summary-tab/helm-release-summary-tab.component.ts @@ -1,25 +1,32 @@ import { HttpClient } from '@angular/common/http'; -import { Component, OnDestroy } from '@angular/core'; +import { Component, ComponentFactoryResolver, OnDestroy } from '@angular/core'; import { Store } from '@ngrx/store'; import { LoggerService } from 'frontend/packages/core/src/core/logger.service'; 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 } from 'rxjs'; +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 { HelmReleaseChartData, HelmReleaseResource } from '../../../workload.types'; +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'] + styleUrls: ['./helm-release-summary-tab.component.scss'], }) export class HelmReleaseSummaryTabComponent implements OnDestroy { // Confirmation dialogs @@ -38,6 +45,8 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { private successChartColor = '#4DD3A7'; private completedChartColour = '#7aa3e5'; + public path: string; + public podChartColors = [ { name: 'Running', @@ -60,88 +69,30 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { } ]; - - public 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' - }, - 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: 'config_map', - font: 'stratos-icons' - }, - ServiceAccount: { - name: 'lock' - }, - default: { - name: 'collocation', - font: 'stratos-icons' - } - }; - // Blue: #00B2E2 // Yellow: #FFC107 private deleted = false; public chartData$: Observable; - public resources$: 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 logService: LoggerService, - private snackbarService: SnackBarService + private snackbarService: SnackBarService, + public analyzerService: KubernetesAnalysisService, + private previewPanel: SidePanelService, ) { - this.isBusy$ = combineLatest([ this.helmReleaseHelper.isFetching$, this.busyDeletingSubject.asObservable().pipe( @@ -152,6 +103,8 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { startWith(true) ); + this.path = `${this.helmReleaseHelper.namespace}/${this.helmReleaseHelper.releaseTitle}`; + this.chartData$ = this.helmReleaseHelper.fetchReleaseChartStats().pipe( distinctUntilChanged(), map(chartData => ({ @@ -162,8 +115,11 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { ) ); - this.resources$ = this.helmReleaseHelper.fetchReleaseGraph().pipe( - map((graph: any) => { + this.resources$ = combineLatest( + this.helmReleaseHelper.fetchReleaseGraph(), + this.analysisReportUpdated$ + ).pipe( + map(([graph,]) => { const resources = {}; // Collect the resources Object.values(graph.nodes).forEach((node: any) => { @@ -173,18 +129,20 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { label: `${node.data.kind}s`, count: 0, statuses: [], - icon: this.getIcon(node.data.kind) + 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$ @@ -206,14 +164,25 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { }, 'Delete' ); + + this.hasAllResources$ = combineLatest([ + this.resources$, + this.hasResources$ + ]).pipe( + map(([resources, hasSome]) => hasSome && resources && resources.length > 0) + ); } - private getIcon(kind: string) { - const rkind = kind || 'Pod'; - if (this.iconMappings[rkind]) { - return this.iconMappings[rkind]; + public analysisChanged(report) { + if (report === null) { + // No report selected + this.analysisReport = null; + this.analysisReportUpdated.next(''); } else { - return this.iconMappings.default; + this.analyzerService.getByID(this.helmReleaseHelper.endpointGuid, report.id).subscribe(results => { + this.analysisReport = results; + this.analysisReportUpdated.next(report.id); + }); } } @@ -283,4 +252,35 @@ export class HelmReleaseSummaryTabComponent implements OnDestroy { 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/suse-extensions/src/custom/kubernetes/workloads/store/workloads-entity-factory.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/store/workloads-entity-factory.ts index abce799544..9605546f9d 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/store/workloads-entity-factory.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/store/workloads-entity-factory.ts @@ -1,6 +1,6 @@ import { EntitySchema } from '../../../../../../store/src/helpers/entity-schema'; import { addKubernetesEntitySchema, KubernetesEntitySchema } from '../../kubernetes-entity-factory'; -import { HelmRelease, HelmReleaseGraph, HelmReleaseResource } from '../workload.types'; +import { HelmRelease, HelmReleaseGraph, HelmReleaseResources } from '../workload.types'; export const helmReleaseEntityKey = 'helmRelease'; export const helmReleasePodEntityType = 'helmReleasePod'; @@ -22,7 +22,7 @@ export const getHelmReleaseIdByObj = (entity: HelmRelease) => getHelmReleaseId(e 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: HelmReleaseResource) => getHelmReleaseResourceId(entity.endpointId, entity.releaseTitle); +export const getHelmReleaseResourceIdByObj = (entity: HelmReleaseResources) => getHelmReleaseResourceId(entity.endpointId, entity.releaseTitle); const entityCache: { [key: string]: EntitySchema diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/store/workloads-entity-generator.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/store/workloads-entity-generator.ts index f4a0939afa..3a8f8a0181 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/store/workloads-entity-generator.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/store/workloads-entity-generator.ts @@ -5,7 +5,7 @@ import { 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, HelmReleaseResource } from '../workload.types'; +import { HelmRelease, HelmReleaseGraph, HelmReleaseResources } from '../workload.types'; import { workloadsEntityCatalog } from '../workloads-entity-catalog'; import { WorkloadGraphBuilders, @@ -62,7 +62,7 @@ function generateReleaseResourceEntity(endpointDefinition: StratosEndpointExtens schema: kubernetesEntityFactory(helmReleaseResourceEntityType), endpoint: endpointDefinition }; - workloadsEntityCatalog.resource = new StratosCatalogEntity( + workloadsEntityCatalog.resource = new StratosCatalogEntity( definition, { actionBuilders: workloadResourceBuilders diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workload.types.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workload.types.ts index f91220bad4..bf5b8e708b 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workload.types.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workload.types.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { KubernetesPod, KubeService } from '../store/kube.types'; +import { KubeAPIResource, KubernetesPod, KubeService, KubeStatus } from '../store/kube.types'; export interface HelmRelease { endpointId: string; @@ -36,11 +36,43 @@ export interface HelmReleasePod extends HelmReleaseEntity, KubernetesPod { } export interface HelmReleaseService extends HelmReleaseEntity, KubeService { } export interface HelmReleaseGraph extends HelmReleaseEntity { - nodes: {}; - links: {}; + nodes: { [key: string]: HelmReleaseGraphNode }; + links: { [key: string]: HelmReleaseGraphLink }; } -export type HelmReleaseResource = any; +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 HelmReleaseKubeAPIResource extends KubeAPIResource { + apiVersion: string; + kind: string; +} + +export type HelmReleaseResource = HelmReleaseKubeAPIResource | KubeStatus; @Injectable() export class HelmReleaseGuid { diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads-entity-catalog.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads-entity-catalog.ts index 06d3a33c04..6492abd0b9 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads-entity-catalog.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads-entity-catalog.ts @@ -1,7 +1,7 @@ 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 } from './store/workload-action-builders'; -import { HelmRelease, HelmReleaseGraph, HelmReleaseResource } from './workload.types'; +import { HelmRelease, HelmReleaseGraph, HelmReleaseResources } from './workload.types'; /** * A strongly typed collection of Workload Catalog Entities. @@ -10,7 +10,7 @@ import { HelmRelease, HelmReleaseGraph, HelmReleaseResource } from './workload.t export class WorkloadsEntityCatalog { release: StratosCatalogEntity; graph: StratosCatalogEntity - resource: StratosCatalogEntity + resource: StratosCatalogEntity } /** diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.module.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.module.ts index 5fb855b409..aaed18524c 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.module.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.module.ts @@ -18,6 +18,7 @@ import { HelmReleaseValuesTabComponent } from './release/tabs/helm-release-value import { HelmReleasesTabComponent } from './releases-tab/releases-tab.component'; import { WorkloadsStoreModule } from './store/workloads.store.module'; import { WorkloadsRouting } from './workloads.routing'; +import { HelmReleaseAnalysisTabComponent } from './release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component'; @NgModule({ imports: [ @@ -39,6 +40,7 @@ import { WorkloadsRouting } from './workloads.routing'; HelmReleaseServicesTabComponent, HelmReleaseResourceGraphComponent, HelmReleaseCardComponent, + HelmReleaseAnalysisTabComponent, ], entryComponents: [ HelmReleaseCardComponent diff --git a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.routing.ts b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.routing.ts index 8f3a4100a7..dc687ecd76 100644 --- a/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.routing.ts +++ b/src/frontend/packages/suse-extensions/src/custom/kubernetes/workloads/workloads.routing.ts @@ -12,6 +12,7 @@ import { HelmReleaseServicesTabComponent } from './release/tabs/helm-release-ser 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 { HelmReleaseAnalysisTabComponent } from './release/tabs/helm-release-analysis-tab/helm-release-analysis-tab.component'; const routes: Routes = [ { @@ -36,7 +37,8 @@ const routes: Routes = [ { path: 'values', component: HelmReleaseValuesTabComponent }, { path: 'pods', component: HelmReleasePodsTabComponent }, { path: 'services', component: HelmReleaseServicesTabComponent }, - { path: 'graph', component: HelmReleaseResourceGraphComponent } + { path: 'graph', component: HelmReleaseResourceGraphComponent }, + { path: 'analysis', component: HelmReleaseAnalysisTabComponent }, ] }, ] diff --git a/src/frontend/packages/suse-theme/assets/core/custom/kubescore.md b/src/frontend/packages/suse-theme/assets/core/custom/kubescore.md new file mode 100644 index 0000000000..a4a592e740 --- /dev/null +++ b/src/frontend/packages/suse-theme/assets/core/custom/kubescore.md @@ -0,0 +1,7 @@ +## Kube-Score + +Kube-score is a tool that performs static code analysis of your Kubernetes object definitions. + +The output is a list of recommendations of what you can improve to make your application more secure and resilient. + +[https://github.com/zegl/kube-score](https://github.com/zegl/kube-score) \ No newline at end of file diff --git a/src/frontend/packages/suse-theme/assets/core/custom/kubescore.png b/src/frontend/packages/suse-theme/assets/core/custom/kubescore.png new file mode 100644 index 0000000000000000000000000000000000000000..ee6b8f82f0d5e47468499fbc2b0a517e13c78776 GIT binary patch literal 36127 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

~E}vPT^smmzUmW@$F(ByW^2 z*mMr$D36ATg}jJ!q+rq-?1DqSaN>?ODIk$ijATj%nNYZ&9{}{MgOdEgL_J@Eo>tlfa46cqtU zQ@Ow`$9Ljb0Jb(5Olh*VCTbeoR8>Zcah7N1EbNJNY!-M7e$-q;(zbbjV3e2NSchpE z{Lg>=5OZftp7^?$(v(L+RKy|(AxIVq^kwttfOW=<4ToED3x>&|g2QKtG;a+i*w7QF zzi40Z>GqMFrY&ycvr3#<7kWO#G6f#Dfg#07>iYs)NfPNo>Fg;uw}T)UPTPF8X9!Db zDnkb1c#=>dNar0=*%Bp?+th^I>Wm!h6Q`WV5)hX<7nWsNN=iA_Px}?1Dy`3GVFXZc z4Bpu`M8-Dx>FraQKPgfIk279aO?{b&u7u`EMRaYt6lP-Aq|Hc*#d1=SpaxrKC4L%(E}N!E>*?MJkiSkWd$uggoQVY9@pr zm6HsoN}kx9@+gZVe%yNENCpoV*01rqaV9t5X!vk&lvOXkMMYVZvhs&%tSX=I7H`XC z;&odXh9p;X7%DoP=@^+DYc4tDXg*7%SrZ>&ZBLy3LXnH*Y}z%*n;#vZwZg;HTKCt_l90rc1v&@w zlr+u-UZa4(ed^s8kS{uvwdeN=!)lg?%jQK_6;*{Ql#W~>0|d$DM)1% zquJ8C_`TLx7v$+{Ao*fxM(Z$uNGY|~>uuk;b7y;5S=qQ8feQv8;c(d2TBnbT)=B3< zY3sB$%~66c4yNcF(X`e_2z!llN=FOy7*`DprEGGx__|)T2Fq|*G}+=Gm)CRKOnCO0 zXZgi%p5}|a2T2!-XGYL}i9Jdw64@N~(HbB^kj~|K?VS(!{ZlV-puGzKk4w;0VG{AF zac{{KLXfv5{fQDab&cC%VbDuZa9)bO8t^EGg?=~Jmq$PdcJDjLV^6%m_MLktoLWRk ziO1{3>vbP>YQ;%`!w{0JG}x3={7cs$|Ie;Ap6uvjPdax|5gAIr6asVX?hIuV?SmRy z8Hn=)B3hSZw2xjDLke8RsUy5lz+%DV2>ZK7`DAB5nSws!&QT!+11Xzl*X?KR#)B-F z9A)X{Wq8cv{zd^s+hKS#&A>s;x0xsTrEzy5P4%a`87^;gZOzPhr+Q=`*asvZx7ZnBd(N zl=L#Pg<~^EKCg>NzzQ%6}CRbZzayIX5Y-hpL3a*?H#XMCOL<*FU#77Fe_(3~Q zytxa3;=b#um|SIit?8unevr-;NoMUarU*BtX`I&B2?ROYAd#0x+c_Nyg;L`ceOyog zsj8|{{r&xerfDV%g+lnSrJF7s0p^_+9}hSHEPDsuTEN{dsr zogUGH4#6#RgQN@apMU)k@%QrFBme*)07*naRDmIO?Aph?xigqMV+zd;HMmXVtMKkp z1QQLGWzy16!{g7tPJ4Ghi?6?u-kyFQe|9x%*MEA%n~iuRt(69DV`8Yf9SwtNO}e1) z7?L>wA2p_dKF<@(8Kvm66_KEy`EzHX1-!NH0~#A@`N?11PgOKDVf7F-CZDX8Dshh; z8uYZVmzIefb!w`aU)1b!VqQ>uIN+`e;u?SP$29**#T9FkJ zpK0U=~j%8}5)p@ykMwqpq zwNn}OVHmTSH7SD2&`7P0ieLl^!D!Oqivxqa|9K~`Y&bx7-zbmV+Q^Nw!uVbKghwam z7>wjhY)9cT1T`Un+dP9BqlQcl;z@msqimX{?RLAzZB;KAfMl~-ZQFLr>-ATIMkP94A~Gjmwk-16~R}YHZT-98^RE3}+Pkds6fcrtz9FEW&sDZEfJ* zWlQ+|v#a^=v#kuqhj?|(dlbi{XI)azTwxIPstIesw3Q$}TAC?o@OqdO3J^SLhs?xz z&-p@;cqYrtsjd8{pFT=B8s=v|dyE%fdxzG>dhWjCMgku9_*y+7(1jxLzBof82|ypo zCQ1R0kPK zZ6sgRJil%?-2*A6G(`#dT+~%YaJ$SSd(|Bu;b3=?9c_bb-P@1Un)`38=leI+P#=?= zuq#SHIZzvGsi?A9*-v*2m~(u2xRr@)l^qkrwfHb zYH)Dy$j{T+0s|>$Y$DS+sR!V4Nn#-%eZz4M_NAHL5IFmSsuXk&XZY}oE&^^zMc9MS zIt>g-!Jz@ouh#U@nXBS|`|15$xo|cnaPzfSu)m|5_U=AD|6(Vff3b_HlUkWSdom%v z=b}~gJe3z?2*JFWQ~A+@EBM)OpXQBs)-ybsJYrPqV+Qq6fjL3UszL~oImt+-6v9sn zc$w+mDi zR6CPd-tk!MO=fwqyPs_%sdJ`SKi9OBPY9~qE*e4s=0<{C9`@7Xwx}=!5d%CzAeBND zvgp%r?rOlT71xKo++A0}uMTw4Ip|OoGAQ#;+_0vmRAfGS={zn;Wz>t5XJ(`=>9BZ~ zkLqYETidF6ciSM_+eS!bZHmRxgn2Tj@VW%em2N5`9&TMw$%;i~Osh6d0Ujj}%~q13 zM2T}Y>~|6NTNs8oWw0;C>}XM$$M9eYA(YGIir3cGUittM2n2}5VtKFEn>+gFMF+Bl z(?_mKDW=zYnAZ|u-R2G!FRW$Zl_K9c3e*|kio^PRK^ECeL; zlD9q|;@J&x?!J2^H(qmjNhs>@3Dg>F2LlNCyko;Y`qD3`>yj4p>}f6h z)oOB?G=`K2sV6Mc;HVO_veHm)8Z?H2xSjJ~6xImrv1EtRhv$__OuAQIHj{t++wb%9 z$DZb;*Vgibhn5redB#UWL0G-1v^o6m zrGil^E`ts(4F+&Cx3``h;N%7%6-!X6qcJf#BGDLNtY4t%<2 zlwUr-l?_`usExX~ac(*FF~Lbvkrb8W(|uVUU)Rl~sk6Cj=}pvBmYuw4`fUJ?QG#S6w~_+fn@HPtQ?ZUCxTz7mtgYB|;D%8Kt*>;7D{mRJ0i?o*z-E z3b#dT(8rumfT?~D(?S8Jx=qSWfoa=c`SMDCqdn1pR*K65UKZC>vOAffXIRk?l>{s? zVGPJcN0C3uORp>o&{Sa^R~b1yh=9UlKy6r18`7vI6ODluJ`^TGD-4_?GaRRzmlgtS zN7EV4kw};9eNEKDXX(>7kBl`345jojWn{~;(gzM4NKc+T`O+_`Z(I~6ReX~T;Ag5-s@`k!`j`LhQYlzH8G_&fMw`os75rPWNDK5v%SOo z@ztHY^xi%SwqoJ*YOb5^#}X%Ibk#zztGmc=-s+?;R$O!y;k*F-Cw%vORd%2mDEbDs-&H+ zR0i2j#g;8wcC2U~EQ+QjiZclk05Q*a^L+0){ow%|WJ&-@fP`e6FCriDpgWwi|9k)U z-v3Rz%e-szBjo(xt$d+KF<-bY@^neTeS5c2DwX)Bm)~YXXIpK~`7%TxQ?p6>heqxk zJtYNgJ`eZT$Jrn9(dqNh;~b?U!*cjW(2mpwKuBCOn zR~^M{wtSs`>-BmoKA+Et$K%WP2=AsA7NJlmo6Tm^hGA&iwgo_@;4q!cQMSXFu8$8R zwT7qmig(JQg{oe#BE3p+_RlO`rT*9 z;uk^+iYvKhncAO0@QJNEe zT=TPo3uVQn;UvdFV3kHbJ?rnjRJ@VT^J~qq^GpPPoh$Bm`as!l*VmJn!=*ype^Csm8xVSd3~6e zNF*kDdV2Do3ILH(@}2K|NBMldl-KJmS1J|H0w5GCkgq~~$@WYW^T9LsMd)t#^ZKb2 zFCU-g+4lx1l%4scXYGIs0r7yv;XMs}^-w(z_xSKzH>R$HfNV+d#;G}u_htCPQ@_D| z`*-8}b&Wng1R`ONo6j9S$ZRsrfB4UT!|>EJeh+ljo42jTD+ClPicF!Fu=bh;8^b|N zA?{K|o5p3DBm#cyRF2tHmRzxf&-UmxLOIzy@miNGyNrhnYS z5C-|`RfB9wNl#}Kuernw`UhlNCLSXO4?X7LiQ%c9Ba zL2J$6@EEh1EcNlouNpS3@wqIzI+}0OYpzvb4UJAxu2gRMIr^of#p_~keS%$KKU=*n zT3jZLra=IO&KH{vKVGo8OQA?PLTB_KTDp!lGEXyhs3WecBBC)KY>|F(r9XocA&*w)j zUc7jndo?keXC_kw;l3$9NY@Ix347q4b#8XFd#E^qLODdS+Kwq5T!td(weVUB^hbk- z5RfawFD_*G`I$LBd-xH0wsh5cIzA0!ZI^2)hxP3(eEpfvGdw!ROK%>fZ_*~{F-iDu zM-%LNRo0l-Lr~cWefPF(P-xmMiOZ0jzHo`j$r(0xwBA^ebiRM)c9mr_nH=XY4%`$F z3BYeiT0Jh>{XVwGqZ|zT=<&J;N$}L_gHXz5weDo4G+iE(2NGdU45Z1G6+V|-R-w+a zt*BJ4KG%krhp<;;h`X;HrZr6GY7+$k!d{5@^db`T^Un@Ju4Gfb20)_GXnJmLZm_Pd zuCkohc~=4Co_p@mV`F1OLWt`!gvDxY)(7I&3N{z2A>4+>Z9ve){O=Leii4d+RW~$( zzDb849i77E4X|(T4mNhQu2EON`k-)2!M!`T@}OQ0e@uHFH>b`eY|F*((91h1qoXpm7vN%P|FEN&J%4M-4 z$(OGh*{+r_(XbyWKSEmMP6DWaiz8{$^Ci4~k44a9EGhTuAeqb>6iQd~g^@@k+t}Ec z`tv{kbNv^8@fV*C0BLP)WqNu#Q>|80LI|aGZE}2}tjHA{Y+Ga824!*mv%U!rPC&i_ zFP}_v{8E}7J9e;lXE$z-Yc2ZthzIR=Sv>LRK|VNrf&cpL=NL;lB!ZImXpPFcwRzE3 zi#HQ43!iB$($l;&;-aQi%CAUEWg9}TfP?5ErBrNbo&XoXfb3vE%KG}lLIO`Ftw zxhc-6i)jj#B0=|Z(My7=me^Mk=z`{$Nzf;jHpNPXywaqRo^zj*&76BE-k#Um>)SVvHD98RC_Bb(3v%Dn{)NweEUcRa#_by0SD zU9`FkVur-670S8$^psdEz+5B((ST5z9d48Rqj8QEM{7-m%MgPoU89EN7yy-6tmml**7QC@KzE5@I2XNI)(LJFB*)Z+MPe`AP{drfJS?+qP}0v9a;f10d_x zt>g6R)71?dHuO(TO;uB=lm#_?nz6JdTb3jOa3|BI3!`#hJvGO8oeT4kp z<&mcN6jNE2VX&iTBVT^U0YN*`X=zZ^nu_C4kp_-opoF9%1UaQC&d+04T2rdpIP-ZUw`me}xd=<}+Es)QtoM6) zVMx;$GO(n&?MJPVq;k#`2PNt^XpXrEdgQY8G2M;>JCmt0HCLeO)Mll(#C@1zalz7B z-P|`3}EHPcKP)6Wr zP1bg(+$PUGm&)+!n@4%pMzQl-KW!iQaO=n0Jir8XaQ%nfTN>f%fZcU-7 zIu4GiX|7p@LBKTdN(fsnLS8SYQ)#9$4l_BE#?b9&!W1SI=PI?@R_~=D;zA1Nu2X$l z2qrU%xooA@q7XFM+!jPG@eOB7wLJ2|kGtLOV!2%IFPF=OL?ZF&0+4OnwmF$hrdKJI z1|kcepC3vyGOO6$Ca~^M>3>bdQu0B6hU0x{d|oe)J#-)Oc$BrZRHLHVzO{=-9@x*x zv%QQ@&d@)l2zd;A*3v!4D+Y1?=am3$m{bjzt=P;|6;q`$V+uY<=jhMo$tXw`ili$Q z3QAL07{att2VHAUo$KY5w|~LLu1*3=l$lt{ay0Z`9-;s85Ot=(H#W5Mm3V+ULtr`% zN;!8NFcxShAtkmnm~#|URfmakg(0Omk<2iZ&6C%H=|YigrAkR_3QAG=kgU*0tw=OV zS9?3<>L|rZhB<17Uxi`4WsoarR8S&s!^{f-xq?lxboELaqaGTf-qq0{tYI=$W;R<( zpohFB&0&QUOCv!H$#pVNkw~Q2*w{Gu{PWM-Pd)Y2rwc%~Y}um!>aYH4EEbFP&CJZS zDWxPpreM=Io~6{|!gAjk_ew~?WXj>aOKAos3Usu$(zCUTpvMDi`Hv-s4MD$$Cl4Ru z$gvar_@7^ADx;XlNIK&t;uZ;(^XVKvoSegB7+lI1Ih!kTF`H+qP@=3f3Vl~fFDItw zc;oF~aB$x)4(#q(HUng9%?IZ$GC4g|g8s72V4*-? zq0Grlo^#n86ZsMqrBJtdRF&hPl}*?y@cHzWvRw_>{+a`FyFKDkNQzZSab65k3PB=Z zkO-`fGhS7a@tFeSb0wsJ`goX#e~HeCkn~Lym`<0jya%7pSL*8O8uR=8dbwxK-*o`# z?Chkyy*&?{7#J9Muw1SU@wFYrz(j$v6DN4bfL}5^SLWyEQdAwyuAa>#5;24ntH17X z6)mAO>pEL`;?aZr;{B71P0lbl?GW;qB>XoaNpX#!@w>BWPA0P`p!A_!#NEY9&09y` z=cU(=u)d>}hFJLaOTC4_wjGAY#>r%|?1}`br`EG{ry}u%oRT9X1q4Hm=KYx*r!zUu zXL6iR=b5n`oMkR|&15p1K6{>ISkM}m#C$cmbb-Q>EKIY$&2uiODtSesCFC_|sSgtI z87O^s54sdElhX{(R!9{bq!83cJotSUn#DA*piqHd_9p2czdDJ~G|hZlTie)j(^snk zKmZOMI#ho7<(DrQhM}%;WEq;V$rd2tTlxNDA)r*%oF6ICJCegR4fbx|N>d`XmixGc zD8y?TJaAw)+qQ0Gd}@}NykaV=342W}v9wv;>KYv;r8T)#T0))475K?ZZ*u6s9%B1< zVcqU_m6C~x2}VaJ(N&v!<58l9T+!+v0T@zXBPb%sY8WcpoGX?%l`U{?HpO7R$VAn? z?cC8~t6X&$n1!i~AnF&i#taexiN{p8T?tv!z!GGO4%PY16$=`yYw+N9arYfXDVa={ zxipqRDUH`9X-`gnlOc0;x*0HnRWy_!y^ zFMB+mbfHkFTL@VP#*&Q8wbPlf?g)pKE;zh-CP}7HrLCozwzg)1KJOZU+~PrPY^>w6 z58cO+V;_*s7a5&(s1F(>{BlW@pb#u7>Azam$+NvY_rl9;=xn3CsSa@)hE5B?#laCS z504TsO*Z@7wUMwZ@Q&ns!)6}Ac-3LJT%o^gb0S;d{n->_xdK^7QNF`xKx@cVH2I2V zGNWkDG>x_9!CSNdVZV<9`**WvXAf_@^GlK? z#mJl>NwanTe4 zLoWXQl`t5Y&eA)4wei&Lb~`OCEtA1uP}SGhe>wrAsi}#F9(riHSS((6|NZw5F6{G6 zL2+go- zq^2!y5c4j9=UIi|A%Ku&vfk%mTO`PiNRX(@;&dj*^COe|?LYj8Xe`1PKKC#_!?=a$ zRglT%85|mAbbN~6?rNjakhkp}FXS~G1bHDDv=u*}&2oG;#X!Emg=~Seqn1@NeYsbm zsyRPyGm#Y3g$ZV$aU4bZ=VE z2WKu&s46DXiip?1_?TJO3nioS?w@HaA!zWp*b$G=<+fPw_p;vWro-={*$}t|JP`5{ zFbuwR{u0mrCPTV&;)>cX;W|LeR7^dDdSno0k+=l*>pf)-orv+1vqPJM) zNUp@u$r<{y1*Yd4_&zC1X_zZ&QYD+wIYm=M(i}C2_`qX02=qr5SO@`CN0Z5`D@}Gm zw_tBq7_U|P_q+Khgb-v(Hm3(OIP=#R54vz!Mokk=fBbio1&0fxc?xCcn(ZXhfk5Eg z{{8zu-2h_ScExd=3r$T;)5F8Vt@HMW>13YKnF5=df-7dsg%Au*D=v+ssW_UB_IfsU zbz-{BH2}Fy05PTH-ksg-+p~?cy?s=wHseW$=7>ei`|%Z7Qq=Sm@5Vt6NJGORV-c(XuFq=wq zX<&%m(GXk00d6I?TLTV3K^UAb*}R`A@Y-yO4`$O$l`B-V=99d$m4;kJacN31o~k9< z>p~_Guf}UB1UHVR9A%KJT&07eAuk&m%w_3rUygekopU(VpSrRSBVL8uSW5YwU^ZLj z^ib~VfK4z!8~^|y07*naR4gHMC=?nT7#NsnX=zyzDrMCG$gyL`IC${jK%r2WSO5@! zOi44HlW4Vae1-;24W_v~nL}%+i$~~cZ$XM>9%A$BxRj#4KF;1<+j!~qBMc0WkuKE| zqhYU!L}el~>z?1=iC+jzYBSN5`c=6Qr4;Q_ z^3B#ds!H*r7hfkB4Ddhy4_{|}dlQCOf)NmcV!6!l;3#9G6Fd?R(&4sHcIBq-10g6$ zgR^Caw`S)!GMDDqT!z_7^;5UKG&MTv!i3H6oFEa9G=^)*cCYCG*Nzkr6sv-{+|@JP zvo1_qofj#@T`dh40xDW?cBsJkeEuUAu;{FF<9GA1T}}chRpHD~ku&|ttH#|lRd;vy z2le&!>GkW^ugG<;8UWe1Zy!@rQ)92a_S)FB%Dkhq6)uisDC`d4GgqP(OJi$`@wpPy z=_*o6ni?DEXlGOSOy-)M(BBNU4Q(*rJC@cV!f37QF~3W?uS2m5z8|9AHVrk z*0(h+UVTra>YR!xsvymG3T>ccm0fE%x z)B=cfO2O~9HsB0T^4t%9M!@gm4}a&&v^FNdQ6IzjX=Y}UoIKl0SIAGd$6BTUdE>^7E2gix zY5=6ArG?jCd#$>4>(Tkro=D6)C%R?zK~uGL>xAol5e&*U=NP-z3=^mpx?(ge(Uoz zCZdQNmG=rLE5(UZ=Qw}zEdRPGL4zSavJ!YflPV_+&X%hDd@jvTC+0Yv&QWxJJ%QuH z@1f$)O7DMtsbv6+ko#okY z{{W>l-~8>Tsf&beu(t#i$Km{?%QT|7H|WLGzgm5@kmD#KIajRmax%%gGilySrb*jt z`MMiYLyl&y_#uZv3N|$RXbhXHBKmNI!P%i1rjlg<8e?uc8Una2y=VZUwPrf2C|1=q zL5WZe4Gn#V4jr1dEbG?;K$@DG^jmMeHPF)1a&%;5WJjS;Z~;st%k+)q$(91de9LFo zg@Cdx7@aNCH&&af>v6lNi-s_TSUY*k>R|~Y(J;GvHuKY0-okNe8-6A$iTOk=$^0uP zLm^Q}^i3IfDWK8qW_u*S{qYDrK_5K)C)+Z*dPu5u(b}~_$=T1 z-V6A=Zoc-VCrCuYA8Jq)WRn>#_7AZ?9H2$sAmR{$5|S|mZ)J=8U~r5RsVqr*E#kP@ z)f~0h(iS2SvX(UEZzY!$kS)UKOoddT1_JSbK}&4j!CB<{RU7(8lce)k_mgRwrEOHo8q(TqYr*Kvhus&W?4}0Lr0VYJRUVN@O;}^Z)%v7%mrI zc>Ez67bY2MjS?_2Hp%qpIA0EU2ncnhguIp@mkc`aPCCbn()CBzOfwn%GIBZ#bUWap)mBy zE3Z@@ee}^^4*&t!v15mG{P^(`&CSi{XJ=>Ym8#Y6zJGC!u~a)7>a5$Ii+!zEoGzBQ zFgSOGgHI&lxIOOqso84?vYY_ovP?QVTiLU{o6(6kU>?Ae1!xZ4xF^K#G4Pn`O6Ty0 zmrDxPd0cD=2iX=4^JqLomnn!yfs5Kq@Vh=BNGY0y;2U)z>e5Ai^6Ybrk52H+mp;d) z4ehvvKmiv9hG?sl*%a~Bax&DU$J2(vu}qN{r<1%eI?Z^wvbHmCV;NG=lJL@*FjvL= z53L24r%Los)_AsvSF&S$bV=j9wKO<6nB(Gj?mA{B9*?JcdU__3$>fUO;Hm?V{rmUp z=bwK*xnaYGGiT16d0+uRW-}E&=+AM_x+s2Yxo9jw**2KTUQw=OeP=t~V6Y}5X9{|t@t^iz-V*aQ1841#NOp>H?XAE5=5+NFmGB`MhQlCzO zED^HU-r-*r-RD9Irc+hU4_~FA5)mJbA?EeHJ|28ZRY>K^WQ$h;#ImePwOYLp2m~f~ z?%et72O!(FZL2){?6W62J3IT&oH?@-YIN}6*c{`@7TTh>KPO!Xs3;hmE;2P+8@?h9 zNkcq>+cekcs}%we!!+2izMYQNCWb~Q&_Jf3$(F$FzrMsy1C|7z$H0(^U6Bx% z>tgKa^dVNk8P^ELlNC;0u1Sy>0(Ptm(iAh6WW!Pg7)w?tR<53aa5!9UZEd~y!V52y zzV@}R{rUk&cXu}rJ@n9Up-?z`?%cWU3uA8QvNq?&D(qSBS{9K#TT4E1qm|a?hAT8-z6!Y#Bm(kRnx>=#QwjoxlZeqL2(1D~x4}RK->Eq=cZPA#XbKn%lR@KlgTL5hOC^voTjutg5Rp%cKGSonwhM^ zSu16^qftmW6d)dr;Id50l`2)GNfi_o8$9NMCXqxTs5%CvibF|hE@TTFHw7N8aREWo zAmlRfp>d&bNr`J=hW0Xv1&aBoLId_Z5M+_eR;r9Ua6VsRIF%;nIFy8Brc`F8Si;eI zUOB5~2C8sncDtiB6-U=ppdEDuC^C-2k(tz8do@x@0)YUrSPau#J*HuSN;6Fp!!WSz zTQ9q9ulKR5(~ZZ{tD?=TP!(L9w8_j*5bSL9)7>7xW6F=6bt0%qpA1b`I6s`b)(KFK zj#H0YFN&qJJ#U<(VqmS_Im;y&d2zoHlNrTm(xL2V z{-O6WFIfgs2;8Pg$m=2MaZ_&yI{jWY`MtE6f<}*{G%dH3$!q|@mO=gyt$6GF6WtqrZ=;#iKegL%4}g12qq z%eG)_I&(!EM>&dOv4o@48iK4OC1ILu-q1lb9As*?rXD(zSDYQS@p}Ze63k^4B}dGc zvKqK7K^d6PVB0oMsRFse6=QEmNysqR7!I%}7UI6JpS?jJjY1)pI9+dnp0cH6PD}bq zRr>OIPUnl9&J^fRXPB#2kE7`q#t834mJ(SC3&YwSjOEgwX z+{2;nD2-wBrY9M$2rwe(8!yp2lBqSG3PD@UBDjQJHvwczaB-q?Wp)UF*XynB+`03; z*4EbYh7B8jeF0?8o;|$y;)}y>xBCOX-+zBDm-7L&dg+NA`Erne`_{{OwT7}XNM_4d zeutD2x7&pw*T7>%0R-r1ZXg;BUp-73CbF8i8DxD&8~fXviH3t1(!ey$D|rVY1cgF@ zR3=BURHkomgv-NY6pAHgt5s%alKgTu#Tx-H`x8+P*T*;*a1qfpS??RYRY{FcE=YsE zs^VC(z}aM)^VtFyvU%n{VMCX0o3;}IvC5PfGMNmvZR2*ku`G*9rE+uk1nk)mVOwVa zkHuZrz7jyODtPxo_KJK-B4p9k>?P_GivpZdFp<1EJLDQCU?v<6AA9h@2P=2tK2~)E z^6azER=028erjlFsI&ke<*MS$aFNlZ!`3Ew%LiK^sMzwl{D+hRx7&>rVhul5a7iE* zixP`OuIsmX`2KtN;?s|@aa}u|&5cAuex#HbQeFoj#d4WUu1LOAW@vbn@$qR+p1a7= z_fB%5?=q!QnKRh}m-0o9PS5dlTO(hn3$w{(Am@b!7jhf6l*}r@rE-;*=TaP*nq?qY zWU^AFdY3?B!FC(6rUPa>cZRQbxm>v2?o|sQqobpAcXxC8^l2_$ym(XJj|B|w*%V}B zqls3lphHN&Y)){Ze~!$&3Pw}Zq%mgTvNRv3t|A1K9l^=LBBRs!Yk^-kH#ZOU_Vy0{ z!5{p=9SO0_)of_??AfEvoH=vMvaBH?L{w`n0FGZuF*MW2rbhp5RZ^5~jjcW$nXDnl ziZ88k`8|ZgR~t5k5Nz7e$rnF=nAW-&Ld|z{+~9RYM1ZD-nt|KBbpz#cmC>0whKI-a z#rvoD>C10(uD6ewWSTR%B4ZbaG+kqOoNX6OW45tv+iYyRL1WuC8ry1Y+qRuFwrzjU z`{Uc|+W))v-g};NX6DQsGc4RqCUajtwAbL`h+{@lO|E(+xhiq#rlkTx9&z2$_FW!{ zZ?k^{l>=j5gMd&G(WfEIWHK%vkMvDxDoAlyqdOW?4B|Lw00Sxi&ihwP%Y+##+x-wL z8kfsK8H5=8Tm$29$J*X2`4_^!FhcN;c{K8Pw1z4jLv`stk3j^<3f?L7eLP1dqsa?w z-)j~!IBfZAO{cT>qw#nyN31I>K)Ae~YyRUZ!~s6*_HJ}^^irvG9~3j2-rlESz+cuU zpm_y3Pn0g*z6!{?uCz}qll}T-SgYowS=8J1h)yf4q}HhNv@k*2bU}#MSQ7NmsNy$l z3}CzdQ$-#wHfs}mp#YIlY44u7i6w`6E$%iTVXPna9R$zdJq(eP7f3 zJbQTiAVm&SE$!Rp{C?t;u0ScGrG=AhtXN5J-DluEv+MYleq!!Q$FH^bz4I=dgljfO z->zHR28JS6?vX!7tU`U7` zH^j91wH3W~k*D1_i+vlcRB5EV{@y+?i}`cmimbyO?`$o7rM^Cc;CRzD=l^R zpp=VT+L+rLUo3)5&IA_q%AmQ?820;x^!36Kx7)n}%Gol+bqm>2;}gUH7`ZIW4?ZoD z^M6=JtG>qvg&gE5o#7Hvtz~1b0u%745rYsW0Xd)d#XytAFsgBN6@52w$;hu<89t{& z1wM}z8qtqcbYz@(N`juD)#zvcv&>(Yo_YzTAS((J+%y4MI z!3x0rj|&S6tAJza=E=#4T4m!*&!}{LBw2=8^rnZje&MOMdhcJhiNbmnYg0N9O?}vL zW&~_xChcXFH%_TsJZH}zx|Rh?KwJfWo9_=iYuSo3vjf|%H(pcEH#dQ-y^s41+t@o# zvK;rCG~X8n_S@}Vu>f7btP3YEU`gYr;rbv(j@6(wYTu>4A5C5# zyF&)e6U4w@e#lCfI%yPCawU$;c^atFsjrueXOXlbvYZ!zstgM}JYUH?U|Lq`cjWmXLX5>gPBUbn>eu0R#l#u?7gJKceIaHmhc!3*Tx?I{ZtR!Pe)*y@VY8Y(WQ3p+~jc) zIHc%ZiHwdu1%AUUKs8?st0g;&$53NPHek4~em?zi3M7MF z%>5{(UZrf@5Lb47;OxO5o&3hlYw;pYsZ@kNhXNJ0)^eF1Ak|Y-Q(JbXAvfZu&#R6U z#M}_-yz1 z*Wb9kM-v}V5QDnk&x)Jd+7d9q?@gMqdWdpaxfM;6`lF%^caX``qBr;i_W=bAm1dC* z-Exr6-hbQ}3Hr6#>+r6n^(tHEe6=&nptVl`SQq^~XZQ>ec-i{o`?~ykmCpVuUT)#! z3<)gBD$0O}lkj8k`c&k9yZ(@HcWwHdx};q5GzfYpZ%%-CY6E|BT}FYW-STeHrhoSl zNH#`_7*yNHw#N-sp%!J&CPu;xae%ZJXFu!Db;pX3UR&j*;q_4 z<2!W(^FGN`x?oZ#E$L;|D0NuA)(;$1W#@SfHJ3Ex<)^cp!3AK3hAv!Kn3#5WuY16* zmKGNg7zNT;f6v}WVPK_~65GuEg{Fh{Kv-XTi21A3C}|4Ut(G^N*Vmr!;nru4@0jmpNN}Enc;u| zRT5Sghnx1=vA^6{^;RdIaK8BV58JDm14e#19sX?3hSu`svagPo7yn0*IdUdJSyOuQ zQu=rbgX75@8GuCsVAL=Infk6^Lh+TYiFh$a?O4dCiBv%I)Pz!M&c`eO_bu)a0fP9i zTqew{diQD$?~S7!yO#C&dB^vKQsqH_dET6TV-lc(G+nOJd7b3g;yrL^m21_m%9bkJ zo%`i@>X1Ai2*JJp5yvsFLdl9hPlW$SEP5NxqCua6m6185)+WYOQJHk7TQJZ@u4$PH z2{^ZfNW>P|`2p9R_qL}qliM>+ed`nAKcc$pV_lxpaR(Xr!Z6vd>-`N&gH;n~zXKrp zEH!zZzI3O4pPTBL1(mMyl>407!|+zEt*e9LLMfT!4D0fXo92RTT(~H!t2a|WO&lLn z0(1^8uUl+(HvYeV|BkP&x-aqZH#LWqpTuB1Y&!8xn9e&Iw`flAsu?-morlJ!syHj1 z`6omNP=fuGR2|JbB)&v;`r`~u`a=*X*scHVub=Efy+|^#%k#gG7MY_zU2T2U+i`jw zEd0i%xsKR`Pry1VGN+9xH7oz2tB#O3ddOwploEd7rvVm=_CT2=rb9!M%Oq?ZGYTD|GEF$JMimc>m8~4BO|BS*DgrlZM^$H0LX4X!NC=UslDF>OqN66c^|L6 z&$rs^+20PLJ!Z$1@pbJ{ho`4#Elt~&uq)Y;jVs5X!7U0q0LR{b#+4d!)~MN1?x>Qb24xkdc%KU*s5HTA^n)Wx`r0kz(;hJ~-J(17k6FVVf<-#H#}a z(!Hj46+&ojzl@A189PC<$(2L&yX{w(C1yxjb@kWZ?(Q!yaq(fiAI%f;@xAV^C&d@G zzHe(+1A8})_4V~YK4}GaxM8QGOu-(j==yr3oWHSDx&gh+D4JwKxHStOOQ=qj-*V#; zeBCa(s@I60_sZZrW;77$a5v)ZX=ZC_^7L5gb!Ph&XX|@4OnGn(_4|uBW4U<|eh7Ip zMJ4@m636ZICX~zl5Hv6!#dQiKnKgLsBIiy?k%=?2P+KPrfx3E@KufkETs5U3@}Rat~P>hjg6SJfYvvF#xA z>iXrvDPZl<=k4*L+IF*ptKD|1i|3P{UlF7ZsJ}js^*NL0tMy04fq{YT5rYQMe_ct3 z({f?8w0PYj3qw;vf@+>#y(*RzD3dACq!D2%u%6Mhg`oG@j5`c;IdAfbp@e&en#fPH zU*W?f{&as{-n~B^{T6}lCHS05S)JMJV69fRD%0fGm=soa7o^9Fp)g4KJByF)n|zNOW?~G zuw-X3uv6*izR9h=4fnQ(yMA7IVoAr`zODv245gHbHMZ1H?H5HS*tQk`ZOzbTX^lV; z8ZJX7z3h!2CPMDmx*i8$Ea+Du;9nH5KY`E3$Hy0(FCNo1yRdNY2`tTTfUvAmSLdYf zU4Ivl%`JS2#Sucf2^cn3DEW8iA{8f7n3^s>xNNZ@&t1~a6W!1s=(foG{9P`kCkGb<98U@Uk$YmPp^>PxP!Bj6eVQXV7pdr6>2ov@n1=R8i5@U zE6K>p>aVdMP3KS~NjXdpF?PSDximGlYf_iN5=o#zFyu_!_LHFCUP+@wfO9p}M@3mH zvO9~IN4rY|ePD+Cq$>IMJ#b!glbfk`tAksl3&2`8zG>g)3yMheib&aeFb=!bi3zyV zj=qu$Sl14gS6dvkLyAvRq)o0a_tS6~|6dEB+x-tf-4l7}EV4%=5>{V31M6kTo{$N;^U|ka=-&*ovW>SDG7L?Q>#( zN}N0BDcV53ZC~%I&XK|HblaN-=Ni&SP2KgB@8jYk?taTc_sr{XLP*?$6fkdk=yoxw zI8>A@e=A`*IWPdu(EZMgf`Za<-3JF8Y2{cJ3nODstK*SG@qEY45Dxex?3Pk>wATbam_~{NEr0>VrG7uaO^%d|O7gV+0&b?bGhS)x!W@;+zpH~(z! z(W3}VOR~>pf@rV6r&sdVZJ+T{BiZxNQ5#oBaok}lPC`0)T2oeV&(kZV29*K-XufAU zmyb_)^HjmgKW$gxCLw4EG}fz3X-5M>$Afz3sBf1Sw}!9}wV#0?)X}}Oo2;Gr%i@_A z7Z)f>V?W3#DBvtX98k`HhGgpFY3u89U|`^8c6JuBvN4U0X0tu@a887z{a-S*c;$8b z-}>Gk#fMd1+zB#F%-nMPzX#B6tIZ875&ib14$eRZ7>($d8l9T4l%ZX0H+l%ZKGmP= zzn-pMhL3(1)OQAZx>^a+m%-%%m%x_c9L`o&r1}=KXc26 zo$RZa#5}R;zk<2;tfHlQe|<*%~}-LJUc~tDmuiX&D#ZgO8L$jIydg+o-NICa}Yo5)w}bl<%Jnkz#va<-{c2{RYIV#LbM1bCAZwVuwhqPk|7k$Xq1Ih z9x@)0U!&EvM67=0bUwjXosC`c`Mzalu-L^pl7dS5fs>@xOGv5wEm_!r#apNIY4`czf3Xu(uqcH= z&Lb^S6;;_mce`r!tnU2uEc_zgJtO_MA=FZf-uVGLw=BA_%*PFPI25{UfLM`{G8(g7 z#7wr~7t1l+P)<2BS6shmMBr@w?ehs^JWcgH%72({KfeGxUs2z|2Q)`jr?84d#3cZ2 zrfb_9JTJieD3sH4kmJ$ACp|EjOwLMdNVdyT$u8G82qguo+m0$aCWe3=6J9(tG*m0c z>vj|h#DPXhL*ozNA3(2Bqo-wQd8W*tQ!OJ^+VCJ+QgvbIo)#fVMXiL>+=$W+Q`Z5h z)M@go4b1Rn`_4JVyyc&ayCnZ9n9Y;z02oXNiD9S^v_HpGpgyrTmXZMzwuXwP;ACrr z)oN{yal{ew6+L$IoTMUJ2aNr`QIU5!h{Gf_TXU%STSKtU+u9#T940Kllq@Ne_J?;D zW7dZ7bExqBD>3|QBM&#u0f5WiQqiVAmuxx8%cPc-5C`UZcrz_CV8dkfc~Sd zo|Gzx-=BQMM8q=C?HHMo;Gz|nLW_ACfx;zS^b`GHf4W@E$|*cvXmiJpW?#8y!GwQn z?pZKHD|NER@5xBqXW)ZSD3)3h9L@yukHICR@{onOwfl$BR>X3T;o`gi#wDozHx zzFe0hYG2MhH9t`l+Ru-r_l&6H2T;(S$lbTNgEp<=o1Ac z$Hm=%{IpTU_j%TT*$E|Bl1pPYmIgEfvokZXswwe%2w>V)F!3qJB6!6tI-rnf$h_!t zolQ(2-^^?-{&sy*e+2q;*ig6*;ZAsp1QFscnq8d}@p7xmLH8DPSXq(p4LBgR$z_gD zg@~)!JqqEy{lLpFA;gJ!7`hk{f_7k@r=lM@*k6sDLJ4Qjj$U3YW>(@C>w6J%m;xWxN<7CvZweB3BeE+ z3Xvpm#X~C=BL;>4w`{`&^RG8mqiyb8Edj&dbC*Z*)JNfZNep-BWGWakwTmODfL6xBRg2^?iW4rf!%n(JMXLWRj4 zM94Py_Rv_}nV)l=k=%-&K1wJlDJ3nR@zv|~23*}+crJrV2nv1!3Y7oh;bF|)?r!$= z+XoOOueglii#WE@3WvD{drFh-^Y z`b-(nUeX609D7+_%6X(mdRbl2^XqIu5hg4KcYniXN3(z3YklP@MIcAD28z(i1r7Qm z=Y*$o;N5jNb84MIXLE_Pe6r4#AI*BM#VH+G?IB{Mf1Hmx*LZm_Tbht6BpVOthr3JP z(g+Fdl*nEEL@ZyG&<)8DX?B07?-IB_7Mj_Co8`eI%}eX5F%|M3s+uJ~`W#s~nx+fO z=j;z3CA59675YgDTZ|(J7AEqZl$5kpnvH#FLZYIpTXnV7{Urq; zn7Kgj2r4@-lTN>CUl+lKhK~$4#39yJlyJbNkZwF|tUA;t@@*=!d!Vq5CJ}vKj}052 zblx^UC?8ur{a0ADS{o-W`|gI^#BbBA!T}q0WiC%_@p8)*$7k>ZZqdABO{z_yu4m|ulFZ$S03Yu?Ju0D=;(DuqsTdH7n|v+shnyJx@(*xoAn>h z{RUo2tUU%Zlw(eDao2-H+}>b;xFu!!GXi~Jt(`;_vA#iL2D8Ll@r{iU;@CsI; zpbBNt8B`4;@C6MVqe7hb)6q)c6X*QQCz7fiRiANQAkY3fAm;0>ll#+N8E&{bXtv-P znO`#!GM-m0BoOH}RESZKAA}0^bS;F-p$V*7m}u2;5G6R?6qP(!U9=zSy ztu`&q(=KCFPW3X9wi$xsy8*`R^hfQY1>V@`=t~Lka=h6c+cB`~USH~?6|F3vRro8<<{kR^F z+1WTs|Bxl3?~9Mh@%%oL)!^}b0Vfdnk4sKFIIWvM7wihdHbP?(QgWgu|MEi{ZjghV zJU+0ndUe+AODV&Bz3150yy>BlCES)~YKqyYVY@_}6qQJJJ%5X@SRiF)nLB<&h|S3I z*|Qt~e^NmT0Jp_`;YuqYqu7md+^JcUkh;#pHsH|8rKT*yHpGgyIBt`({Vjl?o?(7* zTvS?^HI==|kjS!Sqo{mWQ|*o9{h1GLl|9S6+OBFa`dp$ggR4udoT4W?HWZgEZuccd ztUiqte(LpWLoi@%U|Ov>VNbOx`u=FSjA@@9#VY9Pm$nWWxcrJ7)Et!@Ket)GtGm@PkxnQ7BLP?Msqr1vh@`mL50L${@S-zq>k~b*V{Sjv zpO4of3+o?cj)X*sPx3Nq}Z;-F60JYFS<+PoV;| z|D*vqA6r^Qq{^5yg`%RGjFbhly}ix)cO3TmG`AG6gI}C7NVQtmW)SgJ(va^yzqmcG zUm%aq9u$2W(@vF-j?XXi+lynGqUFQ_^zYa7j?u(#+Oj%_6I|`R+$dbN!P7TcdMI>%z=P z#5A9nz8vH!9j1yuDTVd@p&d$+n_4-adfLH6hN_%}2dqtK zlt?pJ!YC@w%QFAjT~6sZ14wEaEHW=W^6tjp@s-r29i(hPOxoAWxRJG?vaJ30bswv( z&C}fas-UtyRL=~wLtUs>g%`~E1`={t=?d@qnU8R`Fl1|IkpBtk+@I+E>TRYi;{-eM zdhJ5i?Oe>KgE)@UGV*l5VnI{)a;K*i2+2JsrVQXBgDGfY2(GuI2ba)EmS~4jDuBu@R`!NiCWDtcehEFwcn|WoSr2? z6*QDjG<5#Bhu4wM&X_!`nLpv>bv|97R#m-9)jH?X{}B-p0g&`|B@GSt`2avJ?!~`F ztFw&oa3T1Zb;CrnCFc_#uwaWj99|Ejme{^(KR23IzHB+gz|o}S{LUJ2G)c%qcT%*z z0d*T{II^P+hlr)!}x|PAEmXy zQWVjYf_u)Kww>ESBw0BEfy{urIhM2II|RG?ea+|`?%|WW6i(M#|E|y8j7wF;-2|pd ztq1|SosYpNX{gt?biWbKe| zx@=e~2K_A+Hys|eX@fN6)>namqZnqPTI7Ar=HSH^$qR_6{1(0J+xtdHMAWOUs94+C z-oEzqpVx!yMB5e%#%VAd#u(6O(E#E_&&Kj{n&G3nLNT_{CPO^h>|J8M&A=B(DtMTZ z@B-5QiCc4B`le@?7`e1?3v6-u%pI={G-f4aMR!6J{$rus*8GS|-K~}0@hKS!9y*BJ zGYyrb_efM-pBt*8MD*Lup$o5?7nXvmy95jlg~S9Tu(&l?ivS7IaCTU5lZ3d{`Qkd4 zMPhW-slS{IaH0JnirU4eEtM(IRKq_u|=F)fzJKyJaffe}@sbPCQ_x{|^l)w)OBE1V%HHO2Pg{`8ZF46AjY z0=9sjsXSiXqy>>VB}%+~#pfQDgeZ$M1tw+Dk9o{$_b&A0Hkc6y%>>;;Ll=K~j`_!Y z-DK***?${+MK$F`G5L+1hDplG^%u0M=j5Z6n<9IJP6!s~WhZ;5NAX8u`AmuN@eZtH zWX4o?)vG3C$5`s_yONa&;# zw5cY`L>+Cn@|T@egHpvYt>iCA<%-9zb)41kE(<>toGOj=7&DWVFR{6cQU zqWUwt)(X;L167_fMM6Q7)v!Ryzup0sG1*7(b&WNLyf<8I**$1_N^r}93mu=6&|3%L z==OLMn#$dXS-pnzMyuoF34yvC84sS|7sSaSeWc3h`f8L+pV|NN&x3!%-VaQb0vP@X zkw5D6l~mA#XJz`T3f{^B6oipwa)O+9P|U2=|7debizMK3FHf2l^A<34s;Q9NXl#dZ z(T_P;X*laYJC8w*S@gIhJl zi}hikFN!{*Z|+va1Z_oeW@sa{M1^w;S#N9gvUU^6ucf7>33$+|`)6aZn5-+FdCLdi zf88>C#dm+bb=&@z03g7`WG@B`wdsj9Pfi%^#RCalBY~owOb7J=LlvM8>C+=)3=ZD!9AhuAU`O+aaa z!^|VXt-9BDjk4L7%=?pqy-c6p%Fu#Wh+bX| zp&^fFo1tZ8{~FBaAIT^v2qwnH!o5-^+Mjs+K!8yrB%pVD^Cw|Vb9J??H?ZjLiXigb z(3T*Yz$YWsb|W}PHn8TLoaD)6*A%Lp5<2wx+E+Mkwb>L(HRxX8zK2gZQW*|t9Pj7X z|ApBmwlMad=_?7+laH(0sY!+rlf7V3b=(BKqw8r?`=p6`B`Eoe91!g@y6FhE&^(+TeL)^PXdI@ppKeR-Rq{=o z{)Nk}DZ=iaTgMnc0zwfD#`}WdJhMGcOL=T67rQ^ty1R-KDQIXSl#AtneI10+&(F^> zfB$bW;7qK@L&h8|Y-=HloR{bL$lBzuV6erfZ!n_O>P84ZVt_7rv_R6mS?$;B&!38% zTCPbPTVFxrvX#(8sfuc2^1j!kqc@iVE9FE51A8v<2^o?(wtnd>ji?xhKrg+89$0dQ zZ0m(vKPGLbG16{yB_&ndF_{>LzSk zQj7)jDn^%FbkLxY=+yF$i#EjHEdmpzqKG+K9JTxo#rx}s?ie05v7P56#_kobr0iae zXA6ty1Oa!>gaB0M^{;BmfX8p6mhJym9ou3`_z%5+0)cDfvZr9w%c`Oj9$B-S_jo6VPD2(V|OZ{>>NYYudX|;Q*giuwBt0FB~vjbayKX zF!Idtp+G96tc+94pbaXPu!v?{P(U6zj`8=Ho2xX; z>+3|^jtlR6{r6yxonQ3^KRp@Ha;+YawVW?${%^ehMCM$jM|ony-y^Cy{luy#$PbnsCb&)5Eu!~ zaIJg$iKZl_lz{Sla(6~WKQTyX6YVgx=M)t1E+KgzpPp&wX(QoU$P% zr^YP`(W*gb@(Klca)R^o6npk@9H{Y(rLZB9UU_0?E%Vny9ND0Oh^Y^h$^JIG+^4nL zTv57O2+qX{3^~24&S*pDb>Tz>FF>1qkH5EpSt)btwbdqM@tL%}k$8&N@lR6Q~lCk{TQ9 z>TWTBdG>R)-qe@I!>Jtq&(3CpDy-w-#B6#-MmZcTEdSZ*=@@iqfASylmx8t|_d$9k zlUBwpzh_mcnlz_m8GA)Xvb3-De=>Fp&Ah)|gJrdz7+V`+Z+G8s#Ebb63li_0o)o=L z*H`w3Z%0MkecqroxQ{H5 z`|og|`d}l~IX{(AkFim$c$P$Uv}m7pYD+81Bno(k!>JqWp~QlT?J9gyvnbk!{)h%6 zGV}3u>YvONc1uc2pShnDrd(<`cbejqW6)~!Twm8zH#a|3PE1UY0fmYTgpQAhpL=fb z9uz7#Yz!eu;xrbky2izXm@Ng0%Z&(KYson)3o2TPt2{IF9(5PD2?|$p4>L2)E~tOl zqD5gD#IN7Pc=cVgfNW^%-#^JPYH;O<+nXVG1NV}4_lrU2G8CQpnFfud7No zbz33nx&!=p!u}|9#qpz2p$74Mv-~s)!Dax7@9Jgr1|xFXcx^nJV^xXPzE$?FjZsj^ zgHBaN9p0_u6yE+CzR#kcB8sRL{a~1uE=d1w&>x4C z6HSU@TaCzpkHFN}0c*kngBWHO(HlFx1Hb9}JEY^H}%gw490!M`Wb0ztdV#QSk%c_x&dKU_51Ow#-W& zOhQiXJK$lG`QJU>>+0%4!=TruOi>D-@`olMurZcOP8uv+!eK9zgpsSC>StMBXe_$> z7ft@2o9eKT**>C0;3K7GLJgs5UOP7pbxRf|F-glylycO^pW}G{Zvu3cc))1BZD9zfp0Q?Te${tTlaPrTGilL#;1Hj>J!rzt zf;@YCeiN6H7BZ^e?p|IjEfe0iAAqa-`ni`nLB9E6Msq~3L;lYP;_Fb|u0+ggJlk-^ zs9T>?5(%`t8o_v(2mHIYO!U$)3qytJ;;s}h1l6w6YQRInhyO%k8tbu~$|ZL`oGvG1 z#Qj^DpJCnp<7TayKhI~6fk~G;*!pF@Ww))9keH|pTVXwnNo>DFjuExclygY4 zkhvD0;G3D!fz>>t=hQv&EGeXDjh+Sa4_QZj)nU_hQA%yI`4!h9oUQ8>M~Uj?wS?^K zPY+$)%~j*^#R8&+~PA)DckB^TYg+VCJfmWrLxr}6i6VEum z0}AO!!`0pFHJK;*zBQ>h6Osk>Apw8ypSp770-p9@Hz=*LTW5KcPOcAiU&k<2O%21m z{Qw-IY&009(r@zoBj9sl4xlp2FQZkOlZx#A ziW4Geu;dX~F^e)vbt%uB9Bif*<}OFXGU%XMzx%0CY3~TSC$s;EAi;`dWYt)R#E8X? z;3)U3?;GbD{ya+9^UiF=CIO$6Z>-hVhc&m~3*AE%onfMUd6s}GX7~|(Q8H^5iHeGP zp3PyqS;S_!6bWsA^kc5;=-YoL2|Y)5!2q87W^HXv0{|!nI?IbYHB-s?7l{X)D~t@^ z*Eo4UK(q2#Nv7$g@;fvzTUP?io&}A6ABz*I=n6Z#*=KPJLnR&e`WV<1m$tO&q`sdW$I?qs4eBDL6+(1jsf|Z>x>k99NEuZG#PC6WN?G6w4GvE>mpD z8+=;wJ?D^3+f;VZV{W{GhqCxQcl*W zTvD*HxinQSl37(%Q24uLo=En@=4WJVTlY{1d^vGk z74S&7lLi|tM|H~U2z96)y+t6O;Dlcj?0RAWxpGKFyU9(y;(3#j<7hiD$)dfwCOSp zRh_z9Nc;VPm6_(zYyuk--)LnN-F)1+BZ}M%e}03H0bhL{mHYf?d@8ZE5DtG!IVG(y z&*XHDzVQ5x6lMYR0k@9K5;A`buXYSO>g{oyQ!)%bw4eS^w#of+05vWQh6V>7oTYzY0$B`Xv`42i+K}N%E6NG1dz@S;*xb~ zy0yB}5hGI_Hhfg5oX+nat-S1v$rK%O z4);}fg9&#$-MDPN+R@Nu@r}I2GDuUyP68<|z(HV~Rq3&oRA_d^cE6no?D@X^_K}b? z!Peqm8`mPR&M#bZy{d>)IKI5x`=H}#om=RugQ$1^Tpf;+*mSpr3yogd{vqD2BUggM zS5=j;?{d3uSnjrSG7NOlr2l@&>n-!7GT`+Wh*xXAF}2&my)gZF^I;%&9#`y6y}`O1 zK0a%ytk>zf{wNT^Iw$+}J8;2yogGZM4tHP;0p(Z=b)hL_MSD@UogqLFiMCoof7EFB zSg#ZZ&{3$bI1WuGlUP5CRZ&a84^Ivby1*6FfyR9Fje8d?EI3KzE7B- zaB-z^UNpTGi;F;qfD!$7e6~K0D1VceC#dcFGb#PE=U!JiY*@+QpiF0g zHy2=tvE|XZ`3bav>q!>ATkeF2XA0G2WpjNyc8-*&b+2AsC%FnEurnN}L}hT{%p0U_ zN%n>rN&)@wSduPI`uly0{!CJb{97Q-DLAuiS->^owK?j%2!j^v7U=npCVR`Tgntc9 z)8tS4daWm5Y~&fY{%rYn{grKi#~`W@QZzOZQGB+5mzN$Rqv_&sVKpL^&O|9bfiXjti*a0`Keu z`w#Y$Fktt%!6h!cVYdklAUO}!v1RT!<1^Cx1C&4nIAu+3i*B-8Wl<#U^M#iby2*2a zX-=&!cIu=*%rLk0>`QYRHPwa;!KNI+dwRJ5U{<~my8 zKr8Gz@`dB)vxzftjQ!@mpf5p_)REHU>#R8erBLpKh0%{?T-P(Zc!5S|+ATJ0I zfRDl>0Vk=|lKbjGRL}SYBhKbf<^KoqX)^#;P$LDmjDL{9_m19_fju@menkGaTX<;l z^sqjYjxAj*a`~u;m>BH*c(^@J);XV3WONmNJW6Lv<|7Yrha=5aXKrhjy6zWZ%-X1Y z_HTJXmsbpfH&B32-ajx=jLjH)kw)-pGZg42`PKn*GtjET@i`@gxI_w+<^>(|A)Kq; zl1<&1$&#`HQZhzn?^=^qo6lR1ojfRbxveaaMg z-pP#+bGv`j&wpvU2-b`-C|m6xSz4a?b;eW{kc1}L(Lx+0GCX-S9qjj0<=~^gv!lK_ z*dHlsofK}GZgP7EmV;ashr?-c9jg1iAy~eQC(NSuN7Jv${s?qB4`*O=y_)y^x*rS> zf4N3d3qb2{1dx)EjRFrN8bCkS4XAs#O3Rp{jaIg8G-M#4QsL52 zQ+v$V+}vEhyu8#%@lgM>bzws?YiM%eM4{Fng(e0H%ROHgg{fAseHXgwP!O1_xI6e* zrMv0o3n$!9(s6xmI6=+ODvo_(pr2IrDY&`Ol7;8_17*=EnJ%-}@oag>W|xD~9ZAVR zQB~@eWpq-42oYMJCF7F%%8(TKVC4jk)Qzt~n-!yc(5qjN1@fD*s5@{iW3#gRy1O6o zeLtSMw@n4S(hK2$lJ79UogPeBCQaYmaI=%FbIzwSSubCq|I(+a(M>dl3o$Y-c{D1n zZoe~UJf+PmUSJ{+@@{UXMiD1SV5W#FDw7JWjI?dP!ia+a{@V5@1XR!2=b|P_GDfdW zLv}ymi`2`C0*L>M=rc?@nP8|hM7=FakwKbasi-d>8s22_E)lBnaG}OIq=-LFHmv9Q zM067=?A!T=#Ex%~@1EF;1FSQjSSL`(`+T$>W%D#zt=6@FUT-xMRai2+UTSyfR+AT6 z%{p()0})(YxZg9r&0fOIv0#!@O-aesVLgzi$Doqxh6>4rXT=sNZS7L>(gsn z{H?5n%b1iBGkJBUj%Zg!^%p=ixO?<|e|>l|H?uOnE#URJ?So8aNDqAZHT=**Ny=0L zt5$z<3HzHW;Img>7jU{P6n)`sa-oXO0QpG-6!Bo7h{N920TStnrYxvGVr=mV$abw+ zB8B@CgOEP|qv@)`qH3eHN(e}IBRF(-OGtM&i1d)s4bt5mLx*&CNjF1CNq2Ym+5b7u zxt+WD_7`it>y3vcS6)5u@H3$onwTo>@WCH163zn_EbC#8GkZkqf77l4AD^nR<@&Kx zr0&bSEv?{s5@}Hh>~6*=93@UM18rUS9Iwc~o>Cie#SknrEs=SGFNd&lh^U z^^Rn{u1^Vrem@hvB^Ay`h{zm@8vW|GVLRXceks@gO7>K-8o#p;(>C}dI(bH9XlNK) zJYVq#T(|msdBBHS7Zy}kjDfee5fIaKb^9?JxH$(BVk!X(hx0a9u9?}H!I*5{cxQUN z&06hFcU0DrgyWlPhhc$Ig)CFMS@8A-BTpt1g^18&!;j|6qe_SvY%t5yzl-8+MFx|g z=>GX&osOh9{WJ7RK~xXyCiR`LAcFRKHb|Ztj`$(>OS1}+~~}s^o1c5D+8N9e-sXy4{+;A}uYI0M-(fNw4`F54a&9LT?YyGsLBbcft}oJ1O7i z07a?qS60>wvYD9~RU#rHbE|YS0NGsO6f~jw_p?fxn)`=Ws~N2bMmzy8Rb-4wEVlXX ziQstsHLRp|6(zs~=?2XGI`R{4V#+^rv!f)GE(^j2+!cop@fv zDc7w_?DPCp`cf1b^{bm)=v`VGI2GlP5#Tc;(J)VZS$yJHOar&yPEetem5I!7Y%Vjs zUMm`yEVdIYpd2>U6=ABqucI%jww*PyJYt$@IWtDKxe2WLYor2;+J=&gM8!9!IHI-J zLQFvQcge3ki5rtb0)vdWXW5C=`^*!WWHEYf1zCl<8*K8@gY45xkyIP03xg)^XCTz> znG@k%>{*=#TV`HZThtTK`#Wk>I6wDivu)sayAC-kJBw@kAPprYrM90NJ-(ShA=WZI zJ?*(`DDW;8I{f`*_WQx??5t#??aCusdeKWZU<%x?B2(xqwiUH9zA%QwT%|xhEBF;d z7g@|{gk8NItuPj1xY@+7%QMR&r4};|;_=Llep{R!pDp6ba&yADqAOaj*Y2#DSYART zRfe4{hF;lqxui(#h~BFY%PKfz>{2^%*u>W`OkAuBfy5D~84#+^%=LwF4Rl*SgCkL3P zm{i*~raXh-f-*j$ksAf%MbDwt6&EFR|C%_|mKLaE3B#U`x+0wy)x06$J3Btx)|)wB z*z*t|{VpRjLL`xI?<*=Q>Nb){dl&gC1T3YrvN8q?3{0FM%Y9mD=_0?&@qJxmVNmcs|MhX-6 z<=^}=tq!C2+9lF8Q7u^CbVSj^Er|u4v%YsclJegY_t@64*;yh2^%_D@kA?EdSBf}n z8d1Nccd3}tEQE#g+*1DB(ZiH*f9LdfA$LL>Pl}b4{DQm#L^q5FK@^m{X(i>pZgA94 zy_`Zp2{Lnz-+gWRzp)s;;Qs128nA65|3(tJ@gDg@kMB_F7o|9@Z@sB)m6$DVnUi5F zfr?~eM91&{fR(jbD6`HOrOnk_u$sgv?7FYsO)CEeBVX*Ua#>a)t7d|?f=mzO##y3? zS~al!m~*O2N+a~C`4FOR{egrFLwoy+EP%$K`tbT8tg#mT3ujFa&NIf*%_vk~_pw{xwytzN}AmAq0Ut~&dk7r77BODAqy zTS}9x`~OmUdnmXF=dn?~h<-`sul!ur$mVsyv$4x}eTo~)XBN;p~< z6+^hssL7xxLb^9!)!EVROsT>#Zc;I>8B5iIkE))gU+8*W`uo+Dc{Z0y9Pud!M@Ogv z(3s8#1qErQFzI_h5B^(HBzM49XeayaA=_1lA^A;8UVa>iiinY*z^F@9ax@K{cj4M} z-l_EpC#myD_)O6a;@DMWOE?$@33iWUc4t=;H` zO1s*uC3VCPk6A*491Y9!^B(r?`pmBSY{;H&M8@sF^T^#|QD8H^-TKLznOYG-$%5Hj zWtNLT$+fn5bbV_f`g7Ndq|4KleH3?W7Z7?bYQ<3A`qZbj7765 zafL{*+}0a5djwmYuu51eqT7^18dj(DPHve5D>%L(@&lAyViAHw6x=0r5skHiv!DlzZI7mS*O$`m; zw6rvU?(7rS_0-mt0N6U&0wVBHA=>bGkBy$7mh>V5CtBX0o!M^Y^kEa$W&i<)Z-UOW zToc4Ca^72rXhNyuW@FASY4t>aPh!JMj8OYFgy`}32(~=h-bngHI}Gi)w;_nE;?Y;3 zslT~j=sZI2^4Wq>$Zi_~!Z`1TuM}&Xy5%+@E+1M^TMLS-vQKTv;-vW`c2*Sw5fD+^ zND_vJZba6b4i{;i?r!Srk4V{ORNO9WLZog7a>Y2?75J_G>*ks+Gq}HQt{J$8m}h&W zy3Z8%T~e&oD@cW{SpM+PMyxB6|1oph;nrafME$YKIju&8dYc0+fF(w~pTSsw;QlSm zuAl9`CHb_D;`OmeFqBl}>}p0>#`qd5`!<_#^Z)Ze-h)F&=EA%kI#8QjVc^t|;|$lyBZ z01U`Ko^hq$0-8G_5EGLElXh*vKWnbHZy?Nv48Uw}xV5sZHSw5dQNDdN(n=p*oR*Gz z6pchSLaNKD_~{<&>*R!3lqP(MuhHRhUe61 zUQYNaMC)H~I1{>gD8nC_c~s+H+keyS%<1v`$hY8A4*Bf^DK#00B+%U*OS}3vl0^S> zrhe}B?(^0Vzy)=GI%)c5P~|EKzzs4i_|J6!wMS3*CdBD0-{0^R%k?gSc!^-iWlZsU zrtUn8u?fz&l*Ea@dU#c5mZocI2L4m2A^<0UYbB;>YI&|(7An|XD_$op9~u$Y?L8B$ zt&EQV%d4z9nnC8%bNV7Ft#E$XK}}{hZjmR%rKuB5tKvQkT4wrqqnUvil5fbhiZ|?( zF*}xdpsGyWL&bpoug&u}pHFfFJuaKC8E?@V)Ltq~T|G@hXUNy{!F}~#bJYCmVxST> zsSX=nalaFU&Mff}mOz%`6RfDRQ7f-&xaiIp8`qv08_%|RpHL(Srwj4t|8oI; zY&O8CM9Zf)IB{3I{0tn4=MgLYW33kITCi112vF7f_|-QV2)XE44U?u=V_uJlVZ{F! zDpl`jp=CtZV?@euG01Ez1{q)ybI09<(pd{}4keemcs$pbtEh9vLm=K9w6q5)Oesf~ z?e15n2UzFtga_gP%}a3CLq->Y2KLU%_4RdMZf>s7$TAWenjNN}R(Me9V%|LT8Zj#{DKzE)>~9fnBof8OC=i@pPi zs01#Q(UiK%HWqAA@GE2`dODEq4eSkvBrKy{Tr@xu7Py&eDlCUBHL5`UbhoP_d3S8T z1G=wk?_O9EttlzQtAE?CqIpT>jg9@Ys<>NQtAhj@v(8m$hY$ZGjS-!R4a#Zk8FVG> z4C(PggLYiwrWe^?rBg0M!gIWz((=y|5z5cU>dOcE>TaVtm0kM2t@`f{y`=@E9Dg;Y zl{9k^L^H0=gx@-X9W0%>A9k^P`(EKz>??vf7!D53 zJ1QzF2B>*nzGk1vt|25*#zy=RdfgJb>^Sqb>p5`xnj}*=@?SjjECAHK-zSwn4}o|m zx?L~x%s+;uAMS;r8>&oms1wp$xM`~y@AX5z9rFqf>^mW;&srSpZ|ubgj;1v#*lIV$ zH;iVS#m+_^p7?`;vL5xc6H*%7A#_j!`xvD=~)%vNwI8buI*n2H4XVK0) z4%Y1rKlndrwHaY_cn2Kw{Y<=#CN1j4m`<1AOOjAkH&aikb9uNKv-v#{sWWBfbEIo+ zhRc}R+X#8uzl({NE8C;`0DXQqG!4r8facNR&wzMc8>|3ghflqwxfpJJZ)`8^|Rq)VTiP4yi|CS>pHVTE3=`>i*GS8K3Unu~r0?Ynz zobQiq7wOqS&NaY51Vy8avta(n^INXc;{Lt`m=B`yE|>~45`HsrTbsuID6T-ZjRlU7 z>&ss^l)ovwHR(67U!({8rPWY2!$6EDE1BQ}!=G-j5&eZJ0I)eKT}zs6hRm zvPzXCW4{zd?|x6;R966IYT<2+2`v9Qmp;d#zySzsChZxhus-e1ZMg7OB61GMB`i^* zyHJSx7%(}SsT;LRISb{2Q;|LBM!dFLiQBJt&Hk%Q=SZFazqVoat~9H&@gd1T>U#0k>l(BW+ij~m@c4acH=Mc3!McAN1(+yvY7i0 zedu?rrAwP&wbQ|LG2q4WmN7CiazSL*e(|X#rmn8K67$+au(Y%kunD8KYu*Tc(~Jk% zpRad251UMA^UYnF{84ti!2o8cCT+MTCF|%*n%`Jy(T}+!t?9o*VY?bx z9vkwVH|r~2H>Wq-Ef6WP)N^>j7Tb??N2NSd%Ogi;*5BeOPutybxL(EtFrJ6 zHi)CW-rb_Vy`0#EG(%9sKV6x5J8DBaT zK9b|}wf4WZ$56`nW_{FhF8XlLxR#R?CdG`pcUV+JJOh?|G4IOqz(F!96^w|CZ20eT zn(iDE$O?T_eQC|vIqm9deSn?M4HKc|C^L_w{0CwMeoV}enC#rvTI7gfmM>>Y#WaCf zh_}Q%!Nb4JH77YNTR3B!Wi3>TevWd^x8U$xi0^~F@dlYCR5X={HdSd!a{a4R781% z0e|x7OrG9g#15CYU&9(aHi|c@{$BrwN#Avlbhg_!f_!fmFMAUHS>>nY{a|jmphTmiddS z^t%S&HnKVS*C5n++IqwVuywc%bakJC)--^1No28G72vX4&D2nDm@%r615CHmhliz` ziogz_IC2b#fnpTYe+5*jg(^(V(XNoTNNHx4vZ$q$^et!}_0PgRwz&=;uSaRp)P=Mh zE<0bqdyyjR%kaA(StnJ=}bvYXG1iRE&j)Gdy!@AHu8do#3F zVx(MQiYAJpBS`W%JR=uF8&wmnOl|Uss=jq@!cxHAe>?x%h+(kE`AZ@AqsD`qn<=S# zdr{rc{{={uLDE7GbkJYtj_zD}6vC8az6#A5E%VB{;0#XNglWWqx>`puB z911Bgp(>VY=E?EKPz}5FLSsZnNHkWIn3mR7pC#MRX4Axr;m^>{XRaMe@nz?$Lr0__ zZQK^j6eAsVM9F+mJS{~HPda*b=FB9=7+=s~DcdbEp9n=;9M~mt2f23qbo! zr1H)6jPDjd0~)04e)*`h)9tiu^y42o-+hO1WUto2~ve7S7jXyH|38yQkd`waX&%f$x;*dJ-F!-Vcci|1|e%g!O3%G3ZDsiPBiGe zg78t`JwgJUqWV%bZTkvL973jBFb4k>;^AUSk5~)h>>e<_Tu1h`7)!tRb4Y7nVa#T6W70z35 zu=@v2UD7_J7!Qzaq@&Bp!MVTAqAXXN(e}fUDrAGruO?oI`R#){yMH|AZ}nkG2mYc+ z@ykd@YPORmm9;{qVe>L|Cm-bhP>&+M*yy#3@g@q+ye)q5DKv zC6z;i*)5f+d3!H1onSS;Rg2&S_+!)MrU{0xFv}tJdiwJM~a*rF2 zeXv+E_N$P4taLNkzSQ}s>&FELr$iH-Z^bkHgoK1#Ro}Z_KStm&pQiG=o^o~t)WJFg zo%fQx#`@le_=cjOqIRk)E9Vxdlym{Y{Ok&FdZj05qH6U}G!FKT*?F@uyeyvP8H-6o zzC;dw6EvOEmPkh+pI=@dTIxV1^txzF6D}(2NgeR57nDE?dau#(XOF8cXQ0287d3fq zPbrx`Ka}IM*4NRo0H8SRCnAXq!EA2<^JnSdCEKk)%fm6T<;QI^N8KiQsjzMul7Qx8 z$%WL=#~7w*wHkRTsOTC=1QZ82f~Dm7>tT1;?g~_b@#Q3dK7d_7Kn9fc$BQ*8j6#)$ zUx2SWE!x24^y?7)nKe*A=g%K3cvwe%sYuwz_8YZbf7EU%&#*FAD2YRY@+uccc-5uH z@~lGEg;o!K_>O`cN6Q83TVsomVB(v9PU|I^M7vTy?6kqOdm4ti$$V2z6a%ijXkg6o zd!_usp^2VZ1Y7)rU~&GE>lvbm1P=YM=g*8m@6P+=-gP0fBPmGI*xbFHM$uXd%q zD@HGXCecP%Id##7i^>G};+uLYA!!Pr1v1YVb|mK}PLEJwZ5 z5MkH@L9iZ>EEq{eHcuBug|-`i*gQlLP}cVPg_(hy%@@1&wj^LaRoHKUt3N-~zF7C0 z;pzv}9#bAk=V^d(lCk$U6QV1#Y9 zK|*Z<3?ssAeFK$15|WzC392HQs2^KeCgjNcnnTlM_Nc~-IHNYgXm3gR{POmH@>tLJ zH7CXdjQ$w3W@YF5+C2eeUEyEn(;$(?5Cw?DP?#27h?-4kuT3I!dA_3PMHAcVcYW9s zPkha?v-^z5btdqqy~TN(*KtDjGa(C3SdHTvD=I0kw1{8GlC9qGi|g_q$dRV~=s(lt z=1q?|COe~@_@n=7Q`?3~A(pXqx6npEbZ=vO2MBJsBMQxQ&}dQX04M3%@m?Xsdw~(j z*GCubyY^upxb?i*TV5)YK<28ih}^)ZD_dAiJb6dW+xw;Fzy0h8_(58G*o!O63=R%nsi`MO7GXM}NCF^F;vNwzQ6CPJ`qTT3)DfbO zRQQ?xh4awj%GTp}zF<>r3e2#})d`sWdX|Wz8ON6S0ZwnVLUT{1DZUowgJ z!vQsr-n99XiW_;YSXdSMcYAWwwkW}mF?DZ~_N^Z9!T}bb5fhrR^Lrd&?oPb`jLYf+*Dh8yno6I!bpWf*$^Evc_vC|=_YD~9x!Kq(+dH{06M@=+QP?7%lIk$CRs zhIIaYqSYOE;_ZgGt_}mq1o>fNo16jNvFM`D-j0q}{%UlELQ8m?dv-45vOCLDi2+Lv z&f)Rc+7!gmENpzy9${PZrsQ#3)m!6tlT*9JHZ%kAI-e|tBR+A0b7IZ<8L)6w3E-H% zWT2u<@h2M&W(8#0f{lM{S!*!sOM#^^P&H8%+HfL<1N%)t6_$bd%D|I%eT(|@`LA}~ z7js(slGN#~_9h!NM@#yWigW!8gV3TM)3oIV0@?pYR0z%IFGqAU7G2Nrb=THH7ESsz z^`z*MDAJ^=n@i$8o^>QW9@dT$H88{S1!SpwCy!G?P*;tcz>>oj)>i)N(uTA3@q?Cc z{Qmswb{&E+b57&(U=wNkrw$Ql6{&CZ?WvCoFD-)rL%@Fg(Adh1N6*$>E<+p4z)Qjrb`C)ubQ^Dwl1)>q5m;h*$AViBuU*t zo;9Y3+cUlYDUlrjzX2foLGbp}d0s!|?}5|X-Eaam9wjDX@1h?|ms?qeSZQDt1MmRq zEn>{^L4Yaf7{IHWI5p_WMetY}OOuiM(6J~My(00soQ*H-GofnDK9i4aK0J*|`q7B< zykvl%#db$BCP~zw2U4U@mqRtWH9B&?!BK`Zee09xxXzgk zj*iVI8q_WksGKBQ^Jg83rfD&fq^%hO#V?1@_ivf;p%i~ovPV0bDog=b28i4oj#D*9 z45COKlL$w;Z0@Xy&g&7#mRKmV=)F&7<*lHi95lG`5vDfC{ zwjND%a6+^lwr4*(e&g^-A-ciy2&GFkZRHwLZhUt5DQl5K5m z+`N#9>O7jatNp2bdaSU(3pKk?N$%SpY&6S@iQl57+l*$lB?SMv-AMf==AZ4UVLt&j z78z@T0Xka;)v%bqmVg37Asa~;mSREmq4}Ktn;&UAx8!-l4ljqg?!wyUrkro0Y6nIz zH5RfWEU$}4rg1UUXl~TaaU8o%evFB=#xggEcZZS#`>1+fZIc$KO&J|QuGSk$ZfnpL zFQ7Tm&^9$e%cOnH$FM4Z)b3uc?=GmLBff7E2+EzQrxztA6Hw|vS4+k<9r5Qp@vob& zmvxs{rxb$r!`!X*RKiH|dbOubZ15C&_LTpw+hjBS>LS2cw__5zX1iZ#4(j<<7V&VE zfkfjnY;pMDDab)Sb*gyrHyPvg{Po?|hTOs-vHSD1ZIcj?TFMTgjA8KeDstP7lXQE& z7~I`TTc>|DHeQM3qzqii@NYFMhR_n+8OfK?M4z{d%|2-=RXcKvy zwn0~fmTb~91X>5Q>ZVR^iP00JAo59Cwps@Le?dP_DW+rzZ-Uqwf=DI&>0q#Ei$rM$ ziV`A2*t6uLF)mLq725nLQbarTMRCC#j8Ucj3VswZLE#^;#u-nX09384CuBp%PGyV( zJ{QKRHZZnu%vT={Du)Bi{)ApT|l?6 zM7V)xcQ-kWx4psH?`5w4KIs8dFGpD^p&)XvW)Z5I;YpC8Kpr^RZeZsN2o|b<_dbga zA9<$hVcf13Zvie=KF?Ps6`-B_bbTKm9VLnf9o%1E*E8Wp%=Q`C=L?}@>3{oy>_M^v zhszK=nhi-B<{yoeD_rpONVH&32&)R+G-7UzAgQK-ol;BJ{}_Gs*rq$&fMna&`70<1 z7sPZXIz16Ydn`fYFp}twZHW{$c1r+e;A9&#FZfs1yBA+gP(NmRPHjao>Msl$?@pFh zxPu)Ub*}ljYF$qC&85yeoP_{op#m5Cfez(Fz`Wi9oCJA_0i;O4qH{wXmDagxs|364 zxUU`CjpLs*v9D5^+)9R2+0u{5?v5tn1`qr|T}zvL2aMii@gaj-7b(ffuOnL*0$JJF zLN~xQrSAx3&k%bf-kRV)gOyTOQ>#ZHe-#IAx$ocCI()j;TwA$8Z!X`M!RlM9l(y}t z2h;tz@6>0{ccsc9M<{S+XhX zj`s_$ao8;Ymg3mnU+J6~-$auMBKsLNVl#qbbw$34TB|jTbUU2@Pi12lRp0Q*VNLgc zbwxjvu)Qe7)e;|pz}O$wblnze&?j?DfySDYEr_a@w?4s|9z<5#)(k)Y_F^fG19Ln8 zOuEL=7r_sYk5-5-S|CUYmD1K;^c_oM-GQtzk$wR$ZY_mQ91*IXK;(ozLr9!_%U zCP15G?cgxYiz_E%^DkoUN03on(D`d7$r~~S3oy4O|*T#Nc#L`i$@=o*p`|K zf+`&+lKhtX4u|5(PMa!SuIBEKebz&7#x~*B^X!Q!fd2m?aEXhAr8<5MHWfG{3d^M8EO1-W(%-E+qI_b{xT zEuD!~RZd%u(s-7Cs0(F?52el{nx5Q^p`W*M(Hu!n0ATx#)oQN%`wH;bV96#kY(y|1 z-GDtl18@?uVU5=BvNB`9a%jrm-`}iwhPM!`v$(L}c4|UH2{}Q*l%?I?#UC1-B9D;O z1hXGL`%%SgAsSw9GuuN$7_0xWMb! zs9cQ8B_zXZnQbjvJEimY-KEG=FqO$b?<8mGlwi6MO%59Yj@YK*-_doK5-E;P*!HWSvm8;+abgi8V#=Q6n48D zrhd4H@g)YV3Vl{jTw)N(Upos6cUt2NRo?mrm>vgvypDn?lIOLnz5Mr4_F}Hr9c6&) zgS1l*CL=o9vAH3M?0Jbm#vAbIRa!s&9$3^-v1PuZBg#L`bim4 z7P%00C@wx5h~77YY4hLbjhW%XFBW=Ns>HHyf?OL+5udnoprvC~TqzYJlJ&5cz!B9- zA($kNI&p0d+W-{(BKpp>y99^c<`)maMc@KxzdR+m7CU#!Svj-GtTjLC%tHtx5_xL) zCl?6H#q}IH7cnta@^mbSRvi!r10>C>Gp>x22O#`qUFhvf$Q;k3Xe#j-)2s3R^{xwQ zRn@@-$u6P*C`{x_pM0@5iIslx*Lt_sJG(Paf3=Ga|7Wc1`QV zNV_9}agaFkFe}f7rms@aGqg_~8{$7@_~*B$dP98duXpS$hI^*Q!9#3bP5FNeUF+&x+%mJ?XcBOJjaPYTqDI!XFNAK zG#aPHyEOhZD0u)J@zLlGy{;U;AV+g@(}%6m_wNttB^a{a{WH||YKg8%pI{jk4Vajmo88b~cI zT>{LHpFBJ~UW+O!PJoLoyJ>H%02_gN|MscbH7+9DivSF-D*g7?e;$A~isBWl_MI`g@^g4J6-#tO2H{&Bv%34+v*mRj9f;TF+EwFQbhnHI zbmY|@i=w6Uvi3S-^Jc~yd&ppQ%0f$gr;$SUpi&7Qp7B^;5)YWs{%#Y@6(|GBZWFb0zB~?{xjesiFx2&ve38l~XJb=t3d&>0le-B=_ z37h?rO%p38Cnq4IowTmel8}&;r2i5Z>d2QKpYB=Q|Hth^!f@z($zVa(HC=@e9bxG{ z2nSN0hj5d*+cT!N4toCz?oQBs&fJ$jUn5I}mJSG5d)RNNn4nbK`ZAEdDW(fKE7`;5+Ebd)p$5!+_=4Eq}r*hko8XFt4hV)Jm#tiBP zp+}prrE!x)Wgd+a|HDIFyOLZ?adjt;@9LeRHAaQ z;NNIfXk>XGkW7P<{Cm6rc{Mdw8;BJX;j(RSZM}b8YX$*;nlY_5E1Ey0>NhUZI-j8PGB(jcF_cJdo<$k&~nVo z@$&v+YsJ*mlp3h@Q~;(m<;KqiE{-(x;JOFD~dtT z-rl1!nxQoJa@}~)KmeHQJTXZVz1yahvaQK?#Tijszk zo5FYg2q-9=5>}xYQeS#;RBQkYPT~@b2W^`uj-1Iz0xm^fniyCKqAv&9_}wlSH4lzY z1}$To9qr$L*FGL7eKTeai~XD)zp#S2vj)H`CojFQ#&q$ZLMuzliT@>$15^yfbh)Cq zGvBu-Er#UdZ^M{FDP(|d2QZVhhzCXC;Np^^!3PACmL>z-d~=>;m5FPGnVlGNdcI5z z5?(U`X)g~33Nf?TmD`z__D)~_HjVjO%gT=jm<5(WvH18gR|8Lse{pqxGjl>i13~Yp z@}t?km31C_e-lPV%IC}<{jQ)JJaN*i%Tvw%xNkzBXuy>FGx59Ng#h48ZWM+cRxd-B zCT7QybKqQ{mNfi_qoX(9dZ9MM+`uj1+hB$=sQaJ0`}0o4*V3yUw5DJX>%!+EWW+!$7E)Y`%rdSvEoQM<8V6~H?2{k!C9LpN34NQ0t8T9J{XuZg z58S6;*1%om7cAO(3N*642}cF?J}|p@Yuo*rYo>1-SbxL|^CB_s#% zAqf9L8#;U*EHwTNQ7yoK?jK{EdQXRdjv!IA(eUlP!Sd?hYMq~4LrOO~Gc)s=cP|R} zvs<@6TpO@WdjO+I-#Bl0&G$gv=AYBvp80gz#(aA@%G}iiSf2Zo%{~qR0*o^@*oOpT2JOy&|WD zdyIa`CD-ypzDSHn6%f7GGlgHGMmYIX+j+T`CEKpzYFZ5}?8{`&6et!V)SsX$>23pk zchalOfiiAZ7X+APL<2D{uV;X73|VP;xf~ZKr|G}TzAgAPJ2LEDJt9IBojqGW#)9aB2&ULl-(kp&EP zXA#)GX)yd?@6d02Cpyveiy=ng-o0cke&&?k3M@Hs_Otw!iU{iOZUN8jdW1)1OtgK; z{X1Ag=l7tqGv1+ke?LAhx zep|8yM=3D!kNWQvr~Twvwr*M?1KZhs-@AK?cvh8uIJj%&o#h?2iMVVrw`baE!K_uP zl8M*H1UoSBJq}6_rd-VaEY)Nm0SRl>X0}kkY8X z13M<6F@~Q|?1#_LN~I*`4d)>(WI_uttp87yYG4QiaKzkfpy+Xd;dwMA?d|QWfcuP~ zzOwQ~bGPAs+uQ3CXUb@j{yl@A$wl@LwzT`(rmpc4)nba3l@(7IKVe;<<$C=Vg6eJj z^JlfarR7L%73F7-=N}Wd3jdwa&-%ii&?FCi5CzNr#A5lRoGt;d;YCuEzRbQ0JF2|$ETkH={ z=N}fkKK|`}*4pY|XY%KZ#BiWKPDxB8&IDXwn5(*8AIAZ2(eruVm-)qyPqgH^K{s#` z1_q=+x$rNI-Mk0z?kcX(IKluBRT+TT0Kh1ukwpc`o#^I|MPUWl$G9Y13H~B2Y?NK1 zzbNSC0yzQx*O$0Cn!6U7iKfrrQT_}kQCLm;h9H6E-x|5&5Rp|&sBwW;(45|IbwvKkThufyhqywnkiy% z>c&zNV>|}P&rmxtd{g;dVOYXsN$o@xc89S#-_W-_Lhd z*B5^r@LG~b+vYe?j;RoH@hwOYSa~qs&aGy05d^&hcpQ)as-^sxJgSwxDLeKSOQ6m2 ze$c-?TN=DOW!fmCcPN=iiTz4mPA82n4e(J(Hm9P#J^RQq_o_+i5Xx8vCOF`?a;+Nwo}Qj+fPi6E_wZ|UCtVJNIpHQ?izbfs z7@dny>`_F3R>(>q@8)p5ZxS^cSBLv+rZfjJ2pEU7DL#btsb!+lD}*45V-AraNF&CT z42ZB`Xe@fUr4Fw04Bz&8mqSuUvn%eVl?wJ`miI+1+=xB$Xi19sU;?*%k;CL+{Ua6O zX&8p`%M0!ghilCIifA$|Z1)~CS98ZmH#cV86Px?tikUveJs4AzoJC-_BqRp_gf z)fp&Y%N|>s9HN2PuSV4?4L@Mi?m7fbILqAVA2;V+;T@=uhx2mnND)_;NTlZpSotm7 zIWzP}EPY0BhAfxzZJ$R~l^p5QCmOmj$+%jOgL-?6n?H)K>&4+xMoZi9z=#Kt$HB9p z3|V3@H(G(4!x4gF$9(V91XxYJ007hn508$xnYC-Tsc|EA8vaAps{u0>`QjPV`A2q& zSZmA|+3*b8R%yVuI8KbBW^L=j>13tNwQ2t7MFvQ*$tx+Lov&oaQgMW0FDddojIc4& z6Imzj(xnu*R$3A&B#daLv<UURB*Mo@TSVc19fT5Fc@AErVk4& zb6N3xqHnrf|405?oYz$P7xrr3ix$x7_c+Bt!_XJT1Tq7?VUzIjv$Ou4q4uUA$D@gx zP?IffP2_knPv|?{HKAYnh^hySf2{BCF^)e7OWhZ9HbT;O7f%9?h1exnN-5}N@+vD& z1EG6QFX1@!2Ci)@9UDJ+^uK@4*588SYol$;Mz*%{0cyf}z+r6?kZ34;{NNV==wys9 z+yuDO{N)^_iu+`-s8F@kP~o!CcXiY`0Yad=&o;{Eh%D zr<)k|b+jBQTFY=7FK2aHU+;_8H=j z)%p4d@;sX@C=~>aC@)#;do&fr@n=5~e6AvU&D_O$y3a=YdpTR^vMmH=!!hyrB`O^j zaKf79W%uhY`aTW080aa9uRJ)o2KuPe}Mv{qI!wKZ!d-I*t&k7EvT z22V{X`@B7^$pT&yfXB+PxcNk;dk$dgD-Td1?CFxGbx|;H3N>uoD`Ojgdk@#Fb&)c6 zf~MY?2`xNW7$@yq%&fOWN=G94c4pZl9sSr^`aEEdf5F+~xhvA`0%!Lss%oV_;BHmM zSsQ~d4#iwQ`VJ$K@_rVJ`P{C{BiwY>!{I#vx1@`a8l2F~3^Q7A;G zSPD#Gprl>nm6 zlA~$^=rDE6X>wV^P-qM_CZZ+_al>_#MBx`ktW71)SYd1v@sQbqUXxU9i=$rqB|-%G zQMumPuS8o0n6cUUy@r!hToyn6emSI^JUm1UTzf*uZ)DJ2z+9+uOU*AA#4@zjXlvAg0B@pX9)e zXz8g5Eb--FAhSjcz0IY`@8qpRCim6vDqxU}}#m<%z!d7z2~NC}+CZ7UPrcPk%b z6$(`X{DjvTo0d8}0j78a8VR4%+WGl;EnsY%2PqziqUQT>Y@21+f2?O~=b*ZmG)4Ol z?&v|tH6;VSMI+2J*$rWkGi!N49;&tZWeqE=-`%*k(XoTV)$!_I;kMRT=<#uRM3^sn zChFB{qhNaj_+yD!W(ICE&BagwXH=Gb5{l%2+taV?UYqwtd0a1u>Yt-P)T9r} zF+BrA$>?w|jFE+9%G-_%6F@PruY zNWn#6nmxJCgw3#tP76gv0%kj+=S#Xow6>Ctp-Az}%qkfO|AmIPU>Xc0z#JSO4BFP2 z)zdK)m4BY4j+GRhS@2%R7xF#kRmcJt1OD(EoL?QvF+FuDp3SyCLv0T>OzgB0oQe#A z4Lt<}cYkNrsoyUK=x^h|2OmE3+T&krc>drH;NruZUw|~5oSGWtPp}_0Ha7txQh4}F zjn;AO{1HS+ozs;8y|(EZt}tO`#)u8@QZhyM5Y(vizZU<2f#NWRN%>#~>KB&ujXg+x z%yyG7b~DLrj3zaa9?(@2R)}VlrPveELEw7~cDp`EaW}tGm@W+;H~M2VUGp{u>hf;N zAfX;;Xf9I3#ldM{1m3yf`1ttj6_29|qF-SjN!^4;4_&-25J0*?N&W7*rF1|-VxJM~ z1<(lc!}$HV2|*=!wDb1%9tCWRuCQuC?KNt|8M1av>>&w-4KCrCISCy1iDl(XvzDij zKy%kfFKeD7m_$HOxqh1Q@K2DNy{(9tQaATTxi$&p@lo=Ae1`d=uda$sfbYEtU4>J) z99^L$RUPjxpm6=~A;!hU?Nl-_@ENgf`bg@rPtM5A&D|E7o$)1(acg{&&hYMZrM|I| z4hV3q6#hKvbbr3#4B#!Jk55nZ#l=*PBLW`o21yx2yY5;7qjH_{J+|0GDpZP>Ei0xf z!D3lU8+)#2Hvs+9_qfSj57o8{%(>=sPrCb8{RcMzc?RBlX(MaPbn0y1fbU;#iC12} zk=2L*CwFr1%2~H!?nE|)$-DQ!iS)*!qZ<&}l>rpe#hX!}5SqsX#Ni`Ah$pCO-pbO- zs*LyN`kzuI8Yf3t+0kbyntc~t(~{O9x@4$JUBR40!@O9otOWD4Mfz<^A_yuIvUaytYb@{() zApvDdAsroE`snEBcWVoah0K%`R~hBRg~ywtSzt>Nd{@&pK?FrGBdU~O55|z01A`gB zcM>0P1^AAO6to)?9lh!VJQhjahSe-MF^3}^xZv4Lz4TJXF?v76Hh9JfmPIgrbQ+D zI`-$!FIxaqoUq{FYienEm;kDgBjAkhZ1=e-WXbREYVxJ3$A43eN^mdaBc?)0Q2v_2 zJPfK3I;x-v8$(D2Zf=8>t-X`~3C0dN@dHDQ22&sVy=Ie<)k~ZBZx7tgnxzYIIqc}J zm0^rRkOEG(cJYIM{SB|ZyOnfW)f^7T*sNKzw#=C`=Q+FGzJK1ld1I?qtx9J@RapYb zB8yK%M@I)6Hf)d&A3kiTuC6ZKvuDrhk&%&QlarH+dU|>a4N}_V^B6_3TTgYdgT=EP z%qlkGvCBBk0)vv#@zB)Ij!c9A6^S;#%1gUPd3IYLo&M;@z8_hZnN?ZJ$|VcA`<6A# zX{g5Iav%%w<;{->K^kc7?B!QazR2%idW%3XthwFp;p?uu?pN#AuYW9+N{!rd%Ppxn zbLM0ZMV3Ia$YN$VaNq!E&z=?2)6-HqozC69efxFsc>I>LXV0!38X7E1#1ke((uC8h z!4=Y@H%vul=O##7C-IikT`cyj!eV$Q`0>6{t&xPjxinocv3Lv zbyQcBa?foyuzLCR%xkE@VKXCC?aSqnobxrb3GaqGy) zNKGIRuxOel2?17v%&by74L&Qgd{%0Ut(52K$+LmYBBPK7O(6c0Ozzd(zLF3`Qi79x zNnYOVXUowMn)@QC=Xp`1LC>NEbGhgC8@T0$<@k#7F@6y9|DvBWAxLT(tsT8Q`phdl z{f9T12n4a&Y{3;PRy?_O?b?4?vSdmB!i5Vpy0J5=JT>-;XH0!y_8i;=9BAj zW7a81U)0c+BnTx^96!~{L_3YWRrzRW@FCQBlTh`v*UOP24<%`8)dQH(*)dwfwBPT)ocoGqkL?8ku zdg8picZ5xchUghhkUmeMMv^4#Rx|arRjgXNkY(4;qj7dE`5qT?<^X>F!dpV12|+B8 zc7{H%JN!rb~=mMxl3)BM>iS}>b@ZyrWT zWX7X>NM71!adQBW)HGtLG(9~7Y}J9BPBe4mcr*Whd*{~M#&N~*|IF-ix#TWIEiW=U5$(@~@(fjxBUk}6Zo!#Bt8=IS(ql{5~YPg^LqLd_Wj3zLU z(~yZXSO$Zj1g;ObZiv&akG3n(I1SNu0|+5-Y&)<$3JHo(04prU~m5!S{(=cOgR~2{?O3S*2KibZ_4HJr|ow8tD84(@((}!Ff!Cg zKq3K&cztmk2M-=Rz}ngx#>dCmqeqXjj~_oCV~qWLV`HQI^y$+#e9tXt#Mdrm7@HaC zBR59V7`>Rlcp;9QNx*1N0D@3}pEW7^Tp*Mb5(H8b@US;>2tt7%52_Z!^BbgRw`YHj-y0qt{`K74+^sj?eDl;aO*u0&6B%bD zAd!GX{PdU`<#o5q!axN`NPiF_YJ z@j?pMh7Am7D9oe+m2$)=K#4pLy64gMECtp3_QLs>s<>X56nWKY03IjUISlammWRKs zo#5V=M|e_gBjEC%c4SpmFgTFIK-Pk3BvBa1!RqgW7T2IC6pS$p4Hi%s$fE0bh^Y!< zs*37f4g2*2Kmz^M_M89!1uaQLK~zEt_+Eg(4{&NXvAbW#_TDpW?d&56!*9L5s;d6* z@Ni{hWaRhda`{tD)4uxnLFhnQBbNqJxX>R*F(1S16%)B+2!%?-;~Eli1#v|nrVzvw z)~mRJ0HpZG-V*t;tjJe7V$T^l2pUa-&nqoF`nrkRPfl@v-A307fAI5CO2H_F!YC*u zV2r{_r(q-$2*MDIF)&JDw^}&0TjwLCAmk82f(rrhJ$)#Qv2bW;Xy3A|JF~O1zcmcw z&+otgers%OOioTtMn)G2NF*Q;|6g>wU97IIVsUYiP)ez$Y5jNZ+?l9UDpxGay5_p> zXti3svcI=CNFXeQNS4$>WUK8JwilPWj)4I7_ zZg+fqe9sFafW34x${W~N#F&&>McJMXmxx9VTdpcU^~F?7Mobvtp5)e zj3iuDRh~+vTwT}q2L}gNCMG6sd!DyAGBUFL?z`_cbY17=av6%EL`D?}NF*Q;KW%EY z8miSQ+1S{KEiNuA|w!Sa(0wxJ0Sut;rSe{FW_@=zVZrV z%-1x{kH_PoVHmDynr%ucZ@1g2R;v}aZ95qRf%b0(g+SMJ*Dwq(oldtjP22E&zdAEB zvpPLJ{ntvRQYn>6t@qx0&zqZ@ivmIYiror+9D4gJYY*pOLS{%i+uFa zM@dCdvXx5Z!utApfpbm|4i3ytr<1lU>nf#mkaHgEcDrV`+tq~-6ad07WS-~6n$2cH z2tg>N;>;l>rBs@x*;Q3FWQ>VaD%CU$!zP4;ob&B!wOTbz(-|HfKE8JC+ODqa{^Q4w zi+g)}S;p9?s;X1AZRbu;Pt8`Vm2_R#0uYl@f>QdbRG6*ndMB65*_LG;#$vI}ot>Sh zxm@mKa&oeE{rdHlTeoiQr_*Wv!3Q7kVzDU7<#J>Y5s`pIMEt7+L4c*DB^(_cVR?C( ztgNhnbB=nwj!!=Mgm$~#^zrer*>1PhYPFhdG#c?R3@HG(uFE=|j%pZ2igQj54-b7p z2;rQIbUIB4Ast=ULq$>K<;#~_Ml}c`Tc{#Jay=^eY%tRuQBZO#DN^s5vA%rl-&P&Fml+C@py_2D#A-hy6 zos5l*9o1^Jqxt!HQ7V=2_Sh$#*G`%OOJ>IBqHL!jg%6$ZKKs{!S{VMn@u>5 zb6(XXgg~KC0Aq{-fTn4JQcA)wlmLk9y0TWQ_14BprO@BsfBxApjBj;Z$8m6QaDan@ z0|Y^Ud_GTv5cIjiS-5-*| z4WHx>mfj1L(H)Gq1eD1Vkjkg#{{p4s0;Jv?j=xW*(I$?)D3iLf>;D0p=M0d`5sk(( zmc$I9*qq<~0-osslhg>2zZRF$1EcC4lf1t8{}z|W5|zOVpVJDK<`j^-tLy&`n8yZ< z#|?|XN~p;anb$X+#R8S%Rj{(B8;{9FpU%JX|6;Y?MW@$5nZ0?x<5jicU%J_9w%3!~ z{)W!=E11BD*Zo1E!E(CXVZ7lfmB}NH#<%zwfa2B_?$*X&il<$T-! zdcD+KrO`E|#woMzEuGgMgsF+i-E6SQRGzzG&;3Nj`ag`PnWP)w00001bW%=J06^y0 zW&i-Qb4f%&RCwC#T?c%WyDyD3(G1hyfU5$hYiUL-m+mMn#Z?3_U6mW@4E5lzkBeSsPFL|W8A|h z?mP713v;?p&Y4qGG<@yWty>Kxbv}Y&iF4p^==FBH!H{0LcJ1uhbBekb4q5a0yN3?n z`q1rB-{ZT$)wkZY_?}IZ=6Cm2>k5w@)9EZ03+GOAr#MKKF_R<_29gz_LN*0wu|((Q zwzsS7Y2LEo<0rj&@Wp#?x;pB6d}G{j^B;cy;hME$hm@6-RdjY*IFsFEM=ZeV@=K)B zY_&Q&n<2u%kPPDhu!ta#s{Q$#4jWB#8f8spWo6m0QM1pyd-UxeM17A7#)CH=-n@Hu zSp_4}N+hh$mtwb5ltQ8E>FJ4;sMXSZYqr_rDfSTOK(BWYL^kU)v$6hIR>HWQP7_Td z#Ow70>vFlc#IiZfo1eJxmr>uN&lq>zEia51Hg(LH#wDZU)wSAGso6u4Bq9i&_f$!< zt=UqkUN0z>LP;`Dgn=(9DN!1f`V_sx+L?X z`S7jZ+pj+0nrH7i_*hY;lDFpPtK$v@WhN*QD;*cDUXdveyMpWgdy)c5!r+gzIGUp1x+WzOMrINL zew9qZsK7T&gf|5M|lzDEd?@7&)!B(bR-rFhI%Ob0>1!%br#b*fe? zKHtDc48Q^>AV39t#G)w*1EWp_V8;vtscbRz(a|yZAR%BxohqU5*9_P`{F?BJxwJ}^ zZBA$$d;cBJT@m#?qN0BM@Zwe7>0NPP>*KPKie-_)q|3_6O4JC9TB}tDA9yhW44n&L z18$T!z*h)~i<=i0hd*xsOR`8bc_w_UfMcE)ZCT)yQ3{(%g#@GAYzAvsXIQwpdBfZP z@7b?wt~@;Ht&fLBt3I2uciyrp53j(M-lR)ROw=g}DG)+pFkgTSK#GAKFyb6AQW0bj zh~mOQ5SS4YP(%Z}EJ`t&Go-+_N})Jixp3GEPdpU$|BIVHdUw{a(i%gWopRgVZmOw( zb1DV7lp)1afv{ghW8ga-zyJd5;KM@E80UIf!KYvR0W!!R0f288L5jeyKnNH)Dumqy z7P*~VXL{L!qF3Ji=*Ln2m$>%uk@<7dv(;K_hLEfQcU7m;aTcXf4%SGEFAhX;Ca^X% z2F3G0NC?5t_dH$}#lby;DG6`_jEMFQA(27=89HN(Ty8WPm9%){GkOA1PN&Wk9h22N z|Gg*v|3bzaK6-am_b2II10g=aVBBd+1TWxx6abOp$4&)6d6}$v7~VS-o@4Y<@NB{|S>%EPZ7`u0_FTtRVUqD7&4v(a!XDS|{B7e-oUH zJN(|q$Ez(FW;0yNB+0qm>Qa|ls5Yewd z`lMCr+Eg{^?%XJw;2K)g%TlMz=wVabsGku+B*d#BSHN2RNRM)8jd$k#_YGRZHSW;v zA&I+=bx_6Yv*DHK}a!Dv;uqCdMG;*$W~D{?1iTWB_%((cltB~tD-HG zBOxKdL#R3mO^NAa1fpMoS88;+Xp051_lyHap$&$V(rMeTiyCw|@WLW*3(2s!&Icsm z(x*>dE^&hSy%sy zL!@yM76YCc5}p_pO9CM}j(SE_zGUU~AO0w6faBWzBNlooSDuP;8fuhjsGTJ-Pyq0o z{+2qCLh_#l1ggL`8%q+nG>?2;#+a_EaH4t!ORwduX*3r2Y#HXLuN{B%Pa!NkqHgzT!SIw}d4S(;0sD6*D zC+w*1P+7Cn44eh2_wF5Rgrv^!30`kfvQ_t$np0@^;2AZaK}iqIHIAI zz&nQObac#MBZP)jyv^leSwb)x1!zO=A-BG@xPLZ5-1`2UPLDasAdp%BNpL1;UFHTE zA+p3ot{~54l}H68RNWJ#9=~&L(ZQcZ^*MV%QhNh1R`;T2NyrN!A1zL(a<)P zizUTDMT%ik>^W5D@HcOa>SwrW!peO;ewD(G5DoKBI*N^j#zZD5ssH73zdaa+;AH>` zscgG+DLvYRcmDdvsD6c??Y}Kvn<`-yc2hT5@;U?1o_z7xy#$fsdNEronsX#fmgBxS&uVXpsrbw&UUBxOAz4oV|hxPWMjs4_mzxT1-OZ0vgQMeX8$TB4aBW1*~MGImj;K|oV^H!BT zR~H>!o!j}&%G*A_B^ZKS`K#C0pS}Lgeo$@y@zK_zC3>p_6G9I%9~VH$I4t9VSUiSG z)ejv?v6L>WKBnM#Rq5D`q0{l~pI#fgucfPU$SZ${y3DxZ(EKf#&wLhZ4iGZva#u1< zqRxab4;<^#(L;yY2s;-8N_EbuBYqQ9t+{E!#+74glDUee(+i6(bD#XU4Mph{EBxzw zVp)QssX<6cdL^VDB(bq9+#py8!lpnjl$N<><5NL8KG=L_i@|Q!WgXw8bABc?U)B=I z0;olvaJw+7xW8gzsRNl8YjL~9N7FG{qryi8R$X4<0%f_KNm_ss83r(oD+SM{ze z=kroY>AEMPE*q|xaQ#RuhQYFU4qp&(a(b{aVwOUeoE6DR4CzWnd+L;=Zp>|u-g$c< zJ@LVxi`J$almb`>5bK~w?To!uOV-^Lb(wI*=9P_RA|R3^2M`Q7=mtb=UMuoSe zF55a|#i;SqpSvnb#NQ6iFH4r2p^slorSKpRS-baB&&cK{qb>`6eaEP0%2NXB3j&Dh z$QdAn9BhPi6ew~UR2kWRR-fifAF|_MFxI%{>t$b;{Bb;^X76LK<)aVR2mQlzdO= z_ybXw0axF#P2H2GpaP>%f`IuT`(HQvw*)%2%VM(YJ;nYs+1a&kEeUBp@a1QqhU&>6#p?q{rKO$#3@C7mqNSKP{$BxR+Yg za1q;)`F=Js_^m-{Od4f!ouRF4_PcKfUCX;3TV3PR&hT3u`s4;HIK*=7&R{}x>FK%0 zx+FcGn(_BWT~ds@W!>2>8F$Q^6TCGL3qWAK1{ot{KFG1y+lnSV8BFM2d#LsLcQTbJ zD!09($(}~TG%>9-7-SRz;K07EfiU-&NA9?k5hBFrJ4O}@8c1UV8V@BR@GFl&00P>aSfGxS4Vif9&d7c9(?J_lY={}?ZZ7#{;AjGV$@XRFyON#G zraHYaa`j`&gQw${FYX+Y?27bym{`$bXogI>R6`{9?%Yz*js{0E5oIwCv6eQ7 z{!tXH!bomv@^U5KF-u;0?cT}3a>u(ndh0Sg4kjYO3?Zg0Y?-SeX1Nr2{LM9)4Gtg# zXLklX5GfcyNkhlc_~DWpy<8`4vb)ooO17?iZQ@fm1XEU<=O4GUv&H5WWW?9wWde^_ zl1+Z+tv4@4pXrYt8^5H>&#*`aav%e~0798`prp~5WR&4$7znG%T4+_CKi1rJ{q#Gd z0(frvx-IFQg&Oo(j|WArplFl=$%4jH*Ua%tFTR@QtIfkQQ~X_ixVDtwS`2hX=%GV( z8abq+1e(K#1U1lZN~L0wUV0-a%5?iLULHTp;N{cYIbf&rP$WKp5Q7q$vOkj)*JV!I zA9ay2Zu=H*o!{C6`Dg(XGSKf3Zs^b$WQB4E)@x8E#MM@jv3{ar#LGeRf5Xu`nu{vu z$}mYOX>OB=ie$1BGLG_Tk{Oi6$|hTjV5cBO3KU+Q#yy+Re6 zAImDDMMCD)sxvZ+mfjkqcFWwk zMLQ?_Fc_5IwRG4R2W}OB%aWrlm=Q@_921ccL_sP=$x|qR&en^kj`H{{qne~C9Dwkc z50V`CgqYRwqeqX9-%~p!_WA0!-oNMSU^4r@58D=|l#gyqPt@>mh!7S*L2RrwlG`g9 z4<%5@;T8h|#T?6eVWWM;P4uJXxVgrM92ycHwFtXW!7}6@0#QNRdLW`4GVO)5B zsBUrVJ>$nNF@g;X6c$1zIas;^;Egh0?Z(E7VU%#=2dbue)~Bwm)tcLNy3XM{_g}Oi zWbs-W5k{jVc7Sy;AXXK4xJj#3%!hhW9_b;itUjm7nZ7l1(e~%A3ehoth#@1JAubql z0Yf|?tm=8b%M%BiEYs#LSar|ED~LQjKb?YSK_CX4S0(O~#1K~|Nu>=GJi|FMC+D@- zjoPt4Xy328_1!bwxi&|YguLL9Hv~LLU@56t&WnS`1v#Wg-I6(caacfj<==nz9rAJI ztaJ?_RZ9qyZlLL3QAEJ@a3)aEW+bFuhA6vJlV~_SbI0_MK{+vfcyDfDhree^6?wsg zt3XSZmEeT2OsN6MHVao$Giu!(uZDeqht9lp@H><|dlX9OOkAoKWo2>1fC7y7$ESi# zmr$mmL+hgz$emo`wKw>S$EcgDwoY1u5vsI@@IaSbt`00J2CwR`v0stJ)0i(-Y|TawsT zw&sqY_cQtNgRKj6igvrgmMr%yOTA!1Bw*!eaW@Q)_$IXW)j+mKAsgS+S z+m@beQ(b84fx+8fAuf+LX+*Id+1Tj^!m5NnoUrryCHvAE=-jO1N4}f-;mz-*OJ*#S zG7_r<=?2;zq9`m>5Fq?77btOV)4XKK*wvfg4lVqdG$+#ti(3$zk(9K=;MDEfb%BJy zYfBk~aE3Q2v$C?X`Kifa(c9wIVU6XrS~x_}$Ffd-H|kpNp6no{31-~#Gw^a(sG~4i zm$@a=Yey{jLi0vLem&S__1g_a!MyUa_c6`>I-$l;-j0RaVQsi0%o^dn_F2{#9K;KIURJx zu=}65B6vFXuN!r`81GM z^$y2fRw8bkFn(rTn%ClWA|IRLB-AjDhRYWsC#dfuc`6_T=~LWWyXS`s!C!G=(#g8d z9At5EOM~8>g9uG}x`BtrImRK+aB*`Mf4i=RRmo&-4DC3z45sGmIX+=@e_Qww0VJE9 zoL)5j&YuKja{gSjHM7lotRc-6EAcS?b|@eaEC9DG!q{_cpOdEu?|n$&x9)r~ObO_k zqn|I@ayqNpf-)FS3R0jMQy3HO{mMvaOe&>ZXzr?S6CPeUX^X?J%Pq)WR!dUgf$+x0 z{zeEc&moEk0UI3>fVZ*8c%BdpLslO-5lZMTonHyxY%w|QtP~e590UceZ*bwVEI3=T zua&i>L>j)dR=T95a_pKn!WM=7^6#5o8f|pyVhSyGB0B@AI`U@hC20ubsq)6g8YNsg zxoaQ2>YEd?zq^1y6gICR1KaQTh>MdT1>}+Ba=i8pSvZ>2=7V&ap4Q}Q>M|-;z4v&~ zvY$Az?)A~5r6Os;Ne%*d3+~eiQHeq?8~|a&T9@0!RZ)l#2_cb9K|xM8 zKi`^XD@k9tXv7=026C(qKKN@qjJyIeP!U++SfxS%l&xWPF?rL!=Xirpw{zD`}3yU zUAuJD1*(_X3Ka@0?FWjI*%_3^X)G5?E6P4U6c%>;YxyK?Gxx5HXeGP$A9K3m5P|ai+pg=zWfUp1T4sOPN$WIzGOb za5j1EU85%58#E}-P8c!D$e7hY0aCy$0b%f?dSCmI=9}UltXc8MOsDY zuo2r2O%Bm<|H3ZT#gbYegyv`kZbl%uzE(UDa@uVhFZ0=N0ybmf|!rttox@ zo1v7|-FLi`1htA}rBjed#Da$mxWM0b#%p=GdKfN>mpw8OL8(sp`S$nJSypP}1)O$q?&jyHXmOa_4q8 zAH5<}jCkPnV!uDn=B!AsQ)5EvVbT^Z8iA{??<*lVVo+nhfK z>!(*exBr3LMkUp`-CVTJnb{aWPs(%AAiXOC$vXm}KBP=YOo&s_NRLVqM^{V|b^At#OuWE9mGP%Td6@)YACVLiUNgkAMRa%{0F{rA>g*MD022#Bt${Ey`{-D znZtH(2s19%F5g)+GQ&(3o3pbCyH1C>7UXiwN8}R%z%z>kfNi_y0th)gRy!r%m#so} zJJG*El69lrI(TEKt>?XyHRTz04P9X4^`J|_y7EK`eei?`oV3R2^*$0pyPe53rDH}s z`OEMO;{3MCB}rW#-)C%^9a%I=h6EsV0n4ZnA_4l&MStkKyyYw}Y=<*@PW}rv$BcOF z#mQl(WB-nAEm|_TWgU}-zhc5hUxRE&G1M4vtw0J|Z8nw~64MQgRG+1@o;tNR6?FYO z=Re%n7-V~OVIeGqWBP-pCFDawPqKJ6oXP?S*Hp-#(fIoHul?#rL9g)6$HrGC`^dh` zuTUZ27ZzpM@a7XAvb*gR?{1*nh;>NMuG|=EiU`%>TZf(XYy0r}3?N7d#Eh6Q_`e{O z!0?U zPg`ii(CCeLg~0KQv;qIYNPB$8mT7lXNs~?#-`?SvfyqlNd42cazHh~l-!wirDL}6;J71>N)P*rmIrQ60I z{99O;)#C>@t}ONBOU$Igke)8<%fuFE0^n??#R4%9MCJk+JEP}QKbcZWA$6QoR$p&& zO;L~B*{A)7M~*8p5)wp>P2#feXkoB_kBbskY>z>J2=RQ)wAN6||Ic4+9Xqwc-rnJS z-bPU}J9@sagd`-0gfL2_Sq-up);x{Z&}nd1Onc+*u(qD(mbR`bg9JWYPcFT;v_`l9 zLg03Sv`sw_Vh~ss@oqzACasdB#!0#80wUkMaiqpe5yBJcmw!_yMc|4#iy%iaw4Nh0bKZOU*P)B{ zfwAefQ+t=0m5@-PZ7dd&i6{C-2p}-7&Y5r-g_Jl@N%+_-7I@!y+cuxX<>zS&P7^~e zUps^B%N+vZ>%a!MA$UzQ!w8wPXFYcK+8b_o_3-<%7SPrid*{V_+H6dZL-Ad${o@gp3uGez>%U+OB&;#pdu<|VDSp8HsX{n4p;-3 zM!5D;YxmUF=KD&EroVVi&;-7?si@l5qlE@`mkO24o%Mr=-R|8owrU2`p`a4tNp#sw z7U^)*8N9jGxxYM-ZNug zxKH$17PXZ$5UiDT=yhv9h?G{G-`1k7(y9t*S%S1b5VEJr!xtQjj>%oP4rCF6 z3ElgP%6fC7O?q-lY|PMTnpDq=i!%#&p<2ZEi=%9jj}&`H2gkKn?J=hr7EKCIS&h4; zd1g{$d_1B#yro+R_98OaibnwWZ+KUN)!}My&*`verh>R>xa=bCgE`sjboKP~_^IB_ zH$(^u=QNcsOO4GjaWW~1r4KH1?u6M>#qiGRwzcC!bX;@in>&`QpgIcR8u#VvhYpRV zB&p!I$nbhQT0!)I5E7BB3Mpu3Zmy+eedEjrCWN`45B_Q6$_nrhJjE~&M+rC!h0C)7 z_LZf5BE$+UFnM%a@9|IOEWPgb^Q>|{@#p!YjK#&QDt-KqzJC1R$bu$MRX%IeX_QzT zO?kQdvUqoze?>;pi2b21tEV1az2&sCB1bP6~NRm2Yr8!VP3 z8f;$g>8%SZG9P&4Ct+i=izm*Wx$msBSfAHnciIqU4$JKuj7Df?kTQ`8Cs{yFMcF&A z{Pn#P|8qU+%R9Cl(^Yg1`T9cDN2{ulS)0#mpbCaZu>_+)Tvkv`I>W+sclj;U#${IwIe-3Etm zhKu)RrfUjvSkjnuxd|!keI-<|yZOFZ!?!y56*+YcDSCfkgFcRX8A_Bg3DoN%LyR*~ zjyq||*y}(4%~z(FOTDEf99`hFDbmvFTJ*dq?Sz2{oCKlzQ18HEq@!K~rHBZ)>E(4x zk{8|ibyxZ;bCXlDNt9EXnIsY-Hxd^aW;uZrlxWwHSQ!dcJD`F9?ri1!4X*}!5Embr z-KNyH>sft@P`pB~YR>~g@B|crKPN^Shp7?q-=u3uScxY{41|9h+?3_qx^mt0r+yvw z$S&SFyfT^f2~Jx}#lNic~NEk@s88Z!*h#SlD(*gu3QS>~|j=|Ad| zZu_Id$N%GN{{F5}8G#ZFuM~K(5OJ|#9z7Zr7FysFv&MqG!HQOv0vjT;rNze6l_y_W zd`0k4*}r>8y2T>=SC0sZfX_Ej;Rq0bl^bFpr-!gZ1PDow$r!lzwV+?^@Q^HHwq`RW zWve&cd3(48nLnx~A)dhnk07lfz{_BWMSzgnD0ZM_GEl$AYjSJj%xM~n#hzB2;o|v{ zA+0aocpd?UKmPGN*6GR@|NXcB$;VaqEUCK06_A9{qiwrlIHQq)M-b;~jz#A>c?Tm+ z7&&Ukq{FWU-}Qml-YIn>6~RZ8UZ**nWQ8LZRg)0DA$*ijfX1yS1|7I`)}=h z?7(?OLw@(ab(`(wq*3^7YpZocR0`8e3kT%f<3Fi#9$nIW&R)ad%d( z6F02!s>p7wz<*KmK$KF+Kx=~{Cac<;+~RW-=f|eVwvPYljn{nHw>Yc>5#Plh8$eO* z(t<%bIfU``*E6AiLna5~?jnoR4CzIypvOFz(0%`7n3|;djPh)PikMzc09_M4SdhRa zMx`LdghUTLUY%Z=hE_Pbwyt^agD|`B^m`vy^RE0@#;uFkw?#?WH1=YD6RBc%t%ta!Ei60$r4#*`%<7kc$7rpa@Jh?NmIxD6y zckS+%uL~>3J^lWwPwERkt6Em&p?1X#jSyL4MFw!}q>LzBh7b>LTM1PG`gw|K8ryol zT?nskDC)H{ezQlbT~@m%sccd6Eq&}-{KcFYr=Ga9pm3WGDP{1MOo4%hjL{=s+HnNz zdti0P{@2oBb-AGTm{~-KPh`aU34B^1LS%3}Fd^6n%kJ`ae$w4NYw^S39lASbzcV+N zL&fTrJ=v&WR}6}%?M^agxgdz^Ul1ecbQ(cwmK!TF*KWUW^7&3jYj0t@-%sY{G13{Q zs>o&N=#fA{MwT5Vwoq za56=}N?CGgeB*7??>*1yczF4`*GFcMuJ)WlUGCfxyB_Qur8m}$+Ogr`KJ02qiFI2p zH6b(ymI@D~frK<=Soy%qp@QAxn;w{HB=W2h?8QKrAW|VLp%+<&6f-9(z2eX$UXWqB zRfwt^weFU%3w82~JBN>yYER8j!vkJwvGXb`ybCho`-Pkt>>3H=QskI>O;YB{2d+CG z9gAD1L8?}5bwEL-elA#E3ylzhBbHq^WEIrHz8)WJshUDLFLid8l0giDdkbCmUA?V; z3Z9PJuba5CQ3`TN9G1Qa2u1Udc#X8aBVg7jaJ4e@#nztu)MX4MOJnl8-sUz;|mu02KRE=RhZ!Iz}2{^QNJhVs+X zZ);R*ttv~jNzCU#bVti%xG35OeJnhp34>@pzqP7L+e2tHoi$}+#~%%wka-pa-b-`x z6qZ5@Shn~$wZ!UD*(hUXW297((TD{v3lw>TQJ#^LQ;<1r+6ypNbuO-YeCeWRdi@-dg35QLb71Y_dRlka{ITHUdG zRQ!6lnW0etJQ#xKfeV0)rW7_Cis&Tpa)>tLJ>Z$T{I0Ydr!IZb#4!5~0`Ds;9MIO2 zri+QGug6_RYHci-HQEGvSrMO|Y{)2B{eDEKY#MLd^^ZPx)mP~_`smDU#pT(>WMbDe}6-O zjwg2(mFTUXA$YJ7HAaC3$iQF&A(W!9pdd0Kf*kmW1Mf=D+nP!$DrcWL`r|K~l81k~ zb6R<9Pmfj4H(7G)an>H;iytXODYJ-ih`2vJ7Ey&U)kD_2@%9y84IiG|v}W7-mKEmW zj95l4i;ju0AT{uDN{cv-kwPKOQdnT>C~z7Ye8r=hH{E-F!?n1QtzCA>O%}MM>eN)J zZo!|$5AC7lMM)!*DwLLL=w>n`8RdkG14b0$zF7jM_zNQv0=`c?*dRZWSvo6+6s>tQ zye0Ib4_D8AXNxzjOV7Gggwlvlo&^K@0f0#7*(Fie?wJ>7rlLRjc-7|fWDsvYv~lH2 z+eR)a^>*pYv!&4A!eFN*e?~w;Fq(tnt;=QsT?s|;`gwa+Xex^)9fc(4x%ly{M7=pX z=TcwlbTlWe-7C?}!8y+d|Nhr*ULwgb)^+A`oDw@RV6K8qnIuDsdEv-$jWiBiXk^!= z4;jDlJ*{a_x$;slvHA0=SX6ARSp*6>=i=>; zOC1cH+Dl9b>=BgiJvBqwjNPlpcC0wXxQV*7u8b8DttbfqbP(b;XPi7@phg&(5Ujvs zW$lG?XSeQuZt|Cd^5-5MJ~g>m+M@SrIy6qVtpRdda3SE8j3W5Rgiy#%*=T3_me(E$ zEBl6&H76BSW^O&LbgOt!WJC>3PH_`8Nz%G3oleHUgJPj&MmWRk)m8@DD>$cn&!<{) z%VSWr49B4?w-3qOImD#}J+z+Do{BY2d=Q+g7m{mF`J8TYn5^D2n{PhP=~zB#PFts2lUU8=5H43Mt|HPLW9AjfT%h$JpC>^7ctk)tQ#R;$@wpA=Vm zLXA2oC>ursLe-hT_H!wyH^>8RCN#WhaP7_6j03?vp2s4DY=+)fHa{Ki_q?&FtW%bv zVgN*_Y(awjpbC*V_d;_zDAU+e*jUmdGgKCBgb4F1xN`ZC;YryN zlE+r|zx(CIuSlG#$0kwa7y>E+?v()3G!5fKh!$p^JDTvbm2o6RD=zs&2x<1&Y0ABA zKVH*5EUk&}gPTkOLWJOQ5k5q~AQYcS2E-=_Z&^|E;t#?yh({-`8RN7y0AZ0|9!L-y zu}P8zSm6_KJGmwcr$IhP)ru`e8_!qN828id!`lR3z7$>XwrZ`!6iqaKevYek?@Bd; z?e8Tg#N%_za)hx@VYQo40&nkA0m_C@Xu^__D-DT>;JbFTyRtp&J=w)0*GvpANlZTc z{>kIL&Sb(7dHo531|E{f=@AgQMsSgJMB9;1>M2gM6*?)EXJJrlHXN_svSZj7gUn*l zIQefzh;ijPHtVOOq!3ebmw^zgTh?B3{gv<-Pah@N`ACFdDw3EU=(jK&jgHj7DDonDgEHD>xFVSet_8(x^-eLM@#5jt2`j*UWn z>Bgo*pcM8zU)B0$r#`KE{&dl%d%+hv7kA&YvwP^SV+dg)br;;}$umGmG@%?jA-o}F zRu&C}m@We$QpEeG{uJKBQ`2htuu=&U>T&_HbU{WKoC9=H;CD$+i59tvgPCWt;A1f|g&VkK3nwaeDGl9CA#fBf5CXWuG)E~br-x;C?t{4niNtgn2$6VHlz%GjY5&!>WPLz^yVGXNn%HZ;}^Eh+INa9CQwizcDP++dDLxBsC_) zUTQ*OJvn;UBHRh{D0G`cKpSZhB;L*f97TFlq#hVT z2}TjYM2#jy#Z)62jhfhyfJTjpnwV&e>86Vx0@KLCO#0Ayf8WO<;I1CE8%{yzX=`OM(2WJ$qNBES;y z0GVBew?6-wU)Gck6T@VuF|l}nvI#3j*!GgVF_5f81F{1pR&Ulp46Hop@wyYtjc zisST@Ry*c<3Q1;berDHREK1wurp!bSU9S!v znRWv9foSv-CmM9hlrr>68z_BT`X0^l@TgF1oa~34#RUyBFF>79WjB5Fwbx-nVHk>e)CO>$d>7?0Ek1Y6M<}l*gMf`zTy)C+O;IZamLKn zA6&{p&K!YTFDZ#&h%vW9361Oh4t$13yLpE`J|IxGvSHN^quzDLgc+?Xp8M%=#^J?D zi?&@nvIn?37QvCHLiq$XJ+^rDlx&wrlE_(il$`zG-7UX}R_>U-eoAA?f;}yw&Pyjj zLNBlT$3(FQ>!M!q9YO&hqQQ)$wx3uKEr={MsSR|Cgm{JXNjNh&BV6TX*zQze&x- z)vL*g6Om@@)qe!3aNR>#!|viZgTp0}MEu4p6GTp=f7QUd&kgUg`reB>yHmHe)K<*0 zmcy$HE*S6lkkx?XR$M}eKYzrOIvJzBtp@MASJ~GO&N(z?S(6KzcqxtS0A?iZX zVgLs=lP=ggeM2N22etV50|j1pa!aL~(9gaj32*h30U>0Hg~dF!4gld5+C9|3F0Ym# zBB*{+Y|(98KkpH>iYv^zf#496Rha4ZTV1pfF{Myh0nG~$A-SVcCnF*sLOjzDuAm^W zOY;*Hwqb}Qxx`evK$4Sd!i`#Q8&|BtNo>B^%PgGHekxha0 zA(u!^SzyUqT8hJVbHkkB_0wCvJg>7kgOk+<9jx znE)IjjE)e5R{np8xCc?)Wd%Z~$Ce+?4DdwcP!S`)QutvyW^9DYwra;0g{)Md7x?^-jwssDz#`*#-Gcv&4hK7Fs%;<-#C7zX5y zB&*j)nw%P}MdG(L4=x5JAT=DX?Cc+xN#zGT@m&y4=!PcSAz|{7X`}>dfSkX_Y;Kn* z+Uw8udXO|UbYv1aYp~l%B4hfwdxvhSmBjcwij(VGV-5(ZlXnGt(gj?FNT^SUjxZt- zDz{}IEMv^)w>A|I%-eGPu+#C<6UTS0v+)SYT5s_UNEb5)xX6`BygR2ZQV^;dQ{%{ z;uqi8Ft7jQ$t;3Ln03ZI^oTVkB{?{RC}Zu$IiUbjK&`)T_Vh|VC6A*2FMuG_KKON9 zAX}(OW({V_qZS@Uc%~E0s;W+E-Lc}Gr++x?qJ3!1zOx0KUZ0C+nnY*(NC(J!3QzOc zfnlH;2o-gn{WSDC30-s=q#v3W!aH+~l(+x! z=YA9-ss8f#wnIr(Mqn>-N2!@65%g+diE^F{cDx^|7Ov$}OuX#8#~u));Q~0I>lYUT zAQ>41i&XnN^iVmcP?^WNYyW-E3_Bg)es20U8(md~B;yM#i(4PkKLsG*-d!|pzXPkzGZSCyH zdJF=F$U4CR@#70KrVsDX{o1A-yAEgPv}9@U{4Ps^iSe~aM23`8IV_;WAS1i<&85lS zwznTrQ&zBS{hjU0U-0Czjj7Ypb69*yF@fkBp%|^VH%+ z*#wpr(vT(`-{O&_Y3ZfZ*`F!@-1*I@(8mKxjRge*DmWBK<_$F42n{UYy?eEZ!F^5B zra95skChtz`57LmQIKQ!u}=(VFTV>noNN>jt1l_hr~zTkFk9hFaZZ_j%O&#dPsh80 zL7ix+gkTv214NM!LJQ90i}x3?Qh@I9>-Ao6b8NxN`5@n@hGWCg)+U?Htkr;P5yf!Q zB*u$+%aiV~dP_|$$)qP~-}YA?PKaTj+_z?i);%6LX(xQ7WOr&h=`DHtOEPh?vfLvH z0{?#jgraspe)`Qb%ivkjB^l8GF%)E&MN@76{Lc-&U*CWC|9t$XIX4@7ZR-}>H#x9l)8OX9_qCRtR27ZhQqxG$LAYV~?0 zhJ=l;o4$YZWz>-$J~+_ltW0sDvNSli>FF##@%*=B;$&YYB}oFIM;da>xh%nU6axPn zPGu1Y(HUlynh*RB@SX}93$b=XC=C7O{2c{L+M1h#c18|-l$`qlUY=OcTx`=qyu^h~Azb4%cTXtEFl0eHZDn@D@z+3!S!F-j zu;OfWQRRa1d3jg|5UbSE@~{G2(Vn<~z!Z=4Y*X{Dm7AVVh;QHc<;UCCo;_PqHJj4H z=CUPgWFeYgnJ4FJX#m8i(FkJ=NKhtl=AJ7=n2e2b2`~g_7EDVm<~9FN62Vj3cD1c? zl*3DF1{vK9r67SVH0pVIGq)XHm7TTq#Ir|s?%#5qIw}A0#~<#p8A&z`Du-U4Ct1P3 zE0q*^3_3o2U)N;)&7s}*KuQze6q$n6h$$*#y?HK#4 z;3-_u*uM@=2oIrD6qmz0I5F={OZI%lx%_KN@w&rHyuUXZ^;`x3!qH(@#cRQ+I1jA8 z-jEsWo75SVviic@9c}&qW&u5Vx#^8zlv`Di3BXimyc0j;tzA1d#a9A~oalP!i8KA{ zIF2PVYqsDEY{1C!W&;MqViLHq2IS}$A@H7euF+@|bSWmJuISM)i97yHOV_~!+1@G0 z%%9rwY@VA%2p632ASfe}U<@Afi1$Ki-W+eqY&f>*;V7r$Yxf*mH08vJLY@P?`TBrP z#10gJDWpGUX5s>3>%Z_yTsYL!<)d%e{Ar}U_291|-K}96((iyQX-w`AQszUIc#b4d z@#DDgbE=#1=mqGpQlAh82uIWSyHmDpNs#TueFiaa{F@6jSeYLTG!B(@Ll_Xq_J|S5 z2o>7Ttt<)wnWhun>)(0u8&Q16L(?Zs$;{fkTGYaLV_Q&{igQQ>57GoC;(aAUH343^ zub3e+9{-qNYu?p+Z%jCD`s5Q8aT%t7?m51EoITjO>37iISCd&P8odawTfW9poFO9#m1S_U%4v^NAf(>Q*2tfwUyy1aD8%&i$Z)F$zU52 zO4dPtu^F$i1Z9RRg?42H=C#Wh8PhWm$?FseqGIj~~ojG!QX%4Jd{l5Z?g9vGt2G``FwoWFe6d8pFFus8OeU%9pb#y}fW z(~mqFTX^OdpZo_L-qSU95dcA?69Bp9s(z#?TxV=?l#1!8MNo;UR$_N|~%ZuzR}}ntwsOT4ld};P3B#c($#FVo34R z0HR}L5lwJn7?4CdOVb!^-qkth&M;-A)&p9zZ$e^Ec2iKdr{%Ym;>>w3{UIhKKTNo7 zLr12lL0SMrHx_`10EhrziuOxWouy3&F|AXBEa1YRI2a|CP2v&+cohuF_&XE)tVsmp2Aq?szsZG&6+C{N5?2 zKq(?N9P?({yv7%hvAxg@q*ak5jhi$F?wXik@O`cAP7U&6K(zEVuZNH#!*MbouG~hS zunTwVZCLBI;C>edkJYNjoJkS`qMY+v=fnd9^C+?m2Yy^2E5mvN?NiPTMs*Z7Y}vo7 zjRVb17%>3@K;TY@tK>j3%CxBqK@dWem$df6;7AO+>$zi#ii1p1xJ3j_eZ+nNE_T;nQP4uJHlEfY3C73piC$DP!yWF+@1;e26R-1}9;>D%GZ} z4y=Hs>x6Q%Ow-$lc&3nBG=(3YMN_1e#?73kzDn`ts?M4HO%-lYV-cNl*8^gl@c_Xe zo#1g}s#%sXve#^S@^dlsi&qz|dyMvagp-Y1VTWj3t8yZi@=Y z?F~jBNn=1n>e>SW$AG38_*UgTOb%+E4xaa}cS|a;BLE{*fWRyaVxw;cK5WCSbc@EmXv@*h1pOD{QTA_yEj3^r+!c76%)Q3KZi&Y24oD3 zrf>@E09WK$eh?1(lOMPww^L-IZuFrOt4$`PUH6efSs%B>OO+)b{35rT^p*MuX#Xwg zcn$XPsO(4goa*j|hiccQtcC#+oK%8cPRSvMkgzy|9%ez=yc4hwi(Rs7`+-{%V%hx% zcg(2s)|RqHJ&hYGaA8--C@29%9YOw?^oY#DfRKzD5G!ZWcNeIsDU_xyA9NJFac5YFo+_098cQSS6sK5o5Z0*#Vk?|fxrU&r>>qWSkP+|zk@ z+_P9Qh@lg$S~+llKmhD=faDSgK;b2Z0cEt@+~6_T=pE}?{-}4|l&0XgV7*pP@jT8| z-K-J2L@^ae9{jq<<2?bd5NK*%e)^f~h5^!1mqrqPP6mX!_R{+hN68W_4zs(|yy9Cx zZtlnzb9j$VhiAmqYx+Ea9ENK>pyYFN8$9s=Nunj6d+W0P-3Ow{+<$(p zD5+4f9ihv7f5h|1NJ#mIJNC!qK0tT&0tjCk{F$C*AT?Y{N<92hMW0DtWC6WnvZxWH z`)N>IG*SV74<#{T?U7SY3`utUdyH;V<`=F`|-l!#9>UyvPrY zh+Pph@`RDdmk!_l^ewRo+xazzf0J9AgEehuLwxLO$vcrE;*NNSEb@!LynM zfJdx5rzM>&t8&pj?G5LZ388-<;#t|iv9^4SMB?U(`1iPF^yRF#YK zig@gY5kR&M$9I4S&V)i+2xY_npa@B*G@n%0+`4n`=VHRKVgA5}CFNcwN0UXS;c7pX zQ#pQfiYQ>^6bA-G03iZ^L@PjInaz0+!GPdF3PHQ7F%9EdJ0Fu=|DKX5k&KZb5Toom zzP3hTUCtCnPs5J*af@--eduJZF5g=v@05s=n28Vp_I)KmwlikVPmLrK4Y(GP?B;2G z{%4l6#%nJGRbTC>3nf1skyR-{2bQ|R+) zU@)apYMl>mB^GzS`1slqtIH5j;Dg^Ot46_ZRf0I0=jFFYUeZtSU`pXk{>DlP+JgcA zTf$%KZMg{H`EXvIEV{;s03cYmn5LICqCU*ve$*EX))@jAkEnq71byAQ5Fnj=6|Dw@ zB(aFU(eEdI+cJ3P*Kdo`CwO4@LIW1KX4#nEk5LS-H#B2|bcU7kB?9G2IXO;ePG-Y# zNcdwih{xWl!MXuPov(N4t>nG$Mek z1@{LHh5EWB)jhAVa=0_y_DtV^uHHV9X>CQr<#}f^ZPq)!jLO8O96Jihm5Z{;q zLV@=tIY(v|T7-T5@nB}Qm-1JIhQ!o>_^6CCisOE;CO=1tss$mCq@?ZfKl?Y;g?s%y zFZK=`S_RU@9zBe0lw5YOp9FI!vm_x92%jg)n^9iUR`;9!-J2ec$(Fwl<0EG$BnCj2 zUL@6hTc5fYRFb1=2>_`R5HV&a1U{c=TA;C7C)avvZib3XFZe4j9Vk=YBNZSJuLl}J z)xr(CygBM0z|66h5iMXXJF;o2{7ToBYX=*Tgjx9z2Xgf;HQtk@CnH!2$mg7K$a$3OL(dM zIDDf&*O>DuS|32M0Pz)>TQ?|i>!uP)Ol4Gfutv@dAxyq&RmTIv%WoUDFH9xMX1!2B zpfc>R47TEU00u-tV+aVY@hnZwDB-ALd6cb2oA2>Lmmos1#s{=Dw+@M{>036d0Wo?wvt8#T z%L-O}A(BD-=M!sxexk)KEz3nL8dB|X#)g8D&1|N?UBNl=2HEiNEL*kzH#m;u5tG9Xpl!avFERFAhR+vD-a_^8NT zYwK06kSCWr?|ma7hF$m6{zV&g_CgCNK&?KJnq~BWunoE#AeTKLIR3^_+SuPA z;FQRv$>x$fmK^&ca1Cb)D6rTpE6T|~{BDA5A1zO!AkR|&2rf9-O}}+E9SrxCKQUkj z={ciEUR_~_e}OWS2nEEL0%NF*@=|&l%*BRzdGBT7ULO7}jkU`HZi)hm#E_9#avFTW*LK@34+u#Hc0Cm8S!{dE z+mbT!4VP0b-X=J2v=o0g?5^hMm%e%T0%LWegK%O<>~hB`B;+iLaXz$qPnka8H&b4& zeOyEFl#8eCeIs_4)zN+(?gRq*1&<36idMO-QXc~lA_qxq9**A9n9j1L_*!5rOZK%tJZz5M3fy7tpDK@)7hyjQk)$+*W zp|tT%$e6n<_;`sN-WZbf0j^fuTO6-O?wfM^4N5j#Q$D%`RA zS_5gx6SY|DjD_@E*((eN8`r5AfOshy3P$x!Ifac=*59tu$%%hKfvZ^G_jvfiy?tU5CD=-=0KGenHL^%S#1Um-ONhXt%=#Y6W*gTye$S%) z55{hazIX8Al;RoX)ikLQMTMqI4HaVWL?aZ?c|; zZ~dd)0(Ll;)k2x{T!L)(bxBj1{MjP`N#vk6-r|^8vTD+o!(7IiBp0F0D6#Tb#!N9~ z!I~q=2~lYG9&Ny22-X*$JM-o1W8NLl9cySb!AMt9l^4H9yrLhBSwd_v=SO%RZ@#X_ zW{U^NSd)t+i6&0WNWVL;4TnVE@0hHxdPGcXU-Xp1pFhzrmgdk^BWvzSwCV0Q?*>3V z>>z?gUH9=5tLKbin6L;Mn=Emojc$3GQP_>>gi3Te)`wIA>DTX~1Sh!lt zFv>KwWS`Qt{`kMtyNAN6%oom$!pSUg-II7vil<%J~5 zBapYOk+Pmv0HihSg+|sVLH<#NB-qvY`28^n-LJp$=(eB^^JXDgvIuvi>~6>7LS}1* zL!N0s^2uY3bRtKH&eF!N-k~j$UpRQaV^?vlxM2LO7hCsgf^7Gm%r=suky1}&aWo?m~ z&8jfi9GqQ;P_&hacm)_LOwX#7yj~=SShnI*_dXvJj=S!gz70noEUEgqk<0b9S`KIV z2#f^drCexY@Mlh$Ie1S*O7q5Zx7TcJY+0~*PZr^tV3k+JQUHinklc}!y|8cS*VlRA^IwWJx_s+vfA4)O+kl@vXEyVy`VOsZ$zo+Q;v>Ys zfAi|As!`6Hl_krkj~pDi9)_i|QQD0+=Hv!44dCSDO-dP7aE6x6W8DS>CTa=``GyVh z$e;k2;dOCdp^Ihz5g-f1T&?8EvO z1adsdX$X3T>TC-KU;SFla>tV|zP|IVf+i2bGEwG&46Ul$eyEMuoebvI(}KYcW9~$L=_44+)KZ;3N{qgO68Mf?vWF(BTKAg=asD2-q{O*t#5|%a-xb2E1&ydn)-!T!)& zf6l}b63~A!0I@B+HyoZ`C=M7PaYI~*RSgJU59|~PKr1mRBRji!`_H}_Q;p=QGcWI> z(<&;8UPzP4C7oTZ@uyd0d%?fT=Y$jtI>3WOzvr749v!w?PrcKvqqxuwM;LI{LQ69A zn5L#SLbG*!sM%U0Qey##%Z2jt3Xfw7u(!TKU1C!r($JbV)EpXRyXT07g{ab@r&IvC z^oSvX>%p}t_WU9U`0F%o#7$HK0urGfLVyR$Q%Mt-uUY(3Y|3iO>DKzGza<$TeSaXQq^7e{N_C|SZ^U@(pEG;lwU97kZr z0uZl@6Y}!p4xmpuC8H6k>k^n~2nkzJ0}m=sXK{CB8qz!b+8BTo9}jnqepFz$7u(om z611yGlL`V+ zatu);!stlxr2Y5Dk)BcZFwY-oZZ0ru8PkHi@hO5o=`nd=CyUgN)xZwr;{1XoW#RL( z@FmMe0fgZ3j9^YJvo%1uI+aKEwF~LV@X9;XLJfg3nr4a2uKBkOHH^NvB=gzB^=z^R z1ESg(G&$f2ns?sNP9^268@%G=i#7zmv3!3>E3OwchTuBT|!fH3lwLYKpDc5!B} z=(IHU?SAOb37^s)er0>>65eS!(Xy4Nt>yn7>yb7>?==gR>3Ff_XE*h^Srs1mg)=}d z%d)$L=R&|R4+$&|QlsoCZFtb?WXYAw500=*Ef*O$gruKHfzp3lY zM?btwOX%_mE*LWr8TrP=w1)wYc*rAJYK=wQ&4pWkkvCsz&u`zj=<%;5T!G!SX-7f3 zl(Wp6%^9-)Jv8*mkI~R4QDx2)l9$cD3D?MNZjx64LK0eB>tTu*6tFd%4yA=hNC3}j zRV#?K2RyS-Zf@sG;kSD^UDy~j8{Hb-YE^SVjg8Bi4tLCZDmG=c;r0(ossRob;W&*_ zXi3G?YD2gp$>1Km8;e-7XlqXHs+SMkb;VjIi(g)4wLWI358D1Mk3|C@)EJFL6J>#R z9ovB4=O@?asC#)4OJW^jjv~|8-1@_5}za3sq0_F`zzVUo}uE!|p?8pBR?gUvo zn{noAjS$S^Dv63cK3QBd{ejpWy04t+IP};Zyrj`Egw|n&{47M|I2^#k;fgc0sX2Ztn?WVpY{s1iO4f*x%>(^$#9{vhV+BKbre`&90gqe;R9P z4P<0Xo!SBXKEEmzk+pU~y=hYmNh@v4-6|AMn*KY5YCUCwJb=njYcdMK+*t(K`0Vj; zYW?>^ey2N!(tAK%gHSDSF&ujD#|bg)`v-QkL4&p2AvalT0P>O7dqT{VQc;Pb$0Z2Z z%_6?((D8Y5znX9r_SL!X&Y$R@mVnPNs{yI2M^a9De^}x0k(y=$=`2Dn znsBBvdKnO<VFn9eD;-2&Cex)mj2xms zX%67%ojYQzN-cCcef#v+V3_c-1}`cWFeAePX4hVo6cPFDO}IGq%vz5TSyY)Xg;-hV zkk%W?#zBR_{q>{MM4BUjMpRQFek)aKntCD3;BBh&dzJYeMP3Zan*tfaWvxhFJ#E$Y zs7-IrytAg+F3MG=ki17|fjeePK&)4z7)cJTdFKfq>qe+5DREq1LBaABs!i5mJY> zBKBD9(43$mXD0Xc81-hZs-bhv>F~aWn8jaQ-jWYg!Or<{&eB+mV0!bn_MQ4VzI+R2 z^A%#23J{ugGXj3lMk4)h-&C@YzVuQqF$N&z6rt&!8*Z>ZPDsk|OBfJAfOExj97-NP zt>(MxIn5`w544r*31t~^BBeglijfMliy;$%xh!*(tXd+p)b2X{akCB~EWtR!8y5 zXqq0$9a+fCm9#bVU)=W5@8Zb7ZutB5frjkK2y1MC1Dv`nd)3P6F&Y1`kg{Q{gp?^> zv+y3=W;IxsKZ__7M*eLrMY7<>^?rxP0Ip58{TtI600a?H0P&cO1T!<79B*k=4oDaf zG6aY@)5nta(}y{iFW&jY{)VbMtn>@BmH{BPjFO2H>5)CZvpopY!v?LwveSw%Bvt06 zrwq<}IF9#u0g-TC0<~l1K8#Om!)Qh8NO{$I_PAX~zZ*{^dDF|Q zGGKg!;?sVw0HV_sxm!|B6dA20FCT~&@Qt|`KLApB4qw&#r|3`-@6R6j24LbH;i2N$ zeDN9OeSUa%eW9!qgh)mmBp7|hjGu*z7)R?d7zjy`a!|NiBp7tE)rYnM56)+{t*ZCG2(Y3`z2dpEuxcwWGAit70h-PCD&7#1E4 z%pGjbvm}%DjT^fL4}K?JEpQX09a@6X$ny$Loq$OfFyz7;r*xtT@8Dqo+DS*htD+9Z zbmbA{^>^>ZJ&Q+742lYfhymefk2LQ1{R+o3r#~TYxb0O8W|r_8%vdFvN-OS3T`goac{fxaAP-~4cnx6>b`Z;Qj(f9+If{jLp>KBcoX zjnxY2so;?)7Z^=rA$j#a5SEpBEuyf8Yinz(+53e!iT0Pztjne<6p;plnWAu$w-x9) z84x5t1pF5O6&y*;($s~!7e5tNSsU}Ud6cJk@D@mx&eW55^`c5aPLZY1JGhFhjr&7U z;6`15PUK?%lE2m6^-%auukom<8nYQcRGwKD>;>KP!Nc$cTd30n-XxBA5dJp#TTbM20rQ z5)9TdYq4x??Cae0k^;%tFP7xBU5l@Sg3KZtt>uAH*G2(^LX^YIibFk%ui6Xwq-sHj zb=|e90+D3QS@dAI^tOJo8V~~jLXs6y?#2rdFZXTpnwjJx-2McH5FTTP7db<9V)5Eq zP-sX>ng|Qlp-1JicKsv0=cY}wWw*IzK>--*F<2oUz#iH+gV}5gPGjIsks)!4MJOvP zb=r~jw?BYG%CTR)$_q2^#BOL|hQrLO?*t481%Q}s&c<^JgZs@#YfFuHBmp2~G(bF( zb1=+%e|f$7guoaM9ttw@#{PMcT9dst5DD;edaP)Lu<4`Dk3ixNjh6nl64-bD?RbRl zy!H7@m%2fEoaSYXMJ*)uU=d;`$nDlZUy9rs=faDv{Q9D;l?y7TPTBGIPyXh&Ss@_>sKQN~sb%y$lFz$pZ;z-#vAN_==>Hvl7I z3(Z`RV4_o$XG`SFA39Y6xjvCx*53d3e~!iaZk`(2W+#sY2<^wdfCmPAb#TuyK%_cL5fsMG~%8c#0ZT`{C=$ZTHKP_y_iang=&{&WANIwRQvoLe;t2|vkjh+vV3 zAjHUm=JGlkn$o)9!lw#J=0>Nm^TCJ(_tUP?f_om~Z>4*NmzQz~eP`fJyXocM>bQv$ z8Ivl^B$bOBk>bqI`?7daeU;T!g@qVQBBOSJ;}-rPoEE-i3#faA5yA@kdb()QPa-+) zQeKNY@xRDe;Su_K5?m7g8_ zW`w`rmW(K`);==K<&u&LH@CD<)sFnErN2$iH_upp^r<*-e17iqj^7Y$vc)17O|H!B z{2g~_QfWDS!US{;s1rd5jYQ!Cf;k{r;pF0Gk(w|;>s0RxA>|VS zG0N_LD4%UxSDVXKrN@9UOq2prac{UCvrZ&N&Mdl|t4}m$ zw(GX8-m|Ra><9PV9~%y3bJat)&)HSbB=?lkkn>#W^Wm$jHKNUyTIhfAQi1=>kyN}~ zp15|@Nof9tw98xBp4iwTQb|b$fmF9H5^!-Nwk3uXC>C@O)AO^Jot*lcZLfd)%Y@6r@!-dAceJ*dJ(CfJ z!#dGQUojwh4=LIVrG+yhKHuD~Lg4f0Yr(xMv#&B9_%aC|y$LSgcY!z|utbR(5Q5_H zw7+Ib=&HVF;W)Ebj}$=EC03@ie@obPoOcnV3J_e-6(AX3()D~KJJHH(NJtq~t-8*o zztv<(t|f`9)*ZX&^Kte<{^01^Hjrm3Lj)WT$O8W-K;W@31`bi{BR=0#GvP7lksy$0 z^zp#Msn{qi{uW$}@2nNwQ%S@UHXBj{f{U$evbgZhP;sg+Q|m>DCFQ|NH6YHJ_lMv5 z0e@}|85IzM&=%Lc5mC53u$C?@H9IKP?}OFbpxauqZ|%xo#i6XO`~DL(Q)Oosf2gZSO8T z?xy9`faGeI)NG0a$1PvkcEnClV36W;W9=uoo+5Ab`CkCxX^KvQ;#q_}va*EJXdI4F z2L#E003ryw^(CQh4jyG&spok;0AkKi%jZZGxGcoX4AC5On~{PCSTsOv9pS3%^XoHN z7O4RtWk9+gjkuw2K3-7b08StZRmA@0<%5^+db{cA6$^Fn=t<0f5b4tZ5PGPd0{-BC z0fYmJe$6nZ?2Q#|w1(syNOKJU!9vkmmi292W;-m8vUNz%vwIXMFHH`t2_0y z^5st#BQP?i97=KTcfx!_!(@V$q5wi7iqalgd~*aVyQ9v`VKCG|LhY2-FFW7yx%x~aDlH*wSr~^V00Em`gSth6H z@T8A$$T>61sHZ87iRaXrPfo6bvId??WunX>b0E8WFKuE;HmCgh0>?VS59s4mv@0-qvsG6g3LxHH$v`mvIzqhP! z-j|fLaGe2#6)d9JuKU8Cq&FYr=L~#QK=g(qcP2#UUAa0QxVtE=?2j#voeREkaQjNz z)|@P7QCgEZqre7Vi`2;b-8>#7_tB~7`}hV@cmPOn@c9utthPBB_?4p`W#P;lU+JV< zb7OAt!0W#TCa(9~R2~395E7-QtMV3K5A2m&hh3XY)AI^ttN@~xVV(5PFu~VBv81*x zCLobtcwUz-6_^QDqZ`?%M^9LJLo5by!;01n4jP7T{H?{wYGLfw05J;!12_<3aE=FD z@s$JOc36q-M@Q;!Y)gSVPLG!^kG26IW+105m04YLHh~J?GrKQhKsfMXnp71a8mGta zF)aK_NY|$jH*w-`Dnp`E?HL>hE1W!dsEn-0jsXa5xOhv19`*FD##BZI#7+!1^S)n2 zhvU|t)$Fr@3aO4^vYZ;0mCq~#_pV%5wE;MjuX_n2K z(}9LzSazuRL~vHTtVP6tm^1*?l>_2tIR|s`PE|u`gI_X28{cT-;iA*iDLu>bv;Btl z^*_ZqPjik*aye)gbSlj%?k5+LUgMJKHz}9zq$s;_VL*Unc$ak+n=)Cch<{>w;yLZSw*!lDTZ2q?m`AenT)rJ&IrA4!?qeE+82W=B50@yKh?av%geMyjIr z=r>i&<@_Z^XQ{_A+656kHEkLVCq&Y7+4Zkt-t6@yWTh_K%CQ)b2`WI0wMp<4NbNEuhDQ}OzlfO7Ipbuh#6*n+1A+)H(ZNHp zkAzyPR@1%zw&9Hne}3rpH6L0zg_NPLMTr~nX!64M5>&QjhH>)=$~xMBAR;}TbK(B; zs*DEwK0mzXG1i@HvnG<3@hOf(TE(Z=qMqfqD%sH!8&y%CJnsQXwx6qDOS>VA4ii}} zx-pAalgFqwhxclCL?#ov8;khFL=po+Ap|aqS6r5Z(sUn47-paRea+gE#Og95i)S!# zYCg(_Ke15{FP8e=dKs-g;dPBRAS{_~$I{7e$&*p@2yRf@MY9@dNrnRhLMKuq6SEu% zacjPyaI*V@rLh4q5=G7P!gu=O1~?(yY!2bigIPHe0AkW0+{!>hItRQU z{!dEN|MYMY@wI_fNlJvy2)N^P)B(X?xgz`YO;Pl3&IeZCJ?el!DCIOHCNidE24w)v z0dlX0veQVx27s9IQs7Edk&(Qs{6&T4c&YPbDWg_Xm79F)%!D+%VQ$#9>@Tp=g<=$! zRaOg90wc<4n7HlR!*h>!SNQ|8e(xsT42+MMxzPs% z`AqJL8Q`MTwznC&vmB!h2!yr(2w_@aK?UzX7II6k4Hfy?n=-6~CeNg*;bXNq*U)&u zfmWS~QT6K5T83l^Lp>R&UjAe_vF$Ce>UBC+4T#Z423D15724l zC4-PYQqD(5Y;5TWZoRRkrSirsojrI>05SSZrnJ_m^w}>vObg2VqYa2fLzpKd3Z}eD zwDuuce!uC3v}M1^&TtS|S&mQvqM-mQc1&5* z6dH9v7)w^>@n1xxmG}FSDF;95fbcLVCWxCWsezZ|0ZuPUV-2=gfV7C(pRZBqr0>7& zFBR2*DB}g0!Q7sG=YJSxrv|Jvg&U4kfXqsEwzod?*$8Iw^^HUk@V`7xEHEHAB{bL@ z2~_1;_t9`Sex`fcG*~MwGQ1g6*0Qr4Ws{~pyYXN`RGV8+L3>9V5Dg1}I0P}T(jYJE zK4a%u7S|9t84y*IK@Q_xwL*EHA6Od@RW1vK0LjiI4EsI~zf4D)9l(B5s!W+O8#T_% zl@CT%3wKYE05?IdVZtAg!GNTvlg8O~r@l6vQ0hEs*TJM4sp!TF1g?^evN4U_HBZK9 z_*#BP_GrtJA|U**PzLO@&7O{*+;O~c6XH;gR;DLa^t+EWx6;&Br3a7Q{>26frHg7ny__b}h zc=8FHSv8(k5P%>VbwG@|eux#L+q)B+8b=!t5^3@38N$shd=0yyR-Q%53Q~lkl{tL; z?AGU$CwH{KBdNk(7MbOCE|1q++^gvHE8X-igtTzos{yI395?+B5uSN^Ff(98qK@N~ z9wkv^k)ChdQ%NK)KQO$7_s%W~4-|Ny-XV8oUjsmpTndR{TRyPmT!Vk04UY}7^)*}M z&JY3#l0=2~JE(dbXiuw>7Tz#eP)6hsDnJZ!H+z|%FWw(!aNj%0DS$8|0nri*DmT6p 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 JPDHLkV1l|5=o

+image/svg+xml \ No newline at end of file diff --git a/src/frontend/packages/theme/_helper.scss b/src/frontend/packages/theme/_helper.scss index 7b9015f992..70cb9f9eda 100644 --- a/src/frontend/packages/theme/_helper.scss +++ b/src/frontend/packages/theme/_helper.scss @@ -115,6 +115,6 @@ $oss-dark-theme: mat-dark-theme($oss-dark-primary, $oss-dark-accent, $oss-dark-w $warn: map-get($theme, warn); $primary: map-get($theme, primary); $white: #fff; // Use default palette for status - @return (success: map-get($mat-green, 500), warning: map-get($mat-orange, 500), danger: mat-color($warn), tentative: map-get($mat-grey, 500), busy: mat-color($primary), text: $white, ); + @return (success: map-get($mat-green, 500), info: map-get($mat-blue, 500), warning: map-get($mat-orange, 500), danger: mat-color($warn), tentative: map-get($mat-grey, 500), busy: mat-color($primary), text: $white, ); } } diff --git a/src/jetstream/config.example b/src/jetstream/config.example index 69d9110c8b..417f5f8144 100644 --- a/src/jetstream/config.example +++ b/src/jetstream/config.example @@ -75,5 +75,8 @@ INVITE_USER_CLIENT_SECRET= # Simplify development with FDB (value is port of FDB server: 27016 for FDB, 27017 for MongoDB) #FDB_LOCAL_DEV=27017 +# Analysis services API +#ANALYSIS_SERVICES_API= + # Download link when installing the Kubernetes Dashboard in a targetted Kube Endpoint -# STRATOS_KUBERNETES_DASHBOARD_IMAGE= \ No newline at end of file +# STRATOS_KUBERNETES_DASHBOARD_IMAGE= diff --git a/src/jetstream/go.mod b/src/jetstream/go.mod index 59494917e6..989a9fc1b8 100644 --- a/src/jetstream/go.mod +++ b/src/jetstream/go.mod @@ -36,6 +36,7 @@ require ( github.com/fatih/color v1.7.0 // indirect github.com/go-sql-driver/mysql v1.5.0 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 diff --git a/src/jetstream/go.sum b/src/jetstream/go.sum index df1d085124..ca7f76c6fb 100644 --- a/src/jetstream/go.sum +++ b/src/jetstream/go.sum @@ -56,6 +56,7 @@ github.com/Microsoft/go-winio v0.4.12 h1:xAfWHN1IrQ0NJ9TBC0KBZoqLjzDTr1ML+4MywiU 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 h1:lsxEuwrXEAokXB9qhlbKWPpo3KMLZQ5WB5WLQRW1uq0= 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 h1:rmGxhojJlM0tuKtfdvliR84CFHljx9ag64t2xmVkjK4= @@ -130,10 +131,13 @@ github.com/containerd/containerd v1.3.0/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMX 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 h1:+9RjdC18gMxNQVvSiXvObLu29mOFmkgdsB4cRTlV+EE= 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 h1:u9SHYsPQNyt5tgDm3YN7+9dYrpK96E5wFilTFWIDZOM= github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea h1:n2Ltr3SrfQlf/9nOna1DoGKxLx3qTSI8Ttl6Xrqp6mw= 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= @@ -357,6 +361,7 @@ github.com/gregjones/httpcache v0.0.0-20181110185634-c63ab54fda8f/go.mod h1:Fecb 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/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= @@ -478,6 +483,7 @@ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9 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 h1:7PxY7LVfSZm7PEeBTyK1rj1gABdCO2mbri6GKO1cMDs= 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= @@ -509,6 +515,7 @@ github.com/opencontainers/runc v0.1.1 h1:GlxAyO6x8rfZYN9Tt0Kti5a/cP41iuiO2yYT0IJ 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/pborman/uuid v1.2.0 h1:J7Q5mO4ysT1dv8hyrUGHb9+ooztCXu1D8MY8DZYsu3g= 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= @@ -634,8 +641,11 @@ github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wK 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 h1:nSQar3Y0E3VQF/VdZ8PTAilaXpER+d7ypdABCrpwMdg= 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 h1:shvkWr0NAZkg4nPuE3XrKP0VuBPijjk3TfX6Y6acFNg= go.uber.org/multierr v0.0.0-20180122172545-ddea229ff1df/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v0.0.0-20180814183419-67bc79d13d15 h1:Z2sc4+v0JHV6Mn4kX1f2a5nruNjmV+Th32sugE8zwz8= 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-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -778,6 +788,7 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy 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 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= 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= @@ -822,12 +833,14 @@ k8s.io/kubernetes/staging/src/k8s.io/apiextensions-apiserver v0.0.0-201910010437 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 h1:QIhu1g7jmiv/90qGiPiCOTHFYEcrL0HA5P/6G/pt7zM= 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 h1:fwZSUxpQ99UBEkIhHbzY2pE3SPU9Zn4yZkMSolEt6Jw= 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= @@ -845,6 +858,7 @@ 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 h1:6dsH6AYQWbyZmtttJNe8Gq1cXOeS1BdV3eW37zHilAQ= 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/load_plugins.go b/src/jetstream/load_plugins.go index 188aef7b7c..861eb8c81b 100644 --- a/src/jetstream/load_plugins.go +++ b/src/jetstream/load_plugins.go @@ -7,12 +7,14 @@ import ( "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/cfappssh" "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/cloudfoundry" "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/cloudfoundryhosting" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/analysis" "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/kubernetes" "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/metrics" "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/monocular" "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/userfavorites" "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/userinfo" "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/userinvite" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" log "github.com/sirupsen/logrus" ) @@ -39,6 +41,7 @@ func (pp *portalProxy) loadPlugins() { {"monocular", monocular.Init}, {"userfavorites", userfavorites.Init}, {"autoscaler", autoscaler.Init}, + {"analysis", analysis.Init}, {"backup", backup.Init}, } { plugin, err := p.Init(pp) 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..533ec83786 --- /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" + "github.com/labstack/echo/middleware" + 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..3b3c846e2b --- /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" + 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..06310a41eb --- /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" + 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..52ad376502 --- /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" + 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..c03f7af4fe --- /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" + + 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..33dbc43917 --- /dev/null +++ b/src/jetstream/plugins/analysis/main.go @@ -0,0 +1,139 @@ +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" + 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 +} + +// 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..7c64e569ed --- /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" + 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/kubernetes/endpoint_config.go b/src/jetstream/plugins/kubernetes/endpoint_config.go index 8a7acd85af..8e7b983235 100644 --- a/src/jetstream/plugins/kubernetes/endpoint_config.go +++ b/src/jetstream/plugins/kubernetes/endpoint_config.go @@ -24,10 +24,8 @@ func (c *KubernetesSpecification) GetConfigForEndpoint(masterURL string, token i func (c *KubernetesSpecification) GetConfigForEndpointUser(endpointID, userID string) (*restclient.Config, error) { var p = c.portalProxy - cnsiRecord, err := p.GetCNSIRecord(endpointID) if err != nil { - //return sendSSHError("Could not get endpoint information") return nil, errors.New("Could not get endpoint information") } @@ -40,6 +38,23 @@ func (c *KubernetesSpecification) GetConfigForEndpointUser(endpointID, userID st 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" diff --git a/src/jetstream/plugins/kubernetes/get_release.go b/src/jetstream/plugins/kubernetes/get_release.go index d072870557..e4627830ab 100644 --- a/src/jetstream/plugins/kubernetes/get_release.go +++ b/src/jetstream/plugins/kubernetes/get_release.go @@ -102,7 +102,7 @@ func (c *KubernetesSpecification) GetReleaseStatus(ec echo.Context) error { // this back incrementally // Parse the manifest - rel := helm.NewHelmRelease(res, endpointGUID, userID) + rel := helm.NewHelmRelease(res, endpointGUID, userID, c.portalProxy) graph := helm.NewHelmReleaseGraph(rel) diff --git a/src/jetstream/plugins/kubernetes/go.mod b/src/jetstream/plugins/kubernetes/go.mod index 288ec1d075..6e44da2a86 100644 --- a/src/jetstream/plugins/kubernetes/go.mod +++ b/src/jetstream/plugins/kubernetes/go.mod @@ -9,7 +9,6 @@ require ( 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/eknkc/amber v0.0.0-20171010120322-cdade1c07385 // 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 diff --git a/src/jetstream/plugins/kubernetes/go.sum b/src/jetstream/plugins/kubernetes/go.sum index d3b2e14ea0..3159ac83df 100644 --- a/src/jetstream/plugins/kubernetes/go.sum +++ b/src/jetstream/plugins/kubernetes/go.sum @@ -112,6 +112,7 @@ github.com/deislabs/oras v0.7.0/go.mod h1:sqMKPG3tMyIX9xwXUBRLhZ24o+uT4y6jgBD2Rz 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= @@ -254,6 +255,7 @@ 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= @@ -292,6 +294,8 @@ github.com/gorilla/handlers v1.4.0 h1:XulKRWSQK5uChr4pEgSE4Tc/OcmnU9GJuSwdog/tZs 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= @@ -369,6 +373,7 @@ 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= @@ -377,6 +382,7 @@ github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8 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= @@ -392,9 +398,12 @@ 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= @@ -495,8 +504,8 @@ github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNue 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/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= @@ -537,12 +546,16 @@ github.com/technosophos/moniker v0.0.0-20180509230615-a5dbd03a2245/go.mod h1:O1c 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= @@ -559,6 +572,7 @@ github.com/yvasiyarov/gorelic v0.0.6 h1:qMJQYPNdtJ7UNYHjX38KXZtltKTqimMuoQjNnSVI 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= @@ -574,12 +588,15 @@ golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7 h1:0hQKqeLdqlt5iIwVOBErRi 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= @@ -624,6 +641,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ 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= @@ -641,6 +660,7 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w 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= @@ -787,6 +807,7 @@ k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8 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= diff --git a/src/jetstream/plugins/kubernetes/helm/graph.go b/src/jetstream/plugins/kubernetes/helm/graph.go index cea0f9c5dd..a46bf3ac0a 100644 --- a/src/jetstream/plugins/kubernetes/helm/graph.go +++ b/src/jetstream/plugins/kubernetes/helm/graph.go @@ -27,8 +27,12 @@ type ReleaseNode struct { ID string `json:"id"` Label string `json:"label"` Data struct { - Kind string `json:"kind"` - Status NodeStatus `json:"status"` + 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"` } @@ -67,6 +71,8 @@ func (r *HelmReleaseGraph) ParseManifest(release *HelmRelease) { } 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) { @@ -114,6 +120,9 @@ func (r *HelmReleaseGraph) ParseManifest(release *HelmRelease) { 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)) } @@ -138,20 +147,15 @@ func (r *HelmReleaseGraph) generateTemporaryNode(id string) { node := ReleaseNode{ ID: id, - Label: parts[1], + 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 { - // // TODO: FIX - empty kind is a pod - // if len(kind) == 0 { - // kind = "Pod" - // } return fmt.Sprintf("%s-%s", kind, name) } @@ -180,6 +184,13 @@ func (r *HelmReleaseGraph) ProcessPod(id string, res KubeResource, spec v1.PodSp } } + // 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 { @@ -256,3 +267,14 @@ func (r *HelmReleaseGraph) ParseRoleBinding(id string, roleBinding *rbacv1.RoleB 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/release.go b/src/jetstream/plugins/kubernetes/helm/release.go index f8fd9c3131..868200bb89 100644 --- a/src/jetstream/plugins/kubernetes/helm/release.go +++ b/src/jetstream/plugins/kubernetes/helm/release.go @@ -9,6 +9,7 @@ import ( "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" @@ -18,15 +19,16 @@ import ( v1 "k8s.io/api/core/v1" extv1beta1 "k8s.io/api/extensions/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" - - "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "sigs.k8s.io/yaml" ) var resourcesWithoutStatus = map[string]bool{ - "RoleBinding": false, - "Role": false, + "RoleBinding": false, + "Role": false, + "ClusterRole": false, + "ClusterRoleBinding": false, + "PodSecurityPolicy": false, } // HelmRelease represents a Helm Release deployed via Helm @@ -45,7 +47,8 @@ type KubeResource struct { Kind string `yaml:"kind" json:"kind"` APIVersion string `yaml:"apiVersion" json:"apiVersion"` Metadata struct { - Name string `yaml:"name" json:"name"` + Name string `yaml:"name" json:"name"` + Namespace string `yaml:"namespace" json:"namespace"` } `yaml:"metadata" json:"metadata"` Resource interface{} `yaml:"resource"` Manifest bool @@ -56,7 +59,7 @@ func (r *KubeResource) getID() string { } // NewHelmRelease represents extended info about a Helm Release -func NewHelmRelease(info *release.Release, endpoint, user string) *HelmRelease { +func NewHelmRelease(info *release.Release, endpoint, user string, jetstream interfaces.PortalProxy) *HelmRelease { r := &HelmRelease{ Release: info, Endpoint: endpoint, @@ -76,16 +79,26 @@ func (r *HelmRelease) parseManifest() { var bufr strings.Builder for { line, err := buffer.ReadString('\n') - if err != nil || (err == nil && strings.TrimSpace(line) == "---") { + 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([]byte(bufr.String()), nil, nil) + obj, _, err := decode(data, nil, nil) if err != nil { - log.Error(fmt.Sprintf("Helm Manifest Parser: Error while decoding YAML object. Err was: %s", err)) - r.ManifestErrors = true + // 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.processResource(obj) + r.processJsonResource(obj) } bufr.Reset() @@ -96,7 +109,11 @@ func (r *HelmRelease) parseManifest() { if err != nil { break } - bufr.WriteString(line) + + // Ignore comments + if !strings.HasPrefix(strings.TrimSpace(line), "#") && !strings.HasPrefix(strings.TrimRight(line, "\t \n"), "---") { + bufr.WriteString(line) + } } } @@ -131,32 +148,61 @@ func (r *HelmRelease) GetPods() []interface{} { return resources } -// process a yaml resource from the helm manifest -func (r *HelmRelease) processResource(obj runtime.Object) { - j, err := json.Marshal(obj) +func (r *HelmRelease) processJsonResource(obj interface{}) { + data, err := json.Marshal(obj) if err == nil { var t KubeResource - if json.Unmarshal(j, &t) == nil { - 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(t.Kind, t.APIVersion, t.Metadata.Name) + 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") } } -func (r *HelmRelease) addJobForResource(kind, apiVersion, name string) { +// 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(t.Metadata.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: r.Namespace, Name: name, - URL: getRestURL(r.Namespace, kind, apiVersion, name), + URL: getRestURL(namespace, kind, apiVersion, name), APIVersion: apiVersion, Kind: kind, } @@ -235,6 +281,8 @@ func (r *HelmRelease) UpdatePods(jetstream interfaces.PortalProxy) { podCopy := &v1.Pod{} *podCopy = pod + podCopy.Kind = "Pod" + podCopy.APIVersion = "v1" res.Resource = podCopy pods[res.getID()] = &res @@ -268,7 +316,7 @@ func (r *HelmRelease) processPodOwners(pod v1.Pod) { } resource.ObjectMeta = metav1.ObjectMeta{ Name: owner.Name, - Namespace: r.Namespace, + Namespace: pod.Namespace, } identifier := getResourceIdentifier(resource.TypeMeta, resource.ObjectMeta) if _, ok := r.Resources[identifier]; !ok { @@ -282,7 +330,7 @@ func (r *HelmRelease) processPodOwners(pod v1.Pod) { res.Resource = &resource r.setResource(res) - r.addJobForResource(owner.Kind, owner.APIVersion, owner.Name) + r.addJobForResource(pod.Namespace, owner.Kind, owner.APIVersion, owner.Name) } } else { log.Debugf("Unexpected Pod owner kind: %s", owner.Kind) @@ -290,15 +338,6 @@ func (r *HelmRelease) processPodOwners(pod v1.Pod) { } } -// func (r *HelmRelease) getKubeResource(typeMeta metav1.TypeMeta, objectMeta metav1.ObjectMeta) KubeResource { -// kres := KubeResource{ -// Kind: typeMeta.Kind, -// APIVersion: typeMeta.APIVersion, -// } -// kres.Metadata.Name = objectMeta.Name -// return kres -// } - func (r *HelmRelease) UpdateResources(jetstream interfaces.PortalProxy) { // This will be an array of resources runner := NewKubeAPIJob(jetstream, r.Jobs) @@ -312,7 +351,7 @@ func (r *HelmRelease) UpdateResources(jetstream interfaces.PortalProxy) { } res.Metadata.Name = j.Name - // TODO: If the status was 404, then we should remove the resource + // 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) @@ -332,7 +371,14 @@ func (r *HelmRelease) UpdateResources(jetstream interfaces.PortalProxy) { res.Resource = obj r.setResource(res) } else { - log.Error("Could not parse resource") + // 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 @@ -351,6 +397,22 @@ func getRestURL(namespace, kind, apiVersion, name string) string { name += "/status" } } - restURL = fmt.Sprintf("/%s/%s/namespaces/%s/%ss/%s", base, apiVersion, namespace, strings.ToLower(kind), name) + + 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) +}