From 6ccabb2b886990ca2da82f9bd8c76dd9c34a8b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Busso?= Date: Fri, 27 Aug 2021 09:25:49 +1200 Subject: [PATCH 01/18] Bump to 2.6.3 --- api/http/handler/handler.go | 2 +- api/portainer.go | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 263aac2ca..e1a80581c 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -69,7 +69,7 @@ type Handler struct { } // @title PortainerCE API -// @version 2.1.1 +// @version 2.6.3 // @description.markdown api-description.md // @termsOfService diff --git a/api/portainer.go b/api/portainer.go index ecf845610..aa737fe21 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1410,7 +1410,7 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "2.6.2" + APIVersion = "2.6.3" // DBVersion is the version number of the Portainer database DBVersion = 32 // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax diff --git a/package.json b/package.json index dc3c4abeb..c594562fc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Portainer.io", "name": "portainer", "homepage": "http://portainer.io", - "version": "2.6.0", + "version": "2.6.3", "repository": { "type": "git", "url": "git@github.com:portainer/portainer.git" From 78c453095626c7d2dde661ecc163f91614a9aebf Mon Sep 17 00:00:00 2001 From: Dmitry Salakhov Date: Mon, 30 Aug 2021 17:14:44 +1200 Subject: [PATCH 02/18] fix(stacks): allow root based compose file paths (#5506) --- api/exec/compose_stack.go | 30 +++++++++++++++++++++++++++--- api/exec/compose_stack_test.go | 18 ++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index 36283c6d9..465862047 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path" + "path/filepath" "regexp" "strings" @@ -56,7 +57,7 @@ func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.End return errors.Wrap(err, "failed to create env file") } - filePaths := append([]string{stack.EntryPoint}, stack.AdditionalFiles...) + filePaths := getStackFiles(stack) _, err = w.wrapper.Up(filePaths, stack.ProjectPath, url, stack.Name, envFilePath, w.configPath) return errors.Wrap(err, "failed to deploy a stack") } @@ -71,8 +72,7 @@ func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.E defer proxy.Close() } - filePaths := append([]string{stack.EntryPoint}, stack.AdditionalFiles...) - + filePaths := getStackFiles(stack) _, err = w.wrapper.Down(filePaths, stack.ProjectPath, url, stack.Name) return err } @@ -115,3 +115,27 @@ func createEnvFile(stack *portainer.Stack) (string, error) { return "stack.env", nil } + +// getStackFiles returns list of stack's confile file paths. +// items in the list would be sanitized according to following criterias: +// 1. no empty paths +// 2. no "../xxx" paths that are trying to escape stack folder +// 3. no dir paths +// 4. root paths would be made relative +func getStackFiles(stack *portainer.Stack) []string { + paths := make([]string, 0, len(stack.AdditionalFiles)+1) + + for _, p := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { + if strings.HasPrefix(p, "/") { + p = `.` + p + } + + if p == `` || p == `.` || strings.HasPrefix(p, `..`) || strings.HasSuffix(p, string(filepath.Separator)) { + continue + } + + paths = append(paths, p) + } + + return paths +} diff --git a/api/exec/compose_stack_test.go b/api/exec/compose_stack_test.go index c61285ebd..0b5dec2a3 100644 --- a/api/exec/compose_stack_test.go +++ b/api/exec/compose_stack_test.go @@ -64,3 +64,21 @@ func Test_createEnvFile(t *testing.T) { }) } } + +func Test_getStackFiles(t *testing.T) { + stack := &portainer.Stack{ + EntryPoint: "./file", // picks entry point + AdditionalFiles: []string{ + ``, // ignores empty string + `.`, // ignores . + `..`, // ignores .. + `./dir/`, // ignrores paths that end with trailing / + `/with-root-prefix`, // replaces "root" based paths with relative + `./relative`, // keeps relative paths + `../escape`, // prevents dir escape + }, + } + + filePaths := getStackFiles(stack) + assert.ElementsMatch(t, filePaths, []string{`./file`, `./with-root-prefix`, `./relative`}) +} From c39c7010be3b6ebff1754f299c101ea6a5ac7783 Mon Sep 17 00:00:00 2001 From: Dmitry Salakhov Date: Mon, 30 Aug 2021 19:06:35 +1200 Subject: [PATCH 03/18] Revert "fix(stacks): allow root based compose file paths (#5506)" (#5540) This reverts commit 78c453095626c7d2dde661ecc163f91614a9aebf. --- api/exec/compose_stack.go | 30 +++--------------------------- api/exec/compose_stack_test.go | 18 ------------------ 2 files changed, 3 insertions(+), 45 deletions(-) diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index 465862047..36283c6d9 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path" - "path/filepath" "regexp" "strings" @@ -57,7 +56,7 @@ func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.End return errors.Wrap(err, "failed to create env file") } - filePaths := getStackFiles(stack) + filePaths := append([]string{stack.EntryPoint}, stack.AdditionalFiles...) _, err = w.wrapper.Up(filePaths, stack.ProjectPath, url, stack.Name, envFilePath, w.configPath) return errors.Wrap(err, "failed to deploy a stack") } @@ -72,7 +71,8 @@ func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.E defer proxy.Close() } - filePaths := getStackFiles(stack) + filePaths := append([]string{stack.EntryPoint}, stack.AdditionalFiles...) + _, err = w.wrapper.Down(filePaths, stack.ProjectPath, url, stack.Name) return err } @@ -115,27 +115,3 @@ func createEnvFile(stack *portainer.Stack) (string, error) { return "stack.env", nil } - -// getStackFiles returns list of stack's confile file paths. -// items in the list would be sanitized according to following criterias: -// 1. no empty paths -// 2. no "../xxx" paths that are trying to escape stack folder -// 3. no dir paths -// 4. root paths would be made relative -func getStackFiles(stack *portainer.Stack) []string { - paths := make([]string, 0, len(stack.AdditionalFiles)+1) - - for _, p := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { - if strings.HasPrefix(p, "/") { - p = `.` + p - } - - if p == `` || p == `.` || strings.HasPrefix(p, `..`) || strings.HasSuffix(p, string(filepath.Separator)) { - continue - } - - paths = append(paths, p) - } - - return paths -} diff --git a/api/exec/compose_stack_test.go b/api/exec/compose_stack_test.go index 0b5dec2a3..c61285ebd 100644 --- a/api/exec/compose_stack_test.go +++ b/api/exec/compose_stack_test.go @@ -64,21 +64,3 @@ func Test_createEnvFile(t *testing.T) { }) } } - -func Test_getStackFiles(t *testing.T) { - stack := &portainer.Stack{ - EntryPoint: "./file", // picks entry point - AdditionalFiles: []string{ - ``, // ignores empty string - `.`, // ignores . - `..`, // ignores .. - `./dir/`, // ignrores paths that end with trailing / - `/with-root-prefix`, // replaces "root" based paths with relative - `./relative`, // keeps relative paths - `../escape`, // prevents dir escape - }, - } - - filePaths := getStackFiles(stack) - assert.ElementsMatch(t, filePaths, []string{`./file`, `./with-root-prefix`, `./relative`}) -} From e8a6f1521060cb8a2034951d84c09eadb4c8f4ec Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 31 Aug 2021 10:04:31 +1200 Subject: [PATCH 04/18] chore(build-system): update dev-toolkit (#4887) (#5543) * chore(build-system): update dev-toolkit * chore(build-system): update dev-toolkit * chore(build-system): update dev-toolkit Dockerfile * chore(build-system): update gruntfile * chore(build-system): gruntfile update * chore(build-system): better support for private git repositories * Update toolkit.Dockerfile * merge develop into toolkit-update * merge develop into toolkit-update --- build/linux/dev-toolkit/run.sh | 99 +++++++++++++++++++ .../{ => dev-toolkit}/toolkit.Dockerfile | 41 +++++--- gruntfile.js | 3 +- 3 files changed, 130 insertions(+), 13 deletions(-) create mode 100755 build/linux/dev-toolkit/run.sh rename build/linux/{ => dev-toolkit}/toolkit.Dockerfile (52%) diff --git a/build/linux/dev-toolkit/run.sh b/build/linux/dev-toolkit/run.sh new file mode 100755 index 000000000..d856cb7c1 --- /dev/null +++ b/build/linux/dev-toolkit/run.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +# Script used to init the Portainer development environment inside the dev-toolkit image + +### COLOR OUTPUT ### + +ESeq="\x1b[" +RCol="$ESeq"'0m' # Text Reset + +# Regular Bold Underline High Intensity BoldHigh Intens Background High Intensity Backgrounds +Bla="$ESeq"'0;30m'; BBla="$ESeq"'1;30m'; UBla="$ESeq"'4;30m'; IBla="$ESeq"'0;90m'; BIBla="$ESeq"'1;90m'; On_Bla="$ESeq"'40m'; On_IBla="$ESeq"'0;100m'; +Red="$ESeq"'0;31m'; BRed="$ESeq"'1;31m'; URed="$ESeq"'4;31m'; IRed="$ESeq"'0;91m'; BIRed="$ESeq"'1;91m'; On_Red="$ESeq"'41m'; On_IRed="$ESeq"'0;101m'; +Gre="$ESeq"'0;32m'; BGre="$ESeq"'1;32m'; UGre="$ESeq"'4;32m'; IGre="$ESeq"'0;92m'; BIGre="$ESeq"'1;92m'; On_Gre="$ESeq"'42m'; On_IGre="$ESeq"'0;102m'; +Yel="$ESeq"'0;33m'; BYel="$ESeq"'1;33m'; UYel="$ESeq"'4;33m'; IYel="$ESeq"'0;93m'; BIYel="$ESeq"'1;93m'; On_Yel="$ESeq"'43m'; On_IYel="$ESeq"'0;103m'; +Blu="$ESeq"'0;34m'; BBlu="$ESeq"'1;34m'; UBlu="$ESeq"'4;34m'; IBlu="$ESeq"'0;94m'; BIBlu="$ESeq"'1;94m'; On_Blu="$ESeq"'44m'; On_IBlu="$ESeq"'0;104m'; +Pur="$ESeq"'0;35m'; BPur="$ESeq"'1;35m'; UPur="$ESeq"'4;35m'; IPur="$ESeq"'0;95m'; BIPur="$ESeq"'1;95m'; On_Pur="$ESeq"'45m'; On_IPur="$ESeq"'0;105m'; +Cya="$ESeq"'0;36m'; BCya="$ESeq"'1;36m'; UCya="$ESeq"'4;36m'; ICya="$ESeq"'0;96m'; BICya="$ESeq"'1;96m'; On_Cya="$ESeq"'46m'; On_ICya="$ESeq"'0;106m'; +Whi="$ESeq"'0;37m'; BWhi="$ESeq"'1;37m'; UWhi="$ESeq"'4;37m'; IWhi="$ESeq"'0;97m'; BIWhi="$ESeq"'1;97m'; On_Whi="$ESeq"'47m'; On_IWhi="$ESeq"'0;107m'; + +printSection() { + echo -e "${BIYel}>>>> ${BIWhi}${1}${RCol}" +} + +info() { + echo -e "${BIWhi}${1}${RCol}" +} + +success() { + echo -e "${BIGre}${1}${RCol}" +} + +error() { + echo -e "${BIRed}${1}${RCol}" +} + +errorAndExit() { + echo -e "${BIRed}${1}${RCol}" + exit 1 +} + +### !COLOR OUTPUT ### + +SETUP_FILE=/setup-done + +display_configuration() { + info "Portainer dev-toolkit container configuration" + info "Go version" + /usr/local/go/bin/go version + info "Node version" + node -v + info "Yarn version" + yarn -v + info "Docker version" + docker version +} + +main() { + [[ -z $PUSER ]] && errorAndExit "Unable to find PUSER environment variable. Please ensure PUSER is set before running this script." + [[ -z $PUID ]] && errorAndExit "Unable to find PUID environment variable. Please ensure PUID is set before running this script." + [[ -z $PGID ]] && errorAndExit "Unable to find PGID environment variable. Please ensure PGID is set before running this script." + [[ -z $DOCKERGID ]] && errorAndExit "Unable to find DOCKERGID environment variable. Please ensure DOCKERGID is set before running this script." + + if [[ -f "${SETUP_FILE}" ]]; then + info "Portainer dev-toolkit container already configured." + display_configuration + else + info "Creating user group..." + groupadd -g $PGID $PUSER + + info "Creating user..." + useradd -l -u $PUID -g $PUSER $PUSER + + info "Setting up home..." + install -d -m 0755 -o $PUSER -g $PUSER /home/$PUSER + + info "Configuring Docker..." + groupadd -g $DOCKERGID docker + usermod -aG docker $PUSER + + info "Configuring Go..." + echo "PATH=\"$PATH:/usr/local/go/bin\"" > /etc/environment + + info "Configuring Git..." + su $PUSER -c "git config --global url.git@github.com:.insteadOf https://github.com/" + + info "Configuring SSH..." + mkdir /home/$PUSER/.ssh + cp /host-ssh/* /home/$PUSER/.ssh/ + chown -R $PUSER:$PUSER /home/$PUSER/.ssh + + touch "${SETUP_FILE}" + success "Portainer dev-toolkit container successfully configured." + + display_configuration + fi +} + +main +su $PUSER -s "$@" \ No newline at end of file diff --git a/build/linux/toolkit.Dockerfile b/build/linux/dev-toolkit/toolkit.Dockerfile similarity index 52% rename from build/linux/toolkit.Dockerfile rename to build/linux/dev-toolkit/toolkit.Dockerfile index dc3f901a2..32470cc3b 100644 --- a/build/linux/toolkit.Dockerfile +++ b/build/linux/dev-toolkit/toolkit.Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu +FROM ubuntu:20.04 # Expose port for the Portainer UI and Edge server EXPOSE 9000 @@ -14,13 +14,30 @@ ARG GO_VERSION=go1.16.6.linux-amd64 # Install packages RUN apt-get update --fix-missing && apt-get install -qq \ - dialog \ - apt-utils \ - curl \ - build-essential \ - nodejs \ - git \ - wget + dialog \ + apt-utils \ + curl \ + build-essential \ + git \ + wget \ + apt-transport-https \ + ca-certificates \ + gnupg-agent \ + software-properties-common + +# Install Docker CLI +RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add - \ + && add-apt-repository \ + "deb [arch=amd64] https://download.docker.com/linux/ubuntu \ + $(lsb_release -cs) \ + stable" \ + && apt-get update \ + && apt-get install -y docker-ce-cli + + +# Install NodeJS +RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - \ + && apt-get install -y nodejs # Install Yarn RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ @@ -33,8 +50,8 @@ RUN cd /tmp \ && tar -xf ${GO_VERSION}.tar.gz \ && mv go /usr/local -# Configure Go -ENV PATH "$PATH:/usr/local/go/bin" +# Copy run script +COPY run.sh / +RUN chmod +x /run.sh -# Confirm installation -RUN go version && node -v && yarn -v +ENTRYPOINT ["/run.sh"] diff --git a/gruntfile.js b/gruntfile.js index f9940229d..6dbbc1c00 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -7,6 +7,7 @@ var arch = os.arch(); if (arch === 'x64') arch = 'amd64'; var portainer_data = '${PORTAINER_DATA:-/tmp/portainer}'; +var portainer_root = process.env.PORTAINER_PROJECT ? process.env.PORTAINER_PROJECT : process.env.PWD; module.exports = function (grunt) { loadGruntTasks(grunt, { @@ -174,7 +175,7 @@ function shell_build_binary_azuredevops(p, a) { function shell_run_container() { return [ 'docker rm -f portainer', - 'docker run -d -p 8000:8000 -p 9000:9000 -v $(pwd)/dist:/app -v ' + + 'docker run -d -p 8000:8000 -p 9000:9000 -v ' + portainer_root + '/dist:/app -v ' + portainer_data + ':/data -v /var/run/docker.sock:/var/run/docker.sock:z -v /var/run/docker.sock:/var/run/alternative.sock:z -v /tmp:/tmp --name portainer portainer/base /app/portainer', ].join(';'); From b4f4ef701aaac8b19e120309966b788da1423078 Mon Sep 17 00:00:00 2001 From: zees-dev <63374656+zees-dev@users.noreply.github.com> Date: Tue, 31 Aug 2021 10:07:50 +1200 Subject: [PATCH 05/18] feat(kubeconfig): kubeconfig download functionality EE-1202 (#5386) * backend migration/backport * Feat(kubeconfig): kubeconfig download button frontend EE-1202 (#5385) * kubeconfig download button frontend * fix kubeconfig download button * backend migration/backport * moved ng-if up one level Co-authored-by: zees-dev * resolved conflicts, updated code * - kube-config -> kube-config-download-button - fixed kubeconfig file name (bug) Co-authored-by: Richard Wei <54336863+WaysonWei@users.noreply.github.com> --- .../handler/endpointproxy/proxy_kubernetes.go | 2 +- api/portainer.go | 2 +- .../kube-config-download-button.controller.js | 15 +++++++++++++++ .../kube-config-download-button.html | 11 +++++++++++ .../kube-config-download-button.js | 7 +++++++ .../kubectl-shell/kubectl-shell.controller.js | 10 ++-------- .../components/kubectl-shell/kubectl-shell.html | 8 +++----- app/kubernetes/services/kubeconfigService.js | 1 - 8 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 app/kubernetes/components/kube-config-download-button/kube-config-download-button.controller.js create mode 100644 app/kubernetes/components/kube-config-download-button/kube-config-download-button.html create mode 100644 app/kubernetes/components/kube-config-download-button/kube-config-download-button.js diff --git a/api/http/handler/endpointproxy/proxy_kubernetes.go b/api/http/handler/endpointproxy/proxy_kubernetes.go index 88a369486..82d922ca1 100644 --- a/api/http/handler/endpointproxy/proxy_kubernetes.go +++ b/api/http/handler/endpointproxy/proxy_kubernetes.go @@ -79,4 +79,4 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r) return nil -} \ No newline at end of file +} diff --git a/api/portainer.go b/api/portainer.go index aa737fe21..2bc64277f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1214,8 +1214,8 @@ type ( // KubeClient represents a service used to query a Kubernetes environment KubeClient interface { - GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error) SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error + GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error) GetServiceAccountBearerToken(userID int) (string, error) CreateUserShellPod(ctx context.Context, serviceAccountName string) (*KubernetesShellPod, error) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error diff --git a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.controller.js b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.controller.js new file mode 100644 index 000000000..643dcefa0 --- /dev/null +++ b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.controller.js @@ -0,0 +1,15 @@ +export default class KubeConfigController { + /* @ngInject */ + constructor($window, KubernetesConfigService) { + this.$window = $window; + this.KubernetesConfigService = KubernetesConfigService; + } + + async downloadKubeconfig() { + await this.KubernetesConfigService.downloadConfig(); + } + + $onInit() { + this.state = { isHTTPS: this.$window.location.protocol === 'https:' }; + } +} diff --git a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.html b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.html new file mode 100644 index 000000000..b2bae2cdf --- /dev/null +++ b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.html @@ -0,0 +1,11 @@ + diff --git a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.js b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.js new file mode 100644 index 000000000..1732e50f2 --- /dev/null +++ b/app/kubernetes/components/kube-config-download-button/kube-config-download-button.js @@ -0,0 +1,7 @@ +import angular from 'angular'; +import controller from './kube-config-download-button.controller'; + +angular.module('portainer.kubernetes').component('kubeConfigDownloadButton', { + templateUrl: './kube-config-download-button.html', + controller, +}); diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js b/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js index 6fa2a5f58..d60ae0b36 100644 --- a/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js +++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js @@ -3,13 +3,12 @@ import * as fit from 'xterm/lib/addons/fit/fit'; export default class KubectlShellController { /* @ngInject */ - constructor(TerminalWindow, $window, $async, EndpointProvider, LocalStorage, KubernetesConfigService, Notifications) { + constructor(TerminalWindow, $window, $async, EndpointProvider, LocalStorage, Notifications) { this.$async = $async; this.$window = $window; this.TerminalWindow = TerminalWindow; this.EndpointProvider = EndpointProvider; this.LocalStorage = LocalStorage; - this.KubernetesConfigService = KubernetesConfigService; this.Notifications = Notifications; } @@ -83,7 +82,7 @@ export default class KubectlShellController { endpointId: this.EndpointProvider.endpointID(), }; - const wsProtocol = this.state.isHTTPS ? 'wss://' : 'ws://'; + const wsProtocol = this.$window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const path = '/api/websocket/kubernetes-shell'; const queryParams = Object.entries(params) .map(([k, v]) => `${k}=${v}`) @@ -97,17 +96,12 @@ export default class KubectlShellController { this.configureSocketAndTerminal(this.state.shell.socket, this.state.shell.term); } - async downloadKubeconfig() { - await this.KubernetesConfigService.downloadConfig(); - } - $onInit() { return this.$async(async () => { this.state = { css: 'normal', checked: false, icon: 'fa-window-minimize', - isHTTPS: this.$window.location.protocol === 'https:', shell: { connected: false, socket: null, diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.html b/app/kubernetes/components/kubectl-shell/kubectl-shell.html index a88209326..76dae9afe 100644 --- a/app/kubernetes/components/kubectl-shell/kubectl-shell.html +++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.html @@ -1,14 +1,12 @@ + +
kubectl shell
-
Date: Tue, 31 Aug 2021 12:39:19 +1200 Subject: [PATCH 06/18] Fix/release commits cherrypick (#5546) * fix EE-1078 Too strict form validation for docker environment variables (#5278) Co-authored-by: Simon Meng * fix(ingress): EE-1049 Ingress config is lost when deleting an application deployed with ingress (#5264) Co-authored-by: Simon Meng * feat(app/k8s): update ingress scheme from v1beta1 to v1 (#5466) Co-authored-by: cong meng Co-authored-by: Simon Meng Co-authored-by: LP B --- app/kubernetes/ingress/converter.js | 10 +++++----- app/kubernetes/ingress/payloads.js | 13 +++++++++---- app/kubernetes/ingress/rest.js | 2 +- ...ronment-variables-simple-mode-item.controller.js | 8 -------- .../environment-variables-simple-mode-item.html | 2 -- app/portainer/helpers/env-vars.js | 7 +++---- 6 files changed, 18 insertions(+), 24 deletions(-) diff --git a/app/kubernetes/ingress/converter.js b/app/kubernetes/ingress/converter.js index 8f7097f99..e3ac6112d 100644 --- a/app/kubernetes/ingress/converter.js +++ b/app/kubernetes/ingress/converter.js @@ -19,10 +19,10 @@ export class KubernetesIngressConverter { : _.map(rule.http.paths, (path) => { const ingRule = new KubernetesIngressRule(); ingRule.IngressName = data.metadata.name; - ingRule.ServiceName = path.backend.serviceName; + ingRule.ServiceName = path.backend.service.name; ingRule.Host = rule.host || ''; ingRule.IP = data.status.loadBalancer.ingress ? data.status.loadBalancer.ingress[0].ip : undefined; - ingRule.Port = path.backend.servicePort; + ingRule.Port = path.backend.service.port.number; ingRule.Path = path.path; return ingRule; }); @@ -151,8 +151,8 @@ export class KubernetesIngressConverter { rule.http.paths = _.map(paths, (p) => { const path = new KubernetesIngressRulePathCreatePayload(); path.path = p.Path; - path.backend.serviceName = p.ServiceName; - path.backend.servicePort = p.Port; + path.backend.service.name = p.ServiceName; + path.backend.service.port.number = p.Port; return path; }); hostsWithRules.push(host); @@ -173,7 +173,7 @@ export class KubernetesIngressConverter { res.spec.rules = []; _.forEach(data.Hosts, (host) => { if (!host.NeedsDeletion) { - res.spec.rules.push({ host: host.Host }); + res.spec.rules.push({ host: host.Host || host }); } }); } else { diff --git a/app/kubernetes/ingress/payloads.js b/app/kubernetes/ingress/payloads.js index d1c6279ec..f5411b7c0 100644 --- a/app/kubernetes/ingress/payloads.js +++ b/app/kubernetes/ingress/payloads.js @@ -20,10 +20,15 @@ export function KubernetesIngressRuleCreatePayload() { export function KubernetesIngressRulePathCreatePayload() { return { - backend: { - serviceName: '', - servicePort: 0, - }, path: '', + pathType: 'ImplementationSpecific', + backend: { + service: { + name: '', + port: { + number: 0, + }, + }, + }, }; } diff --git a/app/kubernetes/ingress/rest.js b/app/kubernetes/ingress/rest.js index df335653b..3bdeaf10f 100644 --- a/app/kubernetes/ingress/rest.js +++ b/app/kubernetes/ingress/rest.js @@ -5,7 +5,7 @@ angular.module('portainer.kubernetes').factory('KubernetesIngresses', factory); function factory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return function (namespace) { - const url = `${API_ENDPOINT_ENDPOINTS}/:endpointId/kubernetes/apis/networking.k8s.io/v1beta1${namespace ? '/namespaces/:namespace' : ''}/ingresses/:id/:action`; + const url = `${API_ENDPOINT_ENDPOINTS}/:endpointId/kubernetes/apis/networking.k8s.io/v1${namespace ? '/namespaces/:namespace' : ''}/ingresses/:id/:action`; return $resource( url, { diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js index 2138e4f57..30d61e8d3 100644 --- a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js @@ -1,12 +1,4 @@ -import { KEY_REGEX, VALUE_REGEX } from '@/portainer/helpers/env-vars'; - class EnvironmentVariablesSimpleModeItemController { - /* @ngInject */ - constructor() { - this.KEY_REGEX = KEY_REGEX; - this.VALUE_REGEX = VALUE_REGEX; - } - onChangeName(name) { const fieldIsInvalid = typeof name === 'undefined'; if (fieldIsInvalid) { diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html index c53af0699..27ace7bc2 100644 --- a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html @@ -9,7 +9,6 @@ placeholder="e.g. FOO" ng-model="$ctrl.variable.name" ng-disabled="$ctrl.variable.added" - ng-pattern="$ctrl.KEY_REGEX" ng-change="$ctrl.onChangeName($ctrl.variable.name)" required /> @@ -36,7 +35,6 @@ ng-model="$ctrl.variable.value" placeholder="e.g. bar" ng-trim="false" - ng-pattern="$ctrl.VALUE_REGEX" name="value" ng-change="$ctrl.onChangeValue($ctrl.variable.value)" /> diff --git a/app/portainer/helpers/env-vars.js b/app/portainer/helpers/env-vars.js index 972fc5ebd..c55c448ea 100644 --- a/app/portainer/helpers/env-vars.js +++ b/app/portainer/helpers/env-vars.js @@ -1,7 +1,6 @@ import _ from 'lodash-es'; -export const KEY_REGEX = /[a-zA-Z]([-_a-zA-Z0-9]*[a-zA-Z0-9])?/.source; - +export const KEY_REGEX = /(.+)/.source; export const VALUE_REGEX = /(.*)?/.source; const KEY_VALUE_REGEX = new RegExp(`^(${KEY_REGEX})\\s*=(${VALUE_REGEX})$`); @@ -16,7 +15,7 @@ export function parseDotEnvFile(src) { return parseArrayOfStrings( _.compact(src.split(NEWLINES_REGEX)) .map((v) => v.trim()) - .filter((v) => !v.startsWith('#')) + .filter((v) => !v.startsWith('#') && v !== '') ); } @@ -40,7 +39,7 @@ export function parseArrayOfStrings(array) { const parsedKeyValArr = variableString.trim().match(KEY_VALUE_REGEX); if (parsedKeyValArr != null && parsedKeyValArr.length > 4) { - return { name: parsedKeyValArr[1], value: parsedKeyValArr[3] || '' }; + return { name: parsedKeyValArr[1].trim(), value: parsedKeyValArr[3].trim() || '' }; } }) ); From ed500b51c68575c924a39338d71c1c8b41111c05 Mon Sep 17 00:00:00 2001 From: ArrisLee Date: Tue, 31 Aug 2021 16:21:23 +1200 Subject: [PATCH 07/18] error message updates for different file type --- api/http/handler/stacks/create_kubernetes_stack.go | 8 +++++++- api/http/handler/stacks/update_kubernetes_stack.go | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 83cb77f9d..04dcf3bbb 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -1,6 +1,7 @@ package stacks import ( + "fmt" "io/ioutil" "net/http" "path/filepath" @@ -97,7 +98,12 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit stackFolder := strconv.Itoa(int(stack.ID)) projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { - return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err} + fileType := "Manifest" + if stack.IsComposeFormat { + fileType = "Compose" + } + errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType) + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err} } stack.ProjectPath = projectPath diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go index bb17b2ad3..114552b68 100644 --- a/api/http/handler/stacks/update_kubernetes_stack.go +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -1,6 +1,7 @@ package stacks import ( + "fmt" "net/http" "strconv" @@ -84,7 +85,12 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer. stackFolder := strconv.Itoa(int(stack.ID)) _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { - return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err} + fileType := "Manifest" + if stack.IsComposeFormat { + fileType = "Compose" + } + errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType) + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err} } return nil From 5e898405f50050ac25d13143137b93356311ab4a Mon Sep 17 00:00:00 2001 From: Felix Han Date: Tue, 31 Aug 2021 17:08:17 +1200 Subject: [PATCH 08/18] not display creation source for external application --- app/kubernetes/views/applications/edit/application.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html index 1e9e725a1..0faeaba77 100644 --- a/app/kubernetes/views/applications/edit/application.html +++ b/app/kubernetes/views/applications/edit/application.html @@ -68,7 +68,9 @@ {{ ctrl.application.ApplicationOwner }} {{ ctrl.application.CreationDate | getisodate }} - Deployed from {{ ctrl.state.appType }} + + Deployed from {{ ctrl.state.appType }} From c597ae96e2e620f77a80ce4944da84cedb0e45ec Mon Sep 17 00:00:00 2001 From: cong meng Date: Wed, 1 Sep 2021 09:08:01 +1200 Subject: [PATCH 10/18] feat(k8s): review the resource assignement when creating a kubernetes application EE-437 (#5254) * feat(nodes limits)Review the resource assignement when creating a Kubernetes application EE-437 * feat(nodes limits) review feedback EE-437 * feat(nodes limits) workaround for lodash cloneDeep not working in production mode EE-437 * feat(nodes limits) calculate max cpu of slide bar with floor function instead of round function EE-437 * feat(nodes limits) another review feedback EE-437 * feat(nodes limits) cleanup code EE-437 * feat(nodes limits) EE-437 pr feedback update * feat(nodes limits) EE-437 rebase onto develop branch * feat(nodes limits) EE-437 another pr feedback update Co-authored-by: Simon Meng --- api/http/handler/kubernetes/handler.go | 2 + .../kubernetes/kubernetes_nodes_limits.go | 52 +++++++ api/kubernetes/cli/nodes_limits.go | 42 ++++++ api/kubernetes/cli/nodes_limits_test.go | 137 ++++++++++++++++++ api/portainer.go | 8 + app/kubernetes/models/nodes-limits/models.js | 65 +++++++++ app/kubernetes/rest/nodesLimits.js | 21 +++ app/kubernetes/services/nodesLimitsService.js | 25 ++++ .../create/createApplication.html | 7 + .../create/createApplicationController.js | 136 +++++++++++------ 10 files changed, 453 insertions(+), 42 deletions(-) create mode 100644 api/http/handler/kubernetes/kubernetes_nodes_limits.go create mode 100644 api/kubernetes/cli/nodes_limits.go create mode 100644 api/kubernetes/cli/nodes_limits_test.go create mode 100644 app/kubernetes/models/nodes-limits/models.js create mode 100644 app/kubernetes/rest/nodesLimits.js create mode 100644 app/kubernetes/services/nodesLimitsService.js diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index c317ed346..3efad11f4 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -39,6 +39,8 @@ func NewHandler(bouncer *security.RequestBouncer, authorizationService *authoriz kubeRouter.PathPrefix("/config").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesConfig))).Methods(http.MethodGet) + kubeRouter.PathPrefix("/nodes_limits").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesNodesLimits))).Methods(http.MethodGet) // namespaces // in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?) diff --git a/api/http/handler/kubernetes/kubernetes_nodes_limits.go b/api/http/handler/kubernetes/kubernetes_nodes_limits.go new file mode 100644 index 000000000..5e1f06215 --- /dev/null +++ b/api/http/handler/kubernetes/kubernetes_nodes_limits.go @@ -0,0 +1,52 @@ +package kubernetes + +import ( + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "net/http" +) + +// @id getKubernetesNodesLimits +// @summary Get CPU and memory limits of all nodes within k8s cluster +// @description Get CPU and memory limits of all nodes within k8s cluster +// @description **Access policy**: authorized +// @tags kubernetes +// @security jwt +// @accept json +// @produce json +// @param id path int true "Endpoint identifier" +// @success 200 {object} K8sNodesLimits "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Endpoint not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/nodes_limits [get] +func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + nodesLimits, err := cli.GetNodesLimits() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve nodes limits", err} + } + + return response.JSON(w, nodesLimits) +} diff --git a/api/kubernetes/cli/nodes_limits.go b/api/kubernetes/cli/nodes_limits.go new file mode 100644 index 000000000..9e0c044eb --- /dev/null +++ b/api/kubernetes/cli/nodes_limits.go @@ -0,0 +1,42 @@ +package cli + +import ( + portainer "github.com/portainer/portainer/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetNodesLimits gets the CPU and Memory limits(unused resources) of all nodes in the current k8s endpoint connection +func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) { + nodesLimits := make(portainer.K8sNodesLimits) + + nodes, err := kcl.cli.CoreV1().Nodes().List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + pods, err := kcl.cli.CoreV1().Pods("").List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, item := range nodes.Items { + cpu := item.Status.Allocatable.Cpu().MilliValue() + memory := item.Status.Allocatable.Memory().Value() + + nodesLimits[item.ObjectMeta.Name] = &portainer.K8sNodeLimits{ + CPU: cpu, + Memory: memory, + } + } + + for _, item := range pods.Items { + if nodeLimits, ok := nodesLimits[item.Spec.NodeName]; ok { + for _, container := range item.Spec.Containers { + nodeLimits.CPU -= container.Resources.Requests.Cpu().MilliValue() + nodeLimits.Memory -= container.Resources.Requests.Memory().Value() + } + } + } + + return nodesLimits, nil +} diff --git a/api/kubernetes/cli/nodes_limits_test.go b/api/kubernetes/cli/nodes_limits_test.go new file mode 100644 index 000000000..bf880c2ff --- /dev/null +++ b/api/kubernetes/cli/nodes_limits_test.go @@ -0,0 +1,137 @@ +package cli + +import ( + portainer "github.com/portainer/portainer/api" + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + kfake "k8s.io/client-go/kubernetes/fake" + "reflect" + "testing" +) + +func newNodes() *v1.NodeList { + return &v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-0", + }, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"), + v1.ResourceName(v1.ResourceMemory): resource.MustParse("4M"), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-1", + }, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceName(v1.ResourceCPU): resource.MustParse("3"), + v1.ResourceName(v1.ResourceMemory): resource.MustParse("6M"), + }, + }, + }, + }, + } +} + +func newPods() *v1.PodList { + return &v1.PodList{ + Items: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-container-0", + Namespace: "test-namespace-0", + }, + Spec: v1.PodSpec{ + NodeName: "test-node-0", + Containers: []v1.Container{ + { + Name: "test-container-0", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceName(v1.ResourceCPU): resource.MustParse("1"), + v1.ResourceName(v1.ResourceMemory): resource.MustParse("2M"), + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-container-1", + Namespace: "test-namespace-1", + }, + Spec: v1.PodSpec{ + NodeName: "test-node-1", + Containers: []v1.Container{ + { + Name: "test-container-1", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"), + v1.ResourceName(v1.ResourceMemory): resource.MustParse("3M"), + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestKubeClient_GetNodesLimits(t *testing.T) { + type fields struct { + cli kubernetes.Interface + } + + fieldsInstance := fields{ + cli: kfake.NewSimpleClientset(newNodes(), newPods()), + } + + tests := []struct { + name string + fields fields + want portainer.K8sNodesLimits + wantErr bool + }{ + { + name: "2 nodes 2 pods", + fields: fieldsInstance, + want: portainer.K8sNodesLimits{ + "test-node-0": &portainer.K8sNodeLimits{ + CPU: 1000, + Memory: 2000000, + }, + "test-node-1": &portainer.K8sNodeLimits{ + CPU: 1000, + Memory: 3000000, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kcl := &KubeClient{ + cli: tt.fields.cli, + } + got, err := kcl.GetNodesLimits() + if (err != nil) != tt.wantErr { + t.Errorf("GetNodesLimits() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetNodesLimits() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/api/portainer.go b/api/portainer.go index 2bc64277f..2d82edf3a 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -398,6 +398,13 @@ type ( // JobType represents a job type JobType int + K8sNodeLimits struct { + CPU int64 `json:"CPU"` + Memory int64 `json:"Memory"` + } + + K8sNodesLimits map[string]*K8sNodeLimits + K8sNamespaceAccessPolicy struct { UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` @@ -1220,6 +1227,7 @@ type ( CreateUserShellPod(ctx context.Context, serviceAccountName string) (*KubernetesShellPod, error) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error NamespaceAccessPoliciesDeleteNamespace(namespace string) error + GetNodesLimits() (K8sNodesLimits, error) GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error DeleteRegistrySecret(registry *Registry, namespace string) error diff --git a/app/kubernetes/models/nodes-limits/models.js b/app/kubernetes/models/nodes-limits/models.js new file mode 100644 index 000000000..b2f94254b --- /dev/null +++ b/app/kubernetes/models/nodes-limits/models.js @@ -0,0 +1,65 @@ +import _ from 'lodash-es'; + +/** + * NodesLimits Model + */ +export class KubernetesNodesLimits { + constructor(nodesLimits) { + this.MaxCPU = 0; + this.MaxMemory = 0; + this.nodesLimits = this.convertCPU(nodesLimits); + + this.calculateMaxCPUMemory(); + } + + convertCPU(nodesLimits) { + _.forEach(nodesLimits, (value) => { + if (value.CPU) { + value.CPU /= 1000.0; + } + }); + return nodesLimits; + } + + calculateMaxCPUMemory() { + const nodesLimitsArray = Object.values(this.nodesLimits); + this.MaxCPU = _.maxBy(nodesLimitsArray, 'CPU').CPU; + this.MaxMemory = _.maxBy(nodesLimitsArray, 'Memory').Memory; + } + + // check if there is enough cpu and memory to allocate containers in replica mode + overflowForReplica(cpu, memory, instances) { + _.forEach(this.nodesLimits, (value) => { + instances -= Math.min(Math.floor(value.CPU / cpu), Math.floor(value.Memory / memory)); + }); + + return instances > 0; + } + + // check if there is enough cpu and memory to allocate containers in global mode + overflowForGlobal(cpu, memory) { + let overflow = false; + + _.forEach(this.nodesLimits, (value) => { + if (cpu > value.CPU || memory > value.Memory) { + overflow = true; + } + }); + + return overflow; + } + + excludesPods(pods, cpuLimit, memoryLimit) { + const nodesLimits = this.nodesLimits; + + _.forEach(pods, (value) => { + const node = value.Node; + if (node && nodesLimits[node]) { + nodesLimits[node].CPU += cpuLimit; + nodesLimits[node].Memory += memoryLimit; + } + }); + + this.calculateMaxCPUMemory(); + } +} diff --git a/app/kubernetes/rest/nodesLimits.js b/app/kubernetes/rest/nodesLimits.js new file mode 100644 index 000000000..d8eb59d93 --- /dev/null +++ b/app/kubernetes/rest/nodesLimits.js @@ -0,0 +1,21 @@ +import angular from 'angular'; + +angular.module('portainer.kubernetes').factory('KubernetesNodesLimits', KubernetesNodesLimitsFactory); + +/* @ngInject */ +function KubernetesNodesLimitsFactory($resource, API_ENDPOINT_KUBERNETES, EndpointProvider) { + const url = API_ENDPOINT_KUBERNETES + '/:endpointId/nodes_limits'; + return $resource( + url, + { + endpointId: EndpointProvider.endpointID, + }, + { + get: { + method: 'GET', + ignoreLoadingBar: true, + transformResponse: (data) => ({ data: JSON.parse(data) }), + }, + } + ); +} diff --git a/app/kubernetes/services/nodesLimitsService.js b/app/kubernetes/services/nodesLimitsService.js new file mode 100644 index 000000000..14a4f3275 --- /dev/null +++ b/app/kubernetes/services/nodesLimitsService.js @@ -0,0 +1,25 @@ +import angular from 'angular'; +import PortainerError from 'Portainer/error'; +import { KubernetesNodesLimits } from 'Kubernetes/models/nodes-limits/models'; + +class KubernetesNodesLimitsService { + /* @ngInject */ + constructor(KubernetesNodesLimits) { + this.KubernetesNodesLimits = KubernetesNodesLimits; + } + + /** + * GET + */ + async get() { + try { + const nodesLimits = await this.KubernetesNodesLimits.get().$promise; + return new KubernetesNodesLimits(nodesLimits.data); + } catch (err) { + throw new PortainerError('Unable to retrieve nodes limits', err); + } + } +} + +export default KubernetesNodesLimitsService; +angular.module('portainer.kubernetes').service('KubernetesNodesLimitsService', KubernetesNodesLimitsService); diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 62ca91281..2a432fcf1 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -722,6 +722,13 @@

+ +
+
+ + These reservations would exceed the resources currently available in the cluster. +
+
diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 6ddd9bdb2..9a4ff4e38 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -49,7 +49,8 @@ class KubernetesCreateApplicationController { KubernetesIngressService, KubernetesPersistentVolumeClaimService, KubernetesVolumeService, - RegistryService + RegistryService, + KubernetesNodesLimitsService ) { this.$async = $async; this.$state = $state; @@ -65,6 +66,7 @@ class KubernetesCreateApplicationController { this.KubernetesIngressService = KubernetesIngressService; this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; this.RegistryService = RegistryService; + this.KubernetesNodesLimitsService = KubernetesNodesLimitsService; this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies; @@ -92,6 +94,10 @@ class KubernetesCreateApplicationController { memory: 0, cpu: 0, }, + namespaceLimits: { + memory: 0, + cpu: 0, + }, resourcePoolHasQuota: false, viewReady: false, availableSizeUnits: ['MB', 'GB', 'TB'], @@ -583,14 +589,28 @@ class KubernetesCreateApplicationController { return !this.state.sliders.memory.max || !this.state.sliders.cpu.max; } - resourceReservationsOverflow() { - const instances = this.formValues.ReplicaCount; + nodeLimitsOverflow() { const cpu = this.formValues.CpuLimit; - const maxCpu = this.state.sliders.cpu.max; - const memory = this.formValues.MemoryLimit; - const maxMemory = this.state.sliders.memory.max; + const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit); - if (cpu * instances > maxCpu) { + const overflow = this.nodesLimits.overflowForReplica(cpu, memory, 1); + + return overflow; + } + + effectiveInstances() { + return this.formValues.DeploymentType === this.ApplicationDeploymentTypes.GLOBAL ? this.nodeNumber : this.formValues.ReplicaCount; + } + + resourceReservationsOverflow() { + const instances = this.effectiveInstances(); + const cpu = this.formValues.CpuLimit; + const maxCpu = this.state.namespaceLimits.cpu; + const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit); + const maxMemory = this.state.namespaceLimits.memory; + + // multiply 1000 can avoid 0.1 * 3 > 0.3 + if (cpu * 1000 * instances > maxCpu * 1000) { return true; } @@ -598,17 +618,23 @@ class KubernetesCreateApplicationController { return true; } - return false; + if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.REPLICATED) { + return this.nodesLimits.overflowForReplica(cpu, memory, instances); + } + + // DeploymentType == GLOBAL + return this.nodesLimits.overflowForGlobal(cpu, memory); } autoScalerOverflow() { const instances = this.formValues.AutoScaler.MaxReplicas; const cpu = this.formValues.CpuLimit; - const maxCpu = this.state.sliders.cpu.max; - const memory = this.formValues.MemoryLimit; - const maxMemory = this.state.sliders.memory.max; + const maxCpu = this.state.namespaceLimits.cpu; + const memory = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit); + const maxMemory = this.state.namespaceLimits.memory; - if (cpu * instances > maxCpu) { + // multiply 1000 can avoid 0.1 * 3 > 0.3 + if (cpu * 1000 * instances > maxCpu * 1000) { return true; } @@ -616,7 +642,7 @@ class KubernetesCreateApplicationController { return true; } - return false; + return this.nodesLimits.overflowForReplica(cpu, memory, instances); } publishViaLoadBalancerEnabled() { @@ -732,50 +758,66 @@ class KubernetesCreateApplicationController { /* #region DATA AUTO REFRESH */ updateSliders() { + const quota = this.formValues.ResourcePool.Quota; + let minCpu = 0, + minMemory = 0, + maxCpu = this.state.namespaceLimits.cpu, + maxMemory = this.state.namespaceLimits.memory; + + if (quota) { + if (quota.CpuLimit) { + minCpu = KubernetesApplicationQuotaDefaults.CpuLimit; + } + if (quota.MemoryLimit) { + minMemory = KubernetesResourceReservationHelper.bytesValue(KubernetesApplicationQuotaDefaults.MemoryLimit); + } + } + + maxCpu = Math.min(maxCpu, this.nodesLimits.MaxCPU); + maxMemory = Math.min(maxMemory, this.nodesLimits.MaxMemory); + + if (maxMemory < minMemory) { + minMemory = 0; + maxMemory = 0; + } + + this.state.sliders.memory.min = KubernetesResourceReservationHelper.megaBytesValue(minMemory); + this.state.sliders.memory.max = KubernetesResourceReservationHelper.megaBytesValue(maxMemory); + this.state.sliders.cpu.min = minCpu; + this.state.sliders.cpu.max = _.floor(maxCpu, 2); + if (!this.state.isEdit) { + this.formValues.CpuLimit = minCpu; + this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(minMemory); + } + } + + updateNamespaceLimits() { + let maxCpu = this.state.nodes.cpu; + let maxMemory = this.state.nodes.memory; + const quota = this.formValues.ResourcePool.Quota; + this.state.resourcePoolHasQuota = false; - const quota = this.formValues.ResourcePool.Quota; - let minCpu, - maxCpu, - minMemory, - maxMemory = 0; if (quota) { if (quota.CpuLimit) { this.state.resourcePoolHasQuota = true; - minCpu = KubernetesApplicationQuotaDefaults.CpuLimit; maxCpu = quota.CpuLimit - quota.CpuLimitUsed; if (this.state.isEdit && this.savedFormValues.CpuLimit) { - maxCpu += this.savedFormValues.CpuLimit * this.savedFormValues.ReplicaCount; + maxCpu += this.savedFormValues.CpuLimit * this.effectiveInstances(); } - } else { - minCpu = 0; - maxCpu = this.state.nodes.cpu; } + if (quota.MemoryLimit) { this.state.resourcePoolHasQuota = true; - minMemory = KubernetesApplicationQuotaDefaults.MemoryLimit; maxMemory = quota.MemoryLimit - quota.MemoryLimitUsed; if (this.state.isEdit && this.savedFormValues.MemoryLimit) { - maxMemory += KubernetesResourceReservationHelper.bytesValue(this.savedFormValues.MemoryLimit) * this.savedFormValues.ReplicaCount; + maxMemory += KubernetesResourceReservationHelper.bytesValue(this.savedFormValues.MemoryLimit) * this.effectiveInstances(); } - } else { - minMemory = 0; - maxMemory = this.state.nodes.memory; } - } else { - minCpu = 0; - maxCpu = this.state.nodes.cpu; - minMemory = 0; - maxMemory = this.state.nodes.memory; - } - this.state.sliders.memory.min = minMemory; - this.state.sliders.memory.max = KubernetesResourceReservationHelper.megaBytesValue(maxMemory); - this.state.sliders.cpu.min = minCpu; - this.state.sliders.cpu.max = _.round(maxCpu, 2); - if (!this.state.isEdit) { - this.formValues.CpuLimit = minCpu; - this.formValues.MemoryLimit = minMemory; } + + this.state.namespaceLimits.cpu = maxCpu; + this.state.namespaceLimits.memory = maxMemory; } refreshStacks(namespace) { @@ -863,6 +905,7 @@ class KubernetesCreateApplicationController { onResourcePoolSelectionChange() { return this.$async(async () => { const namespace = this.formValues.ResourcePool.Namespace.Name; + this.updateNamespaceLimits(); this.updateSliders(); await this.refreshNamespaceData(namespace); this.resetFormValues(); @@ -947,12 +990,14 @@ class KubernetesCreateApplicationController { this.state.useLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; - const [resourcePools, nodes, ingresses] = await Promise.all([ + const [resourcePools, nodes, ingresses, nodesLimits] = await Promise.all([ this.KubernetesResourcePoolService.get(), this.KubernetesNodeService.get(), this.KubernetesIngressService.get(), + this.KubernetesNodesLimitsService.get(), ]); this.ingresses = ingresses; + this.nodesLimits = nodesLimits; this.resourcePools = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); this.formValues.ResourcePool = this.resourcePools[0]; @@ -965,6 +1010,7 @@ class KubernetesCreateApplicationController { this.state.nodes.cpu += item.CPU; }); this.nodesLabels = KubernetesNodeHelper.generateNodeLabelsFromNodes(nodes); + this.nodeNumber = nodes.length; const namespace = this.state.isEdit ? this.$state.params.namespace : this.formValues.ResourcePool.Namespace.Name; await this.refreshNamespaceData(namespace); @@ -998,6 +1044,12 @@ class KubernetesCreateApplicationController { this.formValues.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(null, this.formValues.ReplicaCount); this.formValues.OriginalIngressClasses = angular.copy(this.ingresses); } + + if (this.state.isEdit) { + this.nodesLimits.excludesPods(this.application.Pods, this.formValues.CpuLimit, KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit)); + } + + this.updateNamespaceLimits(); this.updateSliders(); } catch (err) { this.Notifications.error('Failure', err, 'Unable to load view data'); From 35013e7b6a2879573a1453d20c6d8c16f560d188 Mon Sep 17 00:00:00 2001 From: cong meng Date: Wed, 1 Sep 2021 09:23:21 +1200 Subject: [PATCH 11/18] feat(kubeconfig): Introduce the ability to change the expiry of a kubeconfig EE-1153 (#5421) * feat(kubeconfig) EE-1153 Introduce the ability to change the expiry of a kubeconfig * feat(kubeconfig) EE-1153 pr feedback update * feat(kubeconfig) EE-1153 code cleanup Co-authored-by: Simon Meng --- api/bolt/init.go | 1 + api/bolt/migrator/migrate_dbversion31.go | 13 +++ api/cmd/portainer/main.go | 2 +- api/http/handler/kubernetes/handler.go | 1 + .../handler/kubernetes/kubernetes_config.go | 27 ++----- api/http/handler/settings/settings_update.go | 12 +++ api/http/server.go | 1 + api/internal/testhelpers/datastore.go | 15 ++++ api/jwt/jwt.go | 24 +++--- api/jwt/jwt_kubeconfig.go | 26 ++++++ api/jwt/jwt_kubeconfig_test.go | 81 +++++++++++++++++++ api/jwt/jwt_test.go | 8 +- api/portainer.go | 5 ++ app/portainer/models/settings.js | 1 + app/portainer/views/settings/settings.html | 18 +++++ .../views/settings/settingsController.js | 23 +++++- 16 files changed, 221 insertions(+), 37 deletions(-) create mode 100644 api/jwt/jwt_kubeconfig.go create mode 100644 api/jwt/jwt_kubeconfig_test.go diff --git a/api/bolt/init.go b/api/bolt/init.go index 9209cfc3a..1c997087e 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -45,6 +45,7 @@ func (store *Store) Init() error { EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, TemplatesURL: portainer.DefaultTemplatesURL, UserSessionTimeout: portainer.DefaultUserSessionTimeout, + KubeconfigExpiry: portainer.DefaultKubeconfigExpiry, } err = store.SettingsService.UpdateSettings(defaultSettings) diff --git a/api/bolt/migrator/migrate_dbversion31.go b/api/bolt/migrator/migrate_dbversion31.go index 52f6c569b..6c9f7f00b 100644 --- a/api/bolt/migrator/migrate_dbversion31.go +++ b/api/bolt/migrator/migrate_dbversion31.go @@ -24,6 +24,10 @@ func (m *Migrator) migrateDBVersionToDB32() error { return err } + if err := m.kubeconfigExpiryToDB32(); err != nil { + return err + } + return nil } @@ -211,3 +215,12 @@ func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interf } } } + +func (m *Migrator) kubeconfigExpiryToDB32() error { + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry + return m.settingsService.UpdateSettings(settings) +} \ No newline at end of file diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 717c3fbc3..32074c7e5 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -114,7 +114,7 @@ func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout dataStore.Settings().UpdateSettings(settings) } - jwtService, err := jwt.NewService(settings.UserSessionTimeout) + jwtService, err := jwt.NewService(settings.UserSessionTimeout, dataStore) if err != nil { return nil, err } diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 3efad11f4..015b063c9 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -20,6 +20,7 @@ type Handler struct { dataStore portainer.DataStore kubernetesClientFactory *cli.ClientFactory authorizationService *authorization.Service + JwtService portainer.JWTService } // NewHandler creates a handler to process pre-proxied requests to external APIs. diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/kubernetes_config.go index 972d34390..31734dcb1 100644 --- a/api/http/handler/kubernetes/kubernetes_config.go +++ b/api/http/handler/kubernetes/kubernetes_config.go @@ -3,14 +3,11 @@ package kubernetes import ( "errors" "fmt" - "strings" - httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" - httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" kcli "github.com/portainer/portainer/api/kubernetes/cli" @@ -46,16 +43,16 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - bearerToken, err := extractBearerToken(r) - if err != nil { - return &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err} - } - tokenData, err := security.RetrieveTokenData(r) if err != nil { return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} } + bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err} + } + cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} @@ -84,20 +81,6 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque return response.JSON(w, config) } -// extractBearerToken extracts user's portainer bearer token from request auth header -func extractBearerToken(r *http.Request) (string, error) { - token := "" - tokens := r.Header["Authorization"] - if len(tokens) >= 1 { - token = tokens[0] - token = strings.TrimPrefix(token, "Bearer ") - } - if token == "" { - return "", httperrors.ErrUnauthorized - } - return token, nil -} - // getProxyUrl generates portainer proxy url which acts as proxy to k8s api server func getProxyUrl(r *http.Request, endpointID int) string { return fmt.Sprintf("https://%s/api/endpoints/%d/kubernetes", r.Host, endpointID) diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 36150d536..7258170d9 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -32,6 +32,8 @@ type settingsUpdatePayload struct { EnableEdgeComputeFeatures *bool `example:"true"` // The duration of a user session UserSessionTimeout *string `example:"5m"` + // The expiry of a Kubeconfig + KubeconfigExpiry *string `example:"24h" default:"0"` // Whether telemetry is enabled EnableTelemetry *bool `example:"false"` } @@ -52,6 +54,12 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error { return errors.New("Invalid user session timeout") } } + if payload.KubeconfigExpiry != nil { + _, err := time.ParseDuration(*payload.KubeconfigExpiry) + if err != nil { + return errors.New("Invalid Kubeconfig Expiry") + } + } return nil } @@ -135,6 +143,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval } + if payload.KubeconfigExpiry != nil { + settings.KubeconfigExpiry = *payload.KubeconfigExpiry + } + if payload.UserSessionTimeout != nil { settings.UserSessionTimeout = *payload.UserSessionTimeout diff --git a/api/http/server.go b/api/http/server.go index 95f9dfa76..88ca1284f 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -161,6 +161,7 @@ func (server *Server) Start() error { endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.KubernetesClientFactory) + kubernetesHandler.JwtService = server.JWTService var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 635508d9b..08e865c77 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -70,6 +70,21 @@ func NewDatastore(options ...datastoreOption) *datastore { return &d } + +type stubSettingsService struct { + settings *portainer.Settings +} + +func (s *stubSettingsService) Settings() (*portainer.Settings, error) { return s.settings, nil } +func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { return nil } + +func WithSettings(settings *portainer.Settings) datastoreOption { + return func(d *datastore) { + d.settings = &stubSettingsService{settings: settings} + } +} + + type stubUserService struct { users []portainer.User } diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 2caf0840a..5786caf85 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -16,6 +16,7 @@ import ( type Service struct { secret []byte userSessionTimeout time.Duration + dataStore portainer.DataStore } type claims struct { @@ -31,7 +32,7 @@ var ( ) // NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens. -func NewService(userSessionDuration string) (*Service, error) { +func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Service, error) { userSessionTimeout, err := time.ParseDuration(userSessionDuration) if err != nil { return nil, err @@ -45,19 +46,28 @@ func NewService(userSessionDuration string) (*Service, error) { service := &Service{ secret, userSessionTimeout, + dataStore, } return service, nil } +func (service *Service) defaultExpireAt() (int64) { + return time.Now().Add(service.userSessionTimeout).Unix() +} + // GenerateToken generates a new JWT token. func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { - return service.generateSignedToken(data, nil) + return service.generateSignedToken(data, service.defaultExpireAt()) } // GenerateTokenForOAuth generates a new JWT for OAuth login // token expiry time from the OAuth provider is considered func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) { - return service.generateSignedToken(data, expiryTime) + expireAt := service.defaultExpireAt() + if expiryTime != nil && !expiryTime.IsZero() { + expireAt = expiryTime.Unix() + } + return service.generateSignedToken(data, expireAt) } // ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. @@ -88,17 +98,13 @@ func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration service.userSessionTimeout = userSessionDuration } -func (service *Service) generateSignedToken(data *portainer.TokenData, expiryTime *time.Time) (string, error) { - expireToken := time.Now().Add(service.userSessionTimeout).Unix() - if expiryTime != nil && !expiryTime.IsZero() { - expireToken = expiryTime.Unix() - } +func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt int64) (string, error) { cl := claims{ UserID: int(data.ID), Username: data.Username, Role: int(data.Role), StandardClaims: jwt.StandardClaims{ - ExpiresAt: expireToken, + ExpiresAt: expiresAt, }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) diff --git a/api/jwt/jwt_kubeconfig.go b/api/jwt/jwt_kubeconfig.go new file mode 100644 index 000000000..544a481c1 --- /dev/null +++ b/api/jwt/jwt_kubeconfig.go @@ -0,0 +1,26 @@ +package jwt + +import ( + portainer "github.com/portainer/portainer/api" + "time" +) + +// GenerateTokenForKubeconfig generates a new JWT token for Kubeconfig +func (service *Service) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) { + settings, err := service.dataStore.Settings().Settings() + if err != nil { + return "", err + } + + expiryDuration, err := time.ParseDuration(settings.KubeconfigExpiry) + if err != nil { + return "", err + } + + expiryAt := time.Now().Add(expiryDuration).Unix() + if expiryDuration == time.Duration(0) { + expiryAt = 0 + } + + return service.generateSignedToken(data, expiryAt) +} diff --git a/api/jwt/jwt_kubeconfig_test.go b/api/jwt/jwt_kubeconfig_test.go new file mode 100644 index 000000000..b8269b4ca --- /dev/null +++ b/api/jwt/jwt_kubeconfig_test.go @@ -0,0 +1,81 @@ +package jwt + +import ( + "github.com/dgrijalva/jwt-go" + portainer "github.com/portainer/portainer/api" + i "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestService_GenerateTokenForKubeconfig(t *testing.T) { + type fields struct { + userSessionTimeout string + dataStore portainer.DataStore + } + + type args struct { + data *portainer.TokenData + } + + mySettings := &portainer.Settings{ + KubeconfigExpiry: "0", + } + + myFields := fields{ + userSessionTimeout: "24h", + dataStore: i.NewDatastore(i.WithSettings(mySettings)), + } + + myTokenData := &portainer.TokenData{ + Username: "Joe", + ID: 1, + Role: 1, + } + + myArgs := args{ + data: myTokenData, + } + + tests := []struct { + name string + fields fields + args args + wantExpiresAt int64 + wantErr bool + }{ + { + name: "kubeconfig no expiry", + fields: myFields, + args: myArgs, + wantExpiresAt: 0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, err := NewService(tt.fields.userSessionTimeout, tt.fields.dataStore) + assert.NoError(t, err, "failed to create a copy of service") + + got, err := service.GenerateTokenForKubeconfig(tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateTokenForKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + + parsedToken, err := jwt.ParseWithClaims(got, &claims{}, func(token *jwt.Token) (interface{}, error) { + return service.secret, nil + }) + assert.NoError(t, err, "failed to parse generated token") + + tokenClaims, ok := parsedToken.Claims.(*claims) + assert.Equal(t, true, ok, "failed to claims out of generated ticket") + + assert.Equal(t, myTokenData.Username, tokenClaims.Username) + assert.Equal(t, int(myTokenData.ID), tokenClaims.UserID) + assert.Equal(t, int(myTokenData.Role), tokenClaims.Role) + assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt) + }) + } +} \ No newline at end of file diff --git a/api/jwt/jwt_test.go b/api/jwt/jwt_test.go index ce70f6308..2a18783e4 100644 --- a/api/jwt/jwt_test.go +++ b/api/jwt/jwt_test.go @@ -10,7 +10,7 @@ import ( ) func TestGenerateSignedToken(t *testing.T) { - svc, err := NewService("24h") + svc, err := NewService("24h", nil) assert.NoError(t, err, "failed to create a copy of service") token := &portainer.TokenData{ @@ -18,9 +18,9 @@ func TestGenerateSignedToken(t *testing.T) { ID: 1, Role: 1, } - expirtationTime := time.Now().Add(1 * time.Hour) + expiresAt := time.Now().Add(1 * time.Hour).Unix() - generatedToken, err := svc.generateSignedToken(token, &expirtationTime) + generatedToken, err := svc.generateSignedToken(token, expiresAt) assert.NoError(t, err, "failed to generate a signed token") parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) { @@ -34,5 +34,5 @@ func TestGenerateSignedToken(t *testing.T) { assert.Equal(t, token.Username, tokenClaims.Username) assert.Equal(t, int(token.ID), tokenClaims.UserID) assert.Equal(t, int(token.Role), tokenClaims.Role) - assert.Equal(t, expirtationTime.Unix(), tokenClaims.ExpiresAt) + assert.Equal(t, expiresAt, tokenClaims.ExpiresAt) } diff --git a/api/portainer.go b/api/portainer.go index 2d82edf3a..71ff14d70 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -689,6 +689,8 @@ type ( EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:""` // The duration of a user session UserSessionTimeout string `json:"UserSessionTimeout" example:"5m"` + // The expiry of a Kubeconfig + KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"` // Whether telemetry is enabled EnableTelemetry bool `json:"EnableTelemetry" example:"false"` @@ -1215,6 +1217,7 @@ type ( JWTService interface { GenerateToken(data *TokenData) (string, error) GenerateTokenForOAuth(data *TokenData, expiryTime *time.Time) (string, error) + GenerateTokenForKubeconfig(data *TokenData) (string, error) ParseAndVerifyToken(token string) (*TokenData, error) SetUserSessionDuration(userSessionDuration time.Duration) } @@ -1452,6 +1455,8 @@ const ( DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json" // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared DefaultUserSessionTimeout = "8h" + // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared + DefaultKubeconfigExpiry = "0" ) const ( diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 0be1a8c69..58bc3c8b1 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -10,6 +10,7 @@ export function SettingsViewModel(data) { this.EnableEdgeComputeFeatures = data.EnableEdgeComputeFeatures; this.UserSessionTimeout = data.UserSessionTimeout; this.EnableTelemetry = data.EnableTelemetry; + this.KubeconfigExpiry = data.KubeconfigExpiry; } export function PublicSettingsViewModel(settings) { diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index 1f8737e8c..de3bbe41e 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -118,6 +118,24 @@
+ +
+ Kubernetes +
+
+ +
+ +
+
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index a5438ce10..8d7c48b3b 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -24,7 +24,28 @@ angular.module('portainer.app').controller('SettingsController', [ value: 30, }, ], - + availableKubeconfigExpiryOptions: [ + { + key: '1 day', + value: '24h', + }, + { + key: '7 days', + value: `${24 * 7}h`, + }, + { + key: '30 days', + value: `${24 * 30}h`, + }, + { + key: '1 year', + value: `${24 * 30 * 12}h`, + }, + { + key: 'No expiry', + value: '0', + }, + ], backupInProgress: false, }; From 7760595f2164ad146d2b5ac09f0d751a42fdb150 Mon Sep 17 00:00:00 2001 From: cong meng Date: Wed, 1 Sep 2021 09:43:11 +1200 Subject: [PATCH 12/18] feat(rbac) remove list ingresses permissions EE-1304 (#5458) * feat(RBAC) EE-1304 list ingresses of current namespace other than all namespaces at front end side * feat(RBAC) EE-1304 remove list ingresses from clusterrole Co-authored-by: Simon Meng --- api/kubernetes/cli/role.go | 16 ++++---- api/kubernetes/cli/service_account.go | 2 +- .../create/createApplication.html | 2 +- .../create/createApplicationController.js | 41 ++++++++++--------- .../edit/resourcePoolController.js | 2 +- 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go index d75afa3c1..7c22f1b32 100644 --- a/api/kubernetes/cli/role.go +++ b/api/kubernetes/cli/role.go @@ -18,15 +18,10 @@ func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule { Resources: []string{"storageclasses"}, APIGroups: []string{"storage.k8s.io"}, }, - { - Verbs: []string{"list"}, - Resources: []string{"ingresses"}, - APIGroups: []string{"networking.k8s.io"}, - }, } } -func (kcl *KubeClient) createPortainerUserClusterRole() error { +func (kcl *KubeClient) upsertPortainerK8sClusterRoles() error { clusterRole := &rbacv1.ClusterRole{ ObjectMeta: metav1.ObjectMeta{ Name: portainerUserCRName, @@ -35,8 +30,13 @@ func (kcl *KubeClient) createPortainerUserClusterRole() error { } _, err := kcl.cli.RbacV1().ClusterRoles().Create(clusterRole) - if err != nil && !k8serrors.IsAlreadyExists(err) { - return err + if err != nil { + if k8serrors.IsAlreadyExists(err) { + _, err = kcl.cli.RbacV1().ClusterRoles().Update(clusterRole) + } + if err != nil { + return err + } } return nil diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go index ddb852b3c..95dc4e897 100644 --- a/api/kubernetes/cli/service_account.go +++ b/api/kubernetes/cli/service_account.go @@ -63,7 +63,7 @@ func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int, restri } func (kcl *KubeClient) ensureRequiredResourcesExist() error { - return kcl.createPortainerUserClusterRole() + return kcl.upsertPortainerK8sClusterRoles() } func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error { diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 2a432fcf1..0a84c5e13 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -1347,7 +1347,7 @@ class="form-control" name="ingress_class_{{ $index }}" ng-model="publishedPort.IngressName" - ng-options="ingress.Name as ingress.Name for ingress in ctrl.filteredIngresses" + ng-options="ingress.Name as ingress.Name for ingress in ctrl.ingresses" ng-required="!publishedPort.NeedsDeletion" ng-change="ctrl.onChangePortMappingIngress($index)" ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)" diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 9a4ff4e38..31ca80f16 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -371,7 +371,7 @@ class KubernetesCreateApplicationController { /* #region PUBLISHED PORTS UI MANAGEMENT */ addPublishedPort() { const p = new KubernetesApplicationPublishedPortFormValue(); - const ingresses = this.filteredIngresses; + const ingresses = this.ingresses; p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined; p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined; p.IngressHosts = ingresses && ingresses.length ? ingresses[0].Hosts : undefined; @@ -382,7 +382,7 @@ class KubernetesCreateApplicationController { } resetPublishedPorts() { - const ingresses = this.filteredIngresses; + const ingresses = this.ingresses; _.forEach(this.formValues.PublishedPorts, (p) => { p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined; p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined; @@ -441,7 +441,7 @@ class KubernetesCreateApplicationController { onChangePortMappingIngress(index) { const publishedPort = this.formValues.PublishedPorts[index]; - const ingress = _.find(this.filteredIngresses, { Name: publishedPort.IngressName }); + const ingress = _.find(this.ingresses, { Name: publishedPort.IngressName }); publishedPort.IngressHosts = ingress.Hosts; this.ingressHostnames = ingress.Hosts; publishedPort.IngressHost = this.ingressHostnames.length ? this.ingressHostnames[0] : []; @@ -650,7 +650,7 @@ class KubernetesCreateApplicationController { } publishViaIngressEnabled() { - return this.filteredIngresses.length; + return this.ingresses.length; } isEditAndNoChangesMade() { @@ -871,16 +871,22 @@ class KubernetesCreateApplicationController { } refreshIngresses(namespace) { - this.filteredIngresses = _.filter(this.ingresses, { Namespace: namespace }); - this.ingressHostnames = this.filteredIngresses.length ? this.filteredIngresses[0].Hosts : []; - if (!this.publishViaIngressEnabled()) { - if (this.savedFormValues) { - this.formValues.PublishingType = this.savedFormValues.PublishingType; - } else { - this.formValues.PublishingType = this.ApplicationPublishingTypes.INTERNAL; + return this.$async(async () => { + try { + this.ingresses = await this.KubernetesIngressService.get(namespace); + this.ingressHostnames = this.ingresses.length ? this.ingresses[0].Hosts : []; + if (!this.publishViaIngressEnabled()) { + if (this.savedFormValues) { + this.formValues.PublishingType = this.savedFormValues.PublishingType; + } else { + this.formValues.PublishingType = this.ApplicationPublishingTypes.INTERNAL; + } + } + this.formValues.OriginalIngresses = this.ingresses; + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve ingresses'); } - } - this.formValues.OriginalIngresses = this.filteredIngresses; + }); } refreshNamespaceData(namespace) { @@ -990,13 +996,11 @@ class KubernetesCreateApplicationController { this.state.useLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; - const [resourcePools, nodes, ingresses, nodesLimits] = await Promise.all([ + const [resourcePools, nodes, nodesLimits] = await Promise.all([ this.KubernetesResourcePoolService.get(), this.KubernetesNodeService.get(), - this.KubernetesIngressService.get(), this.KubernetesNodesLimitsService.get(), ]); - this.ingresses = ingresses; this.nodesLimits = nodesLimits; this.resourcePools = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name)); @@ -1023,9 +1027,9 @@ class KubernetesCreateApplicationController { this.configurations, this.persistentVolumeClaims, this.nodesLabels, - this.filteredIngresses + this.ingresses ); - this.formValues.OriginalIngresses = this.filteredIngresses; + this.formValues.OriginalIngresses = this.ingresses; this.formValues.ImageModel = await this.parseImageConfiguration(this.formValues.ImageModel); this.savedFormValues = angular.copy(this.formValues); delete this.formValues.ApplicationType; @@ -1042,7 +1046,6 @@ class KubernetesCreateApplicationController { await this.refreshNamespaceData(namespace); } else { this.formValues.AutoScaler = KubernetesApplicationHelper.generateAutoScalerFormValueFromHorizontalPodAutoScaler(null, this.formValues.ReplicaCount); - this.formValues.OriginalIngressClasses = angular.copy(this.ingresses); } if (this.state.isEdit) { diff --git a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js index a38e9ac78..a2f94db49 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePoolController.js +++ b/app/kubernetes/views/resource-pools/edit/resourcePoolController.js @@ -293,7 +293,7 @@ class KubernetesResourcePoolController { this.state.ingressesLoading = true; try { const namespace = this.pool.Namespace.Name; - this.allIngresses = await this.KubernetesIngressService.get(); + this.allIngresses = await this.KubernetesIngressService.get(this.state.hasWriteAuthorization ? '' : namespace); this.ingresses = _.filter(this.allIngresses, { Namespace: namespace }); _.forEach(this.ingresses, (ing) => { ing.Namespace = namespace; From 2f18f2eb8752cfb2f86aa4edfcd3626f3c53c100 Mon Sep 17 00:00:00 2001 From: fhanportainer <79428273+fhanportainer@users.noreply.github.com> Date: Wed, 1 Sep 2021 10:48:02 +1200 Subject: [PATCH 13/18] fix(stack): git form validation improvement. EE-1291 EE-1292 (#5440) * fix(stack): git form validation improvement. EE-1291 EE-1292 * feedback update * moved comparison function to OnChange * fixed on change method in environment variable panel. * using angularJs.ToJson to strip out $$haskey in formValues --- .../stack-redeploy-git-form.controller.js | 36 ++++++++++++++----- .../stack-redeploy-git-form.html | 8 ++--- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js index 717c15333..1d21ae75c 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js @@ -1,4 +1,5 @@ import uuidv4 from 'uuid/v4'; + class StackRedeployGitFormController { /* @ngInject */ constructor($async, $state, StackService, ModalService, Notifications, WebhookHelper, FormHelper) { @@ -15,6 +16,7 @@ class StackRedeployGitFormController { redeployInProgress: false, showConfig: false, isEdit: false, + hasUnsavedChanges: false, }; this.formValues = { @@ -34,11 +36,8 @@ class StackRedeployGitFormController { this.onChange = this.onChange.bind(this); this.onChangeRef = this.onChangeRef.bind(this); - this.handleEnvVarChange = this.handleEnvVarChange.bind(this); - } - - onChangeRef(value) { - this.onChange({ RefName: value }); + this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this); + this.onChangeEnvVar = this.onChangeEnvVar.bind(this); } onChange(values) { @@ -46,6 +45,25 @@ class StackRedeployGitFormController { ...this.formValues, ...values, }; + + this.state.hasUnsavedChanges = angular.toJson(this.savedFormValues) !== angular.toJson(this.formValues); + } + + onChangeRef(value) { + this.onChange({ RefName: value }); + } + + onChangeAutoUpdate(values) { + this.onChange({ + AutoUpdate: { + ...this.formValues.AutoUpdate, + ...values, + }, + }); + } + + onChangeEnvVar(value) { + this.onChange({ Env: value }); } async submit() { @@ -83,6 +101,8 @@ class StackRedeployGitFormController { try { this.state.inProgress = true; await this.StackService.updateGitStackSettings(this.stack.Id, this.stack.EndpointId, this.FormHelper.removeInvalidEnvVars(this.formValues.Env), this.formValues); + this.savedFormValues = angular.copy(this.formValues); + this.state.hasUnsavedChanges = false; this.Notifications.success('Save stack settings successfully'); } catch (err) { this.Notifications.error('Failure', err, 'Unable to save stack settings'); @@ -96,10 +116,6 @@ class StackRedeployGitFormController { return this.state.inProgress || this.state.redeployInProgress; } - handleEnvVarChange(value) { - this.formValues.Env = value; - } - $onInit() { this.formValues.RefName = this.model.ReferenceName; this.formValues.Env = this.stack.Env; @@ -125,6 +141,8 @@ class StackRedeployGitFormController { this.formValues.RepositoryAuthentication = true; this.state.isEdit = true; } + + this.savedFormValues = angular.copy(this.formValues); } } diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html index 340400573..07b85c51f 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html @@ -18,7 +18,7 @@
- +

@@ -40,14 +40,14 @@ From 8b19623c5bd821a57ed950cba993a98ea24faa87 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Wed, 1 Sep 2021 10:42:33 +0300 Subject: [PATCH 17/18] chore(dev): expose https port (#5457) --- gruntfile.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gruntfile.js b/gruntfile.js index 6dbbc1c00..b0a1e2906 100644 --- a/gruntfile.js +++ b/gruntfile.js @@ -175,7 +175,9 @@ function shell_build_binary_azuredevops(p, a) { function shell_run_container() { return [ 'docker rm -f portainer', - 'docker run -d -p 8000:8000 -p 9000:9000 -v ' + portainer_root + '/dist:/app -v ' + + 'docker run -d -p 8000:8000 -p 9000:9000 -p 9443:9443 -v ' + + portainer_root + + '/dist:/app -v ' + portainer_data + ':/data -v /var/run/docker.sock:/var/run/docker.sock:z -v /var/run/docker.sock:/var/run/alternative.sock:z -v /tmp:/tmp --name portainer portainer/base /app/portainer', ].join(';'); From d6dbb3982a1d8cdde6a7893b6897f037660317ad Mon Sep 17 00:00:00 2001 From: Felix Han Date: Wed, 1 Sep 2021 22:53:45 +1200 Subject: [PATCH 18/18] added analytics-on directive to pull and redeploy button --- .../forms/kubernetes-app-git-form/kubernetes-app-git-form.html | 1 + 1 file changed, 1 insertion(+) diff --git a/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.html b/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.html index 18e2a2f74..88f019f26 100644 --- a/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.html +++ b/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.html @@ -39,6 +39,7 @@ ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.redeployGitForm.$valid" style="margin-top: 7px; margin-left: 0;" button-spinner="ctrl.state.redeployInProgress" + analytics-on analytics-category="kubernetes" analytics-event="kubernetes-application-edit-git-pull" >