diff --git a/.gitignore b/.gitignore
index 60bed44fc..4dbf5787f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ storybook-static
.tmp
**/.vscode/settings.json
**/.vscode/tasks.json
+*.DS_Store
.eslintcache
__debug_bin
diff --git a/api/go.mod b/api/go.mod
index 108a5207d..bfca6833c 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -33,11 +33,10 @@ require (
github.com/portainer/docker-compose-wrapper v0.0.0-20211018221743-10a04c9d4f19
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
- github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
+ github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0
- github.com/swaggo/swag v1.7.3
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
diff --git a/api/go.sum b/api/go.sum
index a700e5a31..541f18754 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -41,8 +41,6 @@ github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ
github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
-github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
-github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
@@ -64,9 +62,7 @@ github.com/Microsoft/hcsshim/test v0.0.0-20201218223536-d3e5debf77da/go.mod h1:5
github.com/Microsoft/hcsshim/test v0.0.0-20210227013316-43a75bb4edd3/go.mod h1:mw7qgWloBUl75W/gVH3cQszUg1+gUITj7D6NY7ywVnY=
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
-github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs=
@@ -320,19 +316,11 @@ github.com/go-logr/logr v0.4.0 h1:K7/B1jt6fIBQVd4Owv2MqGQClcgf0R266+7C/QjRcLc=
github.com/go-logr/logr v0.4.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
-github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
-github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc=
github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8=
-github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
-github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo=
-github.com/go-openapi/spec v0.20.3 h1:uH9RQ6vdyPSs2pSy9fL8QPspDF2AMIMPtmK5coSSjtQ=
-github.com/go-openapi/spec v0.20.3/go.mod h1:gG4F8wdEDN+YPBMVnzE85Rbhf+Th2DTvA9nFPQ5AYEg=
github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
-github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng=
-github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
@@ -456,8 +444,6 @@ github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
-github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/ansi v0.0.0-20170202005112-f496b27cd669 h1:l5rH/CnVVu+HPxjtxjM90nHrm4nov3j3RF9/62UjgLs=
github.com/jpillora/ansi v0.0.0-20170202005112-f496b27cd669/go.mod h1:kOeLNvjNBGSV3uYtFjvb72+fnZCMFJF1XDvRIjdom0g=
github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
@@ -503,8 +489,6 @@ github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
-github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
-github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
@@ -608,6 +592,8 @@ github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108 h1:5e8KAnDa2G3cE
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108/go.mod h1:YvYAk7krKTzB+rFwDr0jQ3sQu2BtiXK1AR0sZH7nhJA=
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II=
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0=
+github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc h1:vxVN9srGND+iA9oBmyFgtbtOvnmOCLmxw20ncYCJ5HA=
+github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc/go.mod h1:nyQA6IahOruIvENCcBk54aaUvV2WHFdXkvBjIutg+SY=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -648,7 +634,6 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc=
@@ -691,8 +676,6 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/swaggo/swag v1.7.3 h1:ucB7irEdRrhjmW+Z1Ss4GjO68oPKQFjSgOR8BCAvcbU=
-github.com/swaggo/swag v1.7.3/go.mod h1:zD8h6h4SPv7t3l+4BKdRquqW1ASWjKZgT6Qv9z3kNqI=
github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
@@ -706,7 +689,6 @@ github.com/urfave/cli v0.0.0-20171014202726-7bc6a0acffa5/go.mod h1:70zkFmudgCuE/
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
-github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
github.com/vishvananda/netlink v1.1.1-0.20201029203352-d40f9887b852/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
@@ -790,7 +772,6 @@ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -827,7 +808,6 @@ golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023 h1:ADo5wSpq2gqaCGQWzk7S5vd//0iyyLeAratkEoG5dLE=
@@ -908,7 +888,6 @@ golang.org/x/sys v0.0.0-20201117170446-d9b008d0a637/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201202213521-69691e467435/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -926,7 +905,6 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -973,8 +951,6 @@ golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
-golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1078,7 +1054,6 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
-gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go
index 31cd0a5e2..44687147c 100644
--- a/api/http/handler/kubernetes/handler.go
+++ b/api/http/handler/kubernetes/handler.go
@@ -32,21 +32,23 @@ func NewHandler(bouncer *security.RequestBouncer, authorizationService *authoriz
authorizationService: authorizationService,
}
- kubeRouter := h.PathPrefix("/kubernetes/{id}").Subrouter()
-
+ kubeRouter := h.PathPrefix("/kubernetes").Subrouter()
kubeRouter.Use(bouncer.AuthenticatedAccess)
- kubeRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
- kubeRouter.Use(kubeOnlyMiddleware)
-
kubeRouter.PathPrefix("/config").Handler(
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesConfig))).Methods(http.MethodGet)
- kubeRouter.PathPrefix("/nodes_limits").Handler(
+
+ // endpoints
+ endpointRouter := kubeRouter.PathPrefix("/{id}").Subrouter()
+ endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
+ endpointRouter.Use(kubeOnlyMiddleware)
+
+ endpointRouter.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?)
// to keep it simple, we've decided to leave it like this.
- namespaceRouter := kubeRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
+ namespaceRouter := endpointRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
return h
diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/kubernetes_config.go
index a77e8afd5..0c9f86b52 100644
--- a/api/http/handler/kubernetes/kubernetes_config.go
+++ b/api/http/handler/kubernetes/kubernetes_config.go
@@ -5,12 +5,14 @@ import (
"fmt"
"net/http"
+ clientV1 "k8s.io/client-go/tools/clientcmd/api/v1"
+
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"
"github.com/portainer/portainer/api/http/security"
+ "github.com/portainer/portainer/api/internal/endpointutils"
kcli "github.com/portainer/portainer/api/kubernetes/cli"
)
@@ -22,88 +24,184 @@ import (
// @security jwt
// @accept json
// @produce json
-// @param id path int true "Environment(Endpoint) identifier"
+// @param ids query []int false "will include only these environments(endpoints)"
+// @param excludeIds query []int false "will exclude these environments(endpoints)"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 401 "Unauthorized"
// @failure 403 "Permission denied"
// @failure 404 "Environment(Endpoint) or ServiceAccount not found"
// @failure 500 "Server error"
-// @router /kubernetes/{id}/config [get]
+// @router /kubernetes/config [get]
func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
- endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
- if err != nil {
- return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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 environment with the specified identifier inside the database", err}
- } else if err != nil {
- return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err}
- }
-
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", 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}
+ endpoints, handlerErr := handler.filterUserKubeEndpoints(r)
+ if handlerErr != nil {
+ return handlerErr
+ }
+ if len(endpoints) == 0 {
+ return &httperror.HandlerError{http.StatusBadRequest, "empty endpoints list", errors.New("empty endpoints list")}
}
- apiServerURL := getProxyUrl(r, endpointID)
-
- config, err := cli.GetKubeConfig(r.Context(), apiServerURL, bearerToken, tokenData)
- if err != nil {
- return &httperror.HandlerError{http.StatusNotFound, "Unable to generate Kubeconfig", err}
+ config, handlerErr := handler.buildConfig(r, tokenData, bearerToken, endpoints)
+ if handlerErr != nil {
+ return handlerErr
}
- filenameBase := fmt.Sprintf("%s-%s", tokenData.Username, endpoint.Name)
- contentAcceptHeader := r.Header.Get("Accept")
- if contentAcceptHeader == "text/yaml" {
+ return writeFileContent(w, r, endpoints, tokenData, config)
+}
+
+func (handler *Handler) filterUserKubeEndpoints(r *http.Request) ([]portainer.Endpoint, *httperror.HandlerError) {
+ var endpointIDs []portainer.EndpointID
+ _ = request.RetrieveJSONQueryParameter(r, "ids", &endpointIDs, true)
+
+ var excludeEndpointIDs []portainer.EndpointID
+ _ = request.RetrieveJSONQueryParameter(r, "excludeIds", &excludeEndpointIDs, true)
+
+ if len(endpointIDs) > 0 && len(excludeEndpointIDs) > 0 {
+ return nil, &httperror.HandlerError{http.StatusBadRequest, "Can't provide both 'ids' and 'excludeIds' parameters", errors.New("invalid parameters")}
+ }
+
+ securityContext, err := security.RetrieveRestrictedRequestContext(r)
+ if err != nil {
+ return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
+ }
+
+ endpointGroups, err := handler.dataStore.EndpointGroup().EndpointGroups()
+ if err != nil {
+ return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err}
+ }
+
+ if len(endpointIDs) > 0 {
+ var endpoints []portainer.Endpoint
+ for _, endpointID := range endpointIDs {
+ endpoint, err := handler.dataStore.Endpoint().Endpoint(endpointID)
+ if err != nil {
+ return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment from the database", err}
+ }
+ if !endpointutils.IsKubernetesEndpoint(endpoint) {
+ continue
+ }
+ endpoints = append(endpoints, *endpoint)
+ }
+ filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
+ return filteredEndpoints, nil
+ }
+
+ var kubeEndpoints []portainer.Endpoint
+ endpoints, err := handler.dataStore.Endpoint().Endpoints()
+ if err != nil {
+ return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err}
+ }
+ for _, endpoint := range endpoints {
+ if !endpointutils.IsKubernetesEndpoint(&endpoint) {
+ continue
+ }
+ kubeEndpoints = append(kubeEndpoints, endpoint)
+ }
+
+ filteredEndpoints := security.FilterEndpoints(kubeEndpoints, endpointGroups, securityContext)
+ if len(excludeEndpointIDs) > 0 {
+ filteredEndpoints = endpointutils.FilterByExcludeIDs(filteredEndpoints, excludeEndpointIDs)
+ }
+ return filteredEndpoints, nil
+}
+
+func (handler *Handler) buildConfig(r *http.Request, tokenData *portainer.TokenData, bearerToken string, endpoints []portainer.Endpoint) (*clientV1.Config, *httperror.HandlerError) {
+ configClusters := make([]clientV1.NamedCluster, len(endpoints))
+ configContexts := make([]clientV1.NamedContext, len(endpoints))
+ var configAuthInfos []clientV1.NamedAuthInfo
+ authInfosSet := make(map[string]bool)
+
+ for idx, endpoint := range endpoints {
+ cli, err := handler.kubernetesClientFactory.GetKubeClient(&endpoint)
+ if err != nil {
+ return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
+ }
+
+ serviceAccount, err := cli.GetServiceAccount(tokenData)
+ if err != nil {
+ return nil, &httperror.HandlerError{http.StatusInternalServerError, fmt.Sprintf("unable to find serviceaccount associated with user; username=%s", tokenData.Username), err}
+ }
+
+ configClusters[idx] = buildCluster(r, endpoint)
+ configContexts[idx] = buildContext(serviceAccount.Name, endpoint)
+ if !authInfosSet[serviceAccount.Name] {
+ configAuthInfos = append(configAuthInfos, buildAuthInfo(serviceAccount.Name, bearerToken))
+ authInfosSet[serviceAccount.Name] = true
+ }
+ }
+
+ return &clientV1.Config{
+ APIVersion: "v1",
+ Kind: "Config",
+ Clusters: configClusters,
+ Contexts: configContexts,
+ CurrentContext: configContexts[0].Name,
+ AuthInfos: configAuthInfos,
+ }, nil
+}
+
+func buildCluster(r *http.Request, endpoint portainer.Endpoint) clientV1.NamedCluster {
+ proxyURL := fmt.Sprintf("https://%s/api/endpoints/%d/kubernetes", r.Host, endpoint.ID)
+ return clientV1.NamedCluster{
+ Name: buildClusterName(endpoint.Name),
+ Cluster: clientV1.Cluster{
+ Server: proxyURL,
+ InsecureSkipTLSVerify: true,
+ },
+ }
+}
+
+func buildClusterName(endpointName string) string {
+ return fmt.Sprintf("portainer-cluster-%s", endpointName)
+}
+
+func buildContext(serviceAccountName string, endpoint portainer.Endpoint) clientV1.NamedContext {
+ contextName := fmt.Sprintf("portainer-ctx-%s", endpoint.Name)
+ return clientV1.NamedContext{
+ Name: contextName,
+ Context: clientV1.Context{
+ AuthInfo: serviceAccountName,
+ Cluster: buildClusterName(endpoint.Name),
+ },
+ }
+}
+
+func buildAuthInfo(serviceAccountName string, bearerToken string) clientV1.NamedAuthInfo {
+ return clientV1.NamedAuthInfo{
+ Name: serviceAccountName,
+ AuthInfo: clientV1.AuthInfo{
+ Token: bearerToken,
+ },
+ }
+}
+
+func writeFileContent(w http.ResponseWriter, r *http.Request, endpoints []portainer.Endpoint, tokenData *portainer.TokenData, config *clientV1.Config) *httperror.HandlerError {
+ filenameSuffix := "kubeconfig"
+ if len(endpoints) == 1 {
+ filenameSuffix = endpoints[0].Name
+ }
+ filenameBase := fmt.Sprintf("%s-%s", tokenData.Username, filenameSuffix)
+
+ if r.Header.Get("Accept") == "text/yaml" {
yaml, err := kcli.GenerateYAML(config)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed to generate Kubeconfig", err}
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.yaml", filenameBase))
- return YAML(w, yaml)
+ return response.YAML(w, yaml)
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase))
return response.JSON(w, config)
}
-
-// 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)
-}
-
-// YAML writes yaml response as string to writer. Returns a pointer to a HandlerError if encoding fails.
-// This could be moved to a more useful place; but that place is most likely not in this project.
-// It should actually go in https://github.com/portainer/libhttp - since that is from where we use response.JSON.
-// We use `data interface{}` as parameter - since im trying to keep it as close to (or the same as) response.JSON method signature:
-// https://github.com/portainer/libhttp/blob/d20481a3da823c619887c440a22fdf4fa8f318f2/response/response.go#L13
-func YAML(rw http.ResponseWriter, data interface{}) *httperror.HandlerError {
- rw.Header().Set("Content-Type", "text/yaml")
-
- strData, ok := data.(string)
- if !ok {
- return &httperror.HandlerError{
- StatusCode: http.StatusInternalServerError,
- Message: "Unable to write YAML response",
- Err: errors.New("failed to convert input to string"),
- }
- }
-
- fmt.Fprint(rw, strData)
-
- return nil
-}
diff --git a/api/http/handler/webhooks/handler.go b/api/http/handler/webhooks/handler.go
index aa5046cfe..e928647d4 100644
--- a/api/http/handler/webhooks/handler.go
+++ b/api/http/handler/webhooks/handler.go
@@ -17,7 +17,7 @@ type Handler struct {
DockerClientFactory *docker.ClientFactory
}
-// NewHandler creates a handler to manage settings operations.
+// NewHandler creates a handler to manage webhooks operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
diff --git a/api/http/security/filter.go b/api/http/security/filter.go
index f784827dd..a2593de80 100644
--- a/api/http/security/filter.go
+++ b/api/http/security/filter.go
@@ -81,7 +81,7 @@ func FilterRegistries(registries []portainer.Registry, user *portainer.User, tea
}
// FilterEndpoints filters environments(endpoints) based on user role and team memberships.
-// Non administrator users only have access to authorized environments(endpoints) (can be inherited via endoint groups).
+// Non administrator users only have access to authorized environments(endpoints) (can be inherited via endpoint groups).
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint {
filteredEndpoints := endpoints
diff --git a/api/internal/endpointutils/endpoint_test.go b/api/internal/endpointutils/endpoint_test.go
index 35793b6e5..8d65552a2 100644
--- a/api/internal/endpointutils/endpoint_test.go
+++ b/api/internal/endpointutils/endpoint_test.go
@@ -45,3 +45,60 @@ func Test_IsKubernetesEndpoint(t *testing.T) {
assert.Equal(t, test.expected, ans)
}
}
+
+func Test_FilterByExcludeIDs(t *testing.T) {
+ tests := []struct {
+ name string
+ inputArray []portainer.Endpoint
+ inputExcludeIDs []portainer.EndpointID
+ asserts func(*testing.T, []portainer.Endpoint)
+ }{
+ {
+ name: "filter endpoints",
+ inputArray: []portainer.Endpoint{
+ {ID: portainer.EndpointID(1)},
+ {ID: portainer.EndpointID(2)},
+ {ID: portainer.EndpointID(3)},
+ {ID: portainer.EndpointID(4)},
+ },
+ inputExcludeIDs: []portainer.EndpointID{
+ portainer.EndpointID(2),
+ portainer.EndpointID(3),
+ },
+ asserts: func(t *testing.T, output []portainer.Endpoint) {
+ assert.Contains(t, output, portainer.Endpoint{ID: portainer.EndpointID(1)})
+ assert.NotContains(t, output, portainer.Endpoint{ID: portainer.EndpointID(2)})
+ assert.NotContains(t, output, portainer.Endpoint{ID: portainer.EndpointID(3)})
+ assert.Contains(t, output, portainer.Endpoint{ID: portainer.EndpointID(4)})
+ },
+ },
+ {
+ name: "empty input",
+ inputArray: []portainer.Endpoint{},
+ inputExcludeIDs: []portainer.EndpointID{
+ portainer.EndpointID(2),
+ },
+ asserts: func(t *testing.T, output []portainer.Endpoint) {
+ assert.Equal(t, 0, len(output))
+ },
+ },
+ {
+ name: "no filter",
+ inputArray: []portainer.Endpoint{
+ {ID: portainer.EndpointID(1)},
+ {ID: portainer.EndpointID(2)},
+ },
+ inputExcludeIDs: []portainer.EndpointID{},
+ asserts: func(t *testing.T, output []portainer.Endpoint) {
+ assert.Equal(t, 2, len(output))
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ output := FilterByExcludeIDs(tt.inputArray, tt.inputExcludeIDs)
+ tt.asserts(t, output)
+ })
+ }
+}
\ No newline at end of file
diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go
index 4c54c4196..537f52315 100644
--- a/api/internal/endpointutils/endpointutils.go
+++ b/api/internal/endpointutils/endpointutils.go
@@ -11,7 +11,7 @@ func IsLocalEndpoint(endpoint *portainer.Endpoint) bool {
return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5
}
-// IsKubernetesEndpoint returns true if this is a kubernetes endpoint
+// IsKubernetesEndpoint returns true if this is a kubernetes environment(endpoint)
func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.KubernetesLocalEnvironment ||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
@@ -29,3 +29,24 @@ func IsDockerEndpoint(endpoint *portainer.Endpoint) bool {
func IsEdgeEndpoint(endpoint *portainer.Endpoint) bool {
return endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
}
+
+// FilterByExcludeIDs receives an environment(endpoint) array and returns a filtered array using an excludeIds param
+func FilterByExcludeIDs(endpoints []portainer.Endpoint, excludeIds []portainer.EndpointID) []portainer.Endpoint {
+ if len(excludeIds) == 0 {
+ return endpoints
+ }
+
+ filteredEndpoints := make([]portainer.Endpoint, 0)
+
+ idsSet := make(map[portainer.EndpointID]bool)
+ for _, id := range excludeIds {
+ idsSet[id] = true
+ }
+
+ for _, endpoint := range endpoints {
+ if !idsSet[endpoint.ID] {
+ filteredEndpoints = append(filteredEndpoints, endpoint)
+ }
+ }
+ return filteredEndpoints
+}
diff --git a/api/kubernetes/cli/kubeconfig.go b/api/kubernetes/cli/kubeconfig.go
deleted file mode 100644
index c4ca71e28..000000000
--- a/api/kubernetes/cli/kubeconfig.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package cli
-
-import (
- "context"
- "fmt"
-
- portainer "github.com/portainer/portainer/api"
- clientV1 "k8s.io/client-go/tools/clientcmd/api/v1"
-)
-
-// GetKubeConfig returns kubeconfig for the current user based on:
-// - portainer server url
-// - portainer user bearer token
-// - portainer token data - which maps to k8s service account
-func (kcl *KubeClient) GetKubeConfig(ctx context.Context, apiServerURL string, bearerToken string, tokenData *portainer.TokenData) (*clientV1.Config, error) {
- serviceAccount, err := kcl.GetServiceAccount(tokenData)
- if err != nil {
- errText := fmt.Sprintf("unable to find serviceaccount associated with user; username=%s", tokenData.Username)
- return nil, fmt.Errorf("%s; err=%w", errText, err)
- }
-
- kubeconfig := generateKubeconfig(apiServerURL, bearerToken, serviceAccount.Name)
-
- return kubeconfig, nil
-}
-
-// generateKubeconfig will generate and return kubeconfig resource - usable by `kubectl` cli
-// which will allow the client to connect directly to k8s server environment(endpoint) via portainer (proxy)
-func generateKubeconfig(apiServerURL, bearerToken, serviceAccountName string) *clientV1.Config {
- const (
- KubeConfigPortainerContext = "portainer-ctx"
- KubeConfigPortainerCluster = "portainer-cluster"
- )
-
- return &clientV1.Config{
- APIVersion: "v1",
- Kind: "Config",
- CurrentContext: KubeConfigPortainerContext,
- Contexts: []clientV1.NamedContext{
- {
- Name: KubeConfigPortainerContext,
- Context: clientV1.Context{
- AuthInfo: serviceAccountName,
- Cluster: KubeConfigPortainerCluster,
- },
- },
- },
- Clusters: []clientV1.NamedCluster{
- {
- Name: KubeConfigPortainerCluster,
- Cluster: clientV1.Cluster{
- Server: apiServerURL,
- InsecureSkipTLSVerify: true,
- },
- },
- },
- AuthInfos: []clientV1.NamedAuthInfo{
- {
- Name: serviceAccountName,
- AuthInfo: clientV1.AuthInfo{
- Token: bearerToken,
- },
- },
- },
- }
-}
diff --git a/api/kubernetes/cli/kubeconfig_test.go b/api/kubernetes/cli/kubeconfig_test.go
deleted file mode 100644
index 2ea64f075..000000000
--- a/api/kubernetes/cli/kubeconfig_test.go
+++ /dev/null
@@ -1,150 +0,0 @@
-package cli
-
-import (
- "context"
- "errors"
- "testing"
-
- portainer "github.com/portainer/portainer/api"
- v1 "k8s.io/api/core/v1"
- k8serrors "k8s.io/apimachinery/pkg/api/errors"
- metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
- kfake "k8s.io/client-go/kubernetes/fake"
-)
-
-func Test_GetKubeConfig(t *testing.T) {
-
- t.Run("returns error if SA non-existent", func(t *testing.T) {
- k := &KubeClient{
- cli: kfake.NewSimpleClientset(),
- instanceID: "test",
- }
-
- tokenData := &portainer.TokenData{
- ID: 1,
- Role: portainer.AdministratorRole,
- Username: portainerClusterAdminServiceAccountName,
- }
-
- _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData)
-
- if err == nil {
- t.Error("GetKubeConfig should fail as service account does not exist")
- }
- if k8sErr := errors.Unwrap(err); !k8serrors.IsNotFound(k8sErr) {
- t.Error("GetKubeConfig should fail with service account not found k8s error")
- }
- })
-
- t.Run("successfully obtains kubeconfig for cluster admin", func(t *testing.T) {
- k := &KubeClient{
- cli: kfake.NewSimpleClientset(),
- instanceID: "test",
- }
-
- tokenData := &portainer.TokenData{
- Role: portainer.AdministratorRole,
- Username: portainerClusterAdminServiceAccountName,
- }
- serviceAccount := &v1.ServiceAccount{
- ObjectMeta: metav1.ObjectMeta{Name: tokenData.Username},
- }
-
- k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(context.Background(), serviceAccount, metav1.CreateOptions{})
- defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(context.Background(), serviceAccount.Name, metav1.DeleteOptions{})
-
- _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData)
-
- if err != nil {
- t.Errorf("GetKubeConfig should succeed; err=%s", err)
- }
- })
-
- t.Run("successfully obtains kubeconfig for standard user", func(t *testing.T) {
- k := &KubeClient{
- cli: kfake.NewSimpleClientset(),
- instanceID: "test",
- }
-
- tokenData := &portainer.TokenData{
- ID: 1,
- Role: portainer.StandardUserRole,
- }
- nonAdminUserName := userServiceAccountName(int(tokenData.ID), k.instanceID)
- serviceAccount := &v1.ServiceAccount{
- ObjectMeta: metav1.ObjectMeta{Name: nonAdminUserName},
- }
-
- k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(context.Background(), serviceAccount, metav1.CreateOptions{})
- defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(context.Background(), serviceAccount.Name, metav1.DeleteOptions{})
-
- _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData)
-
- if err != nil {
- t.Errorf("GetKubeConfig should succeed; err=%s", err)
- }
- })
-}
-
-func Test_generateKubeconfig(t *testing.T) {
- apiServerURL, bearerToken, serviceAccountName := "localhost", "test-token", "test-user"
-
- t.Run("generates Config resource kind", func(t *testing.T) {
- config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
- want := "Config"
- if config.Kind != want {
- t.Errorf("generateKubeconfig resource kind should be %s", want)
- }
- })
-
- t.Run("generates v1 version", func(t *testing.T) {
- config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
- want := "v1"
- if config.APIVersion != want {
- t.Errorf("generateKubeconfig api version should be %s", want)
- }
- })
-
- t.Run("generates single entry context cluster and authinfo", func(t *testing.T) {
- config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
- if len(config.Contexts) != 1 {
- t.Error("generateKubeconfig should generate single context configuration")
- }
- if len(config.Clusters) != 1 {
- t.Error("generateKubeconfig should generate single cluster configuration")
- }
- if len(config.AuthInfos) != 1 {
- t.Error("generateKubeconfig should generate single user configuration")
- }
- })
-
- t.Run("sets default context appropriately", func(t *testing.T) {
- config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
- want := "portainer-ctx"
- if config.CurrentContext != want {
- t.Errorf("generateKubeconfig set cluster to be %s", want)
- }
- })
-
- t.Run("generates cluster with InsecureSkipTLSVerify to be set to true", func(t *testing.T) {
- config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
- if config.Clusters[0].Cluster.InsecureSkipTLSVerify != true {
- t.Error("generateKubeconfig default cluster InsecureSkipTLSVerify should be true")
- }
- })
-
- t.Run("should contain passed in value", func(t *testing.T) {
- config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName)
- if config.Clusters[0].Cluster.Server != apiServerURL {
- t.Errorf("generateKubeconfig default cluster server url should be %s", apiServerURL)
- }
-
- if config.AuthInfos[0].Name != serviceAccountName {
- t.Errorf("generateKubeconfig default authinfo name should be %s", serviceAccountName)
- }
-
- if config.AuthInfos[0].AuthInfo.Token != bearerToken {
- t.Errorf("generateKubeconfig default authinfo user token should be %s", bearerToken)
- }
- })
-}
diff --git a/api/portainer.go b/api/portainer.go
index f951866f3..e189d7b79 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -7,7 +7,6 @@ import (
gittypes "github.com/portainer/portainer/api/git/types"
v1 "k8s.io/api/core/v1"
- clientV1 "k8s.io/client-go/tools/clientcmd/api/v1"
)
type (
@@ -1286,7 +1285,6 @@ type (
DeleteRegistrySecret(registry *Registry, namespace string) error
CreateRegistrySecret(registry *Registry, namespace string) error
IsRegistrySecret(namespace, secretName string) (bool, error)
- GetKubeConfig(ctx context.Context, apiServerURL string, bearerToken string, tokenData *TokenData) (*clientV1.Config, error)
ToggleSystemState(namespace string, isSystem bool) error
}
diff --git a/app/assets/css/app.css b/app/assets/css/app.css
index e84e4cd53..700e887df 100644
--- a/app/assets/css/app.css
+++ b/app/assets/css/app.css
@@ -440,10 +440,25 @@ a[ng-click] {
display: none;
}
+.bootbox-form .visible {
+ position: initial !important;
+ display: initial !important;
+ margin-left: 10px !important;
+ margin-top: -2px !important;
+}
+
.bootbox-form label {
padding-left: 0;
}
+.bootbox-checkbox-list {
+ max-height: 200px;
+ overflow-y: scroll;
+ background-color: var(--white-color);
+ border: 1px solid var(--grey-48);
+ border-radius: 4px;
+}
+
.small-select {
display: inline-block;
padding: 0px 6px;
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
deleted file mode 100644
index 55a8ec548..000000000
--- a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.controller.js
+++ /dev/null
@@ -1,45 +0,0 @@
-export default class KubeConfigController {
- /* @ngInject */
- constructor($async, $window, KubernetesConfigService, SettingsService) {
- this.$async = $async;
- this.$window = $window;
- this.KubernetesConfigService = KubernetesConfigService;
- this.SettingsService = SettingsService;
- }
-
- async downloadKubeconfig() {
- await this.KubernetesConfigService.downloadConfig();
- }
-
- async expiryHoverMessage() {
- const settings = await this.SettingsService.publicSettings();
- const expiryDays = settings.KubeconfigExpiry;
- switch (expiryDays) {
- case '0':
- this.state.expiryDays = 'not expire';
- break;
- case '24h':
- this.state.expiryDays = 'expire in 1 day';
- break;
- case '168h':
- this.state.expiryDays = 'expire in 7 days';
- break;
- case '720h':
- this.state.expiryDays = 'expire in 30 days';
- break;
- case '8640h':
- this.state.expiryDays = 'expire in 1 year';
- break;
- }
- }
-
- $onInit() {
- return this.$async(async () => {
- this.state = {
- isHTTPS: this.$window.location.protocol === 'https:',
- expiryDays: '',
- };
- await this.expiryHoverMessage();
- });
- }
-}
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
deleted file mode 100644
index d2c2c4cf4..000000000
--- a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.html
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
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
deleted file mode 100644
index 1732e50f2..000000000
--- a/app/kubernetes/components/kube-config-download-button/kube-config-download-button.js
+++ /dev/null
@@ -1,7 +0,0 @@
-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.html b/app/kubernetes/components/kubectl-shell/kubectl-shell.html
index 2bd45a7fc..cf7ef80fb 100644
--- a/app/kubernetes/components/kubectl-shell/kubectl-shell.html
+++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.html
@@ -11,8 +11,6 @@
kubectl shell
-
' + options.message + '
'); - box.find('.bootbox-input-checkbox').prop('checked', optionToggled); + box.find('.bootbox-input-checkbox').prop('checked', options.optionToggled); + if (options.showCheck) { + box.find('.bootbox-input-checkbox').addClass('visible'); + } } service.confirmAccessControlUpdate = function (callback) { @@ -231,28 +234,25 @@ angular.module('portainer.app').factory('ModalService', [ }; service.confirmContainerRecreation = function (callback) { - customPrompt( - { - title: 'Are you sure?', - message: - "You're about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.", - inputType: 'checkbox', - inputOptions: [ - { - text: 'Pull latest image', - value: '1', - }, - ], - buttons: { - confirm: { - label: 'Recreate', - className: 'btn-danger', - }, + customCheckboxPrompt({ + title: 'Are you sure?', + message: + "You're about to re-create this container, any non-persisted data will be lost. This container will be removed and another one will be created using the same configuration.", + inputOptions: [ + { + text: 'Pull latest image', + value: '1', + }, + ], + buttons: { + confirm: { + label: 'Recreate', + className: 'btn-danger', }, - callback: callback, }, - false - ); + callback: callback, + optionToggled: false, + }); }; service.confirmEndpointSnapshot = function (callback) { @@ -285,27 +285,24 @@ angular.module('portainer.app').factory('ModalService', [ service.confirmServiceForceUpdate = function (message, callback) { message = $sanitize(message); - customPrompt( - { - title: 'Are you sure ?', - message: message, - inputType: 'checkbox', - inputOptions: [ - { - text: 'Pull latest image version', - value: '1', - }, - ], - buttons: { - confirm: { - label: 'Update', - className: 'btn-primary', - }, + customCheckboxPrompt({ + title: 'Are you sure ?', + message: message, + inputOptions: [ + { + text: 'Pull latest image version', + value: '1', + }, + ], + buttons: { + confirm: { + label: 'Update', + className: 'btn-primary', }, - callback: callback, }, - false - ); + callback: callback, + optionToggled: false, + }); }; service.selectRegistry = function (options) { @@ -319,6 +316,24 @@ angular.module('portainer.app').factory('ModalService', [ applyBoxCSS(box); }; + service.confirmKubeconfigSelection = function (options, expiryMessage, callback) { + const message = 'Select the kubernetes environment(s) to add to the kubeconfig file.' + expiryMessage; + customCheckboxPrompt({ + title: 'Download kubeconfig file', + message: $sanitize(message), + inputOptions: options, + buttons: { + confirm: { + label: 'Download file', + className: 'btn-primary', + }, + }, + callback: callback, + optionToggled: true, + showCheck: true, + }); + }; + return service; }, ]);