Compare commits

...

116 Commits

Author SHA1 Message Date
Ali 90a160e83f Revert "fix(gitops): correct commit hash link [EE-6346] (#10722)" (#10788)
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
This reverts commit 83cd5d9b2f.
2023-12-07 17:05:17 +13:00
Matt Hook f58aa8cd5b fix(rollback): reversed rollback code from 2.19.4 [EE-6435] (#10787)
* Revert "fix(rollback): reimplement rollback feature [EE-6367] (#10720)"

This reverts commit 93124f75cf.

* Revert "fix(backups): fix rollback feature [EE-6367] (#10691) (#10703)"

This reverts commit 0fce4c98a0.
2023-12-07 16:42:41 +13:00
Ali b9ff7b6f32 Revert "fix toast error (#10724)" (#10786)
This reverts commit 6d0aefd7bb.
2023-12-07 16:40:07 +13:00
Prabhat Khera 5761342069 Revert "fix(kube): configmaps and secrets from envFrom in the app detail screen [EE-6282] (#10741)" (#10785)
This reverts commit ce4b6dc586.
2023-12-07 16:34:03 +13:00
Ali d8480a0db6 Revert "fix(app): shift external to the top [EE-6392] (#10753)" (#10784)
This reverts commit 0f89ade048.
2023-12-07 16:33:46 +13:00
Ali 03a4f1227e Revert "fix(gitops): clean trailing slash [EE-6346] (#10778)" (#10781)
This reverts commit e78519f492.
2023-12-07 16:33:00 +13:00
Ali ee6c3f958f Revert "fix(app): update sliders when limits are known [EE-5933] (#10769)" (#10782)
This reverts commit f80501b505.
2023-12-07 16:32:46 +13:00
Ali e78519f492 fix(gitops): clean trailing slash [EE-6346] (#10778)
Co-authored-by: testa113 <testa113>
2023-12-07 13:43:05 +13:00
Ali f80501b505 fix(app): update sliders when limits are known [EE-5933] (#10769)
Co-authored-by: testa113 <testa113>
2023-12-07 12:36:50 +13:00
Ali 0f89ade048 fix(app): shift external to the top [EE-6392] (#10753)
Co-authored-by: testa113 <testa113>
2023-12-07 12:11:19 +13:00
Matt Hook 6d0aefd7bb fix toast error (#10724) 2023-12-07 12:01:21 +13:00
Ali 6aa0a1ffa9 fix(gitops): correct commit hash link [EE-6346] (#10752)
Co-authored-by: testa113 <testa113>
2023-12-06 16:31:24 +13:00
Prabhat Khera ce4b6dc586 fix(kube): configmaps and secrets from envFrom in the app detail screen [EE-6282] (#10741)
* fix configmaps and secrets from envFrom

* adress review comments
2023-12-06 16:02:30 +13:00
Chaim Lev-Ari 4410394ede Revert "fix(images): sort by tags [EE-6410]" (#10754) 2023-12-05 05:28:55 +02:00
Ali e5eb354d7b Revert "fix(app): shift external to the top [EE-6392] (#10718)" (#10749)
This reverts commit b051629f13.
2023-12-05 09:16:40 +13:00
Ali b660feafbf Revert "fix(gitops): correct commit hash link [EE-6346] (#10722)" (#10750)
This reverts commit 83cd5d9b2f.
2023-12-05 09:16:22 +13:00
Chaim Lev-Ari b75f0e561b fix(images): sort by tags [EE-6410] (#10739) 2023-12-04 08:47:19 +02:00
Ali 83cd5d9b2f fix(gitops): correct commit hash link [EE-6346] (#10722) 2023-12-04 11:18:05 +13:00
Ali b051629f13 fix(app): shift external to the top [EE-6392] (#10718)
Co-authored-by: testa113 <testa113>
2023-12-04 07:43:50 +13:00
andres-portainer 32da62cdc8 feat(version): bump to v2.19.4 EE-6407 (#10730) 2023-12-01 11:18:52 -03:00
Matt Hook 93124f75cf fix(rollback): reimplement rollback feature [EE-6367] (#10720) 2023-12-01 13:02:37 +13:00
Matt Hook 0fce4c98a0 fix(backups): fix rollback feature [EE-6367] (#10691) (#10703) 2023-12-01 10:03:31 +13:00
Chaim Lev-Ari 5dad419f60 fix(swarm/services): avoid sending credSpec object when empty [EE-6322] (#10636)
Co-authored-by: matias-portainer <104775949+matias-portainer@users.noreply.github.com>
2023-11-26 07:01:58 +02:00
andres-portainer cd9ad97235 fix(gitops): change the condition that checks if the environment is online EE-6321 (#10664)
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
2023-11-20 23:59:22 -03:00
Prabhat Khera 67308838fd version bump to 2.19.3 (#10645) 2023-11-17 09:51:21 +13:00
andres-portainer 3360576e07 fix(gitops): handle the local environment in isEnvironmentOnline() EE-6321 (#10632) 2023-11-16 09:40:24 -03:00
yi-portainer c5a51a9fb7 * remove line break 2023-11-13 14:17:00 +13:00
Prabhat Khera 280a2fe093 fix(kubernetes): clear user token from kube token cache on logout + update cluster rolebindings for user on change of team/user authorization [EE-6298] (#10603)
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
2023-11-10 10:06:50 +13:00
Prabhat Khera ddd30dd17a fix(app): disable deploy when there are no namespaces [EE-6295] (#10608)
* fix(app): hide services section when there are no namespaces [EE-6295] (#10588)

Co-authored-by: testa113 <testa113>

* fix(app): disable deploy when there are no namespaces [EE-6295] (#10606)

Co-authored-by: testa113 <testa113>

---------

Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2023-11-09 20:02:02 +13:00
Chaim Lev-Ari 15df3277ca fix(edge/updates): hide sidebar item when disabled [EE-6294] (#10581) 2023-11-05 13:41:16 +02:00
Prabhat Khera 47845523a5 fix(users): hide admin users for non admins from user list API [EE-6290] (#10579)
* hide admin users for non admins from user list API

* address review comments
2023-11-02 16:08:22 +13:00
LP B 2af2827cba fix(app/logout): always perform API logout + make API logout route public [EE-6198] (#10447)
* feat(api/logout): make logout route public

* feat(app/logout): always perform API logout on /logout redirect

* fix(app): send a logout event to AngularJS when axios hits a 401
2023-10-27 14:02:18 +02:00
andres-portainer 8f4f5fddcc fix(gitops): only attempt to redeploy when the environment appears to be online EE-6182 (#10463) 2023-10-24 11:20:54 -03:00
Oscar Zhou 8b7436e4d0 fix(edge): introduce pause and rollback status [EE-5992] (#10466) 2023-10-19 11:25:43 +13:00
Chaim Lev-Ari 5b8a0471e9 fix(edge/updates): allow group search [EE-6179] (#10407) 2023-10-12 08:30:25 +03:00
Oscar Zhou 0b9e5c564f feat(fs): support to update stack file by version (#10417) 2023-10-06 09:08:34 +13:00
Chaim Lev-Ari 1ed2c8b346 chore(deps): upgrade golangci [EE-5685] (#10413) 2023-10-05 10:31:48 +03:00
Ali c43f771a88 fix(teasers): add teaser message full stops [EE-6035] (#10402) 2023-10-02 21:22:52 +01:00
Matt Hook 8755a22fee add support for forward proxy (#10334) 2023-09-29 12:54:53 +13:00
cmeng 8e3c47719e fix(websocket): abort websocket when logout EE-6058 (#10371) 2023-09-29 12:13:18 +13:00
Matt Hook 157393c965 support proxy for helm repo validation (#10359) 2023-09-29 11:37:30 +13:00
Ali 6163aaa577 fix(teasers): updated muted styles from qa feedback [EE-6035] (#10391)
* fix(teasers): updated muted styles from qa feedback [EE-6035]
2023-09-28 11:32:48 +01:00
Prabhat Khera d9a3b98275 fix team lead access to view user names (#10389) 2023-09-28 12:40:58 +13:00
Chaim Lev-Ari c0c689c2af fix(docker/services): show cred spec configs [EE-5276] (#10082) 2023-09-27 07:57:43 +03:00
Chaim Lev-Ari 4efe66d33f fix(stacks): mark stack as start after autoupdate [EE-6165] (#10375) 2023-09-27 07:53:36 +03:00
Prabhat Khera 80415ab68f fix(authorization): disable user list api call if not authorised [EE-5825] (#10380)
* fix tests
* disable user list api call if not authorised
* fix lint issues
2023-09-27 10:12:40 +13:00
Chaim Lev-Ari fa087f0bb9 style(kubernetes): disable autoFocus warning [EE-5752] (#10367) 2023-09-25 20:13:35 +03:00
LP B 3994d74c71 feat(app/home): tooltip aside edge agent version on mismatch with Portainer version (#10288)
* feat(app/home): tooltip aside edge agent version on mismatch with Portainer version

* fix(app/home): split agent and edge version display + display warning for agents before 2.15
2023-09-25 11:56:03 +02:00
Matt Hook 537585e78c chore: bump version 2.19.2 [EE-6153] (#10370) 2023-09-25 14:26:54 +13:00
Prabhat Khera 78202cfb25 fix(permissions): non admin access to view users [EE-5825] (#10353)
* fix(security): added restrictions to see user names [EE-5825]
2023-09-25 09:08:37 +13:00
Ali b60f32a25b fix(be-teaser): mute styles [EE-6035] (#10350) 2023-09-24 19:56:18 +01:00
Matt Hook 8f42ba0254 allow libhelm to use forward proxy (#10330)
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
2023-09-19 18:07:41 +12:00
Chaim Lev-Ari 6f81fcc169 fix(api): restore deleted apis [EE-6090] (#10266) 2023-09-19 13:44:55 +12:00
Oscar Zhou 46949508a4 fix(db/migration): avoid fatal error from being overwritten (#10317) 2023-09-18 14:32:57 +12:00
Matt Hook 034157be9a improved user update validation (#10322) 2023-09-18 12:29:12 +12:00
Dakota Walsh 011a1ce720 fix(kubernetes): add prefix only when needed EE-6068 (#3918) (#10311) 2023-09-15 07:59:37 +12:00
Prabhat Khera a4922eb693 fix(docker): revert PR #10297 and #10242 [EE-5825] (#10308)
* revert PR #10297 and #10242
2023-09-14 15:51:19 +12:00
cmeng 8c77c5ffbe fix(backup): add chisel key to backup EE-6105 (#10282) 2023-09-13 09:01:31 +12:00
andres-portainer a062c36ff5 fix(gitops): avoid cancelling the auto updates for any error EE-5604 (#10295) 2023-09-12 17:52:52 -03:00
Oscar Zhou 122fd835dc fix(db/init): check server version and db schema version (#10299) 2023-09-12 15:55:15 +12:00
Prabhat Khera f7ff07833f fix(security): added restrictions to see user names [EE-5825] (#10297)
* fix(security): added restrictions to see user names [EE-5825]

* use pluralize method
2023-09-12 13:15:29 +12:00
matias-portainer 8010167006 fix(authentication): allow nested whitespaces on AD OU names EE-5206 (#10261) 2023-09-07 11:03:04 -03:00
Matt Hook 4c79e9ef6b prevent regular users changing their username (#10246) 2023-09-06 08:44:24 +12:00
Matt Hook 88ea0cb64f non-admins must supply existing passwd when changing passwd (#10248) 2023-09-06 07:53:31 +12:00
Dakota Walsh 5f50f20a7a fix(security): block user access policies for non admins EE-5826 (#10244) 2023-09-05 09:18:17 +12:00
Dakota Walsh bbc26682dd fix(security): block non-admins from user info listing EE-5825 (#10242) 2023-09-05 09:17:10 +12:00
Matt Hook f74704fca4 Bump 2.19.0 release to 2.19.1 (#10237) 2023-09-04 12:06:47 +12:00
Chaim Lev-Ari 9b52bd50d9 fix(ui/switch): reduce label size [EE-3803] (#10018) 2023-09-03 10:26:33 +01:00
Prabhat Khera 04073f0d1f add tls options to the tls dropdown (#10222) 2023-09-01 10:42:26 +12:00
Ali c035e4a778 fix(k8sconfigure): make ingress restrict be only [EE-6062] (#10217)
Co-authored-by: testa113 <testa113>
2023-09-01 06:11:43 +12:00
Prabhat Khera 7abed624d9 fix showing default ns for ingresses on edit (#10196)
Test / test-client (push) Has been cancelled
Test / test-server (push) Has been cancelled
2023-08-29 15:12:40 +12:00
cmeng 1e24451cc9 fix(relative-path): not deploy git stack via unpacker EE-6043 (#10194) 2023-08-29 11:48:57 +12:00
Prabhat Khera adcfcdd6e3 fix ECR registry token refresh (#10190) 2023-08-29 10:32:47 +12:00
Dakota Walsh e6e3810fa4 fix(registry): ecr secret fix [EE-5673] (#10108) 2023-08-28 08:38:40 +12:00
andres-portainer 5e20854f86 fix(docker): use version negotiation for the Docker client EE-5797 (#9251) 2023-08-22 17:59:46 -03:00
Chaim Lev-Ari 69f3670ce5 fix(ui/datatables): sync page count with filtering [EE-5890] (#10009) 2023-08-22 09:36:27 +03:00
Chaim Lev-Ari f24555c6c9 feat(ui): add confirmation to delete actions [EE-4612] (#10002) 2023-08-19 19:18:58 +03:00
cmeng 1c79f10ae8 fix(migrator): prevent duplicated migration EE-5777 (#10076) 2023-08-18 21:40:42 +12:00
Chaim Lev-Ari dc76900a28 feat(edge/stacks): reload edge stacks from server [EE-5970] (#10062) 2023-08-17 14:09:43 +03:00
cmeng 74eeb9da06 fix(datatable): image page not loading image list EE-5978 (#10070) 2023-08-17 09:53:25 +12:00
Chaim Lev-Ari 77120abf33 fix(edge/groups): filter selected environments [EE-5891] (#10016) 2023-08-16 12:24:43 +03:00
Chaim Lev-Ari dffdf6783c fix(edge/stacks): show pending envs [EE-5913] (#10051) 2023-08-16 10:22:37 +03:00
Ali 55236129ea fix(ingress): empty initial selection + fixes [EE-5852] (#10067)
Co-authored-by: testa113 <testa113>
2023-08-16 18:07:49 +12:00
Ali d54dd47b21 fix(environments): fix env table [EE-5971] (#10060)
Co-authored-by: testa113 <testa113>
2023-08-16 13:21:16 +12:00
Prabhat Khera 360969c93e fix edit namespace resource quota issue (#10063) 2023-08-16 10:24:55 +12:00
Chaim Lev-Ari 3ea6d2b9d9 feat(edge/configs): add context help [EE-5963] (#10054) 2023-08-15 18:46:53 +03:00
Chaim Lev-Ari 577a36e04e fix(edge/devices): search waiting room devices [EE-5895] (#10015) 2023-08-15 06:05:14 +03:00
matias-portainer 6aa978d5e9 fix(authentication): allow whitespaces when loading AD OU name EE-5206 (#9978) 2023-08-14 12:18:21 -03:00
matias-portainer 0b8d72bfd4 fix(edge/stacks): add pagination to environments list EE-5908 (#10043) 2023-08-14 12:16:49 -03:00
Chaim Lev-Ari faa1387110 feat(edge/stacks): info for old agent status [EE-5792] (#10012) 2023-08-14 16:04:20 +03:00
Ali f5cc245c63 fix(app): use correct withCurrentUser wrapper [EE-5928] (#10041)
Co-authored-by: testa113 <testa113>
2023-08-14 16:53:36 +12:00
cmeng 20c6965ce0 fix(stack): fail to start swarm stack with private image EE-4797 (#10046) 2023-08-14 16:13:15 +12:00
Ali 53679f9381 fix(microk8s): PO ui fixes [EE-5900] (#10032)
Co-authored-by: testa113 <testa113>
2023-08-14 12:35:03 +12:00
andres-portainer e1951baac0 fix(unpacker): implement unpacker error parsing EE-5779 (#10006) 2023-08-10 10:26:09 -03:00
Oscar Zhou 187ec2aa9a fix(stagger): introduce stack version into DeploymentInfo struct (#10027) 2023-08-10 11:58:47 +12:00
matias-portainer 125db4f0de fix(edge/stacks): fix UI issues EE-5844 (#10022) 2023-08-09 10:09:15 -03:00
cmeng 59be96e9e8 fix(edge-stack): detaching swarm stack from git repository EE-5812 (#9997) 2023-08-07 10:33:08 +12:00
Oscar Zhou d3420f39c1 fix(react/datatable): override getColumnCanGlobalFilter method (#9991) 2023-08-07 10:30:31 +12:00
cmeng 004c86578d fix(edge-stack): detaching from git repository EE-5812 (#9988) 2023-08-04 15:17:51 +12:00
cmeng b3d404b378 fix(registry): registry login failure for regular stack EE-5832 (#9985) 2023-08-04 15:17:04 +12:00
Ali 82faf20c68 fix(app): update summary with ingresses [EE-5847] (#9974)
Co-authored-by: testa113 <testa113>
2023-08-04 13:48:18 +12:00
Chaim Lev-Ari 18e40cd973 fix(home): empty default sort [EE-5822] (#9950) 2023-08-03 16:21:00 -03:00
Chaim Lev-Ari 9c4d512a4c fix(docker/images): show empty size cell [EE-5823] (#9953) 2023-08-03 16:19:50 -03:00
Ali ce5c38f841 fix(ingress): ingress ui feedback [EE-5852] (#9983)
Co-authored-by: testa113 <testa113>
2023-08-03 23:03:07 +12:00
cmeng dbb79a181e fix(edge-stack): unable to edit edge stack EE-5845 (#9980) 2023-08-03 17:20:56 +12:00
matias-portainer 2177c27dc4 fix(endpoints): fix nil pointer dereference EE-5843 (#9970) 2023-08-02 11:06:43 -03:00
Matt Hook bfdd72d644 show kube icon for custom template (#9967) 2023-08-02 09:43:39 +12:00
Ali 998bf481f7 fix(ingress): loading and ui fixes [EE-5132] (#9960) 2023-08-01 19:31:29 +12:00
Matt Hook c97ef40cc0 bump compose to 2.20.2 (#9965) 2023-08-01 12:27:28 +12:00
Ali cbae7bdf82 fix(app): improve perceived ingress load time [EE-5805] (#9948)
Co-authored-by: testa113 <testa113>
2023-07-31 20:18:52 +12:00
cmeng f4ec4d6175 fix(stack): update gitops updates tooltip EE-5827 (#9961) 2023-07-31 18:46:04 +12:00
Prabhat Khera ec39d5a88e upgrade helm binary to v3.12.2 (#9264) 2023-07-28 15:06:53 +12:00
Matt Hook d0d9c2a93b post po review changes (#9265) 2023-07-28 07:53:21 +12:00
Ali 73010efd8d fix(UI): PO review tweaks [EE-5776] (#9268)
Co-authored-by: testa113 <testa113>
2023-07-28 07:50:46 +12:00
Dakota Walsh 88de50649f fix(metrics): node chart race condition EE-5447 (#9252) 2023-07-27 11:46:46 +12:00
Dakota Walsh fc89066846 fix(jwt): replace deprecated gorilla/securecookie [EE-5153] (#9262) 2023-07-27 09:44:43 +12:00
237 changed files with 3580 additions and 1959 deletions
+1 -1
View File
@@ -41,6 +41,6 @@ jobs:
- name: GolangCI-Lint - name: GolangCI-Lint
uses: golangci/golangci-lint-action@v3 uses: golangci/golangci-lint-action@v3
with: with:
version: v1.52.2 version: v1.54.1
working-directory: api working-directory: api
args: --timeout=10m -c .golangci.yaml args: --timeout=10m -c .golangci.yaml
+11 -11
View File
@@ -10,17 +10,17 @@ linters:
- exportloopref - exportloopref
linters-settings: linters-settings:
depguard: depguard:
list-type: denylist rules:
include-go-root: true main:
packages: deny:
- github.com/sirupsen/logrus - pkg: 'github.com/sirupsen/logrus'
- golang.org/x/exp desc: 'logging is allowed only by github.com/rs/zerolog'
packages-with-error-message: - pkg: 'golang.org/x/exp'
- github.com/sirupsen/logrus: 'logging is allowed only by github.com/rs/zerolog' desc: 'exp is not allowed'
ignore-file-rules: files:
- '**/*_test.go' - '!**/*_test.go'
- '**/base.go' - '!**/base.go'
- '**/base_tx.go' - '!**/base_tx.go'
# errorlint is causing a typecheck error for some reason. The go compiler will report these # errorlint is causing a typecheck error for some reason. The go compiler will report these
# anyway, so ignore them from the linter # anyway, so ignore them from the linter
-13
View File
@@ -1,9 +1,6 @@
package apikey package apikey
import ( import (
"crypto/rand"
"io"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
@@ -18,13 +15,3 @@ type APIKeyService interface {
DeleteAPIKey(apiKeyID portainer.APIKeyID) error DeleteAPIKey(apiKeyID portainer.APIKeyID) error
InvalidateUserKeyCache(userId portainer.UserID) bool InvalidateUserKeyCache(userId portainer.UserID) bool
} }
// generateRandomKey generates a random key of specified length
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
func generateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}
+3 -2
View File
@@ -3,6 +3,7 @@ package apikey
import ( import (
"testing" "testing"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -33,7 +34,7 @@ func Test_generateRandomKey(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := generateRandomKey(tt.wantLenth) got := securecookie.GenerateRandomKey(tt.wantLenth)
is.Equal(tt.wantLenth, len(got)) is.Equal(tt.wantLenth, len(got))
}) })
} }
@@ -41,7 +42,7 @@ func Test_generateRandomKey(t *testing.T) {
t.Run("Generated keys are unique", func(t *testing.T) { t.Run("Generated keys are unique", func(t *testing.T) {
keys := make(map[string]bool) keys := make(map[string]bool)
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
key := generateRandomKey(8) key := securecookie.GenerateRandomKey(8)
_, ok := keys[string(key)] _, ok := keys[string(key)]
is.False(ok) is.False(ok)
keys[string(key)] = true keys[string(key)] = true
+2 -1
View File
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/securecookie"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -39,7 +40,7 @@ func (a *apiKeyService) HashRaw(rawKey string) []byte {
// GenerateApiKey generates a raw API key for a user (for one-time display). // GenerateApiKey generates a raw API key for a user (for one-time display).
// The generated API key is stored in the cache and database. // The generated API key is stored in the cache and database.
func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) { func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) {
randKey := generateRandomKey(32) randKey := securecookie.GenerateRandomKey(32)
encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey) encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey)
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey
+1
View File
@@ -30,6 +30,7 @@ var filesToBackup = []string{
"portainer.key", "portainer.key",
"portainer.pub", "portainer.pub",
"tls", "tls",
"chisel",
} }
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file. // Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
+5 -4
View File
@@ -75,10 +75,11 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug(). log.Debug().
Int("endpoint_id", int(endpointID)). Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()). Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("start") Msg("KeepTunnelAlive: start")
maxAliveTicker := time.NewTicker(maxAlive) maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop() defer maxAliveTicker.Stop()
pingTicker := time.NewTicker(tunnelCleanupInterval) pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop() defer pingTicker.Stop()
@@ -91,13 +92,13 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug(). log.Debug().
Int("endpoint_id", int(endpointID)). Int("endpoint_id", int(endpointID)).
Err(err). Err(err).
Msg("ping agent") Msg("KeepTunnelAlive: ping agent")
} }
case <-maxAliveTicker.C: case <-maxAliveTicker.C:
log.Debug(). log.Debug().
Int("endpoint_id", int(endpointID)). Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()). Float64("timeout_minutes", maxAlive.Minutes()).
Msg("tunnel keep alive timeout") Msg("KeepTunnelAlive: tunnel keep alive timeout")
return return
case <-ctx.Done(): case <-ctx.Done():
@@ -105,7 +106,7 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
log.Debug(). log.Debug().
Int("endpoint_id", int(endpointID)). Int("endpoint_id", int(endpointID)).
Err(err). Err(err).
Msg("tunnel stop") Msg("KeepTunnelAlive: tunnel stop")
return return
} }
+20
View File
@@ -20,6 +20,7 @@ import (
"github.com/portainer/portainer/api/database/models" "github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/datastore/migrator"
"github.com/portainer/portainer/api/demo" "github.com/portainer/portainer/api/demo"
"github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client" dockerclient "github.com/portainer/portainer/api/docker/client"
@@ -119,11 +120,15 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
log.Fatal().Err(err).Msg("failed generating instance id") log.Fatal().Err(err).Msg("failed generating instance id")
} }
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
// from MigrateData // from MigrateData
v := models.Version{ v := models.Version{
SchemaVersion: portainer.APIVersion, SchemaVersion: portainer.APIVersion,
Edition: int(portainer.PortainerCE), Edition: int(portainer.PortainerCE),
InstanceID: instanceId.String(), InstanceID: instanceId.String(),
MigratorCount: migratorCount,
} }
store.VersionService.UpdateVersion(&v) store.VersionService.UpdateVersion(&v)
@@ -152,6 +157,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
return store return store
} }
// checkDBSchemaServerVersionMatch checks if the server version matches the db scehma version
func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersion string, serverEdition int) bool {
v, err := dbStore.Version().Version()
if err != nil {
return false
}
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
}
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager { func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager) composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
if err != nil { if err != nil {
@@ -383,6 +398,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
log.Fatal().Err(err).Msg("") log.Fatal().Err(err).Msg("")
} }
// check if the db schema version matches with server version
if !checkDBSchemaServerVersionMatch(dataStore, portainer.APIVersion, int(portainer.Edition)) {
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
}
instanceID, err := dataStore.Version().InstanceID() instanceID, err := dataStore.Version().InstanceID()
if err != nil { if err != nil {
log.Fatal().Err(err).Msg("failed getting instance id") log.Fatal().Err(err).Msg("failed getting instance id")
+18
View File
@@ -5,6 +5,7 @@ import (
"time" "time"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
) )
// BucketName represents the name of the bucket where this service stores data. // BucketName represents the name of the bucket where this service stores data.
@@ -144,6 +145,23 @@ func (service *Service) Create(endpoint *portainer.Endpoint) error {
}) })
} }
func (service *Service) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
return endpoints, service.connection.GetAll(
BucketName,
&portainer.Endpoint{},
dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool {
for t := range e.TeamAccessPolicies {
if t == teamID {
return true
}
}
return false
}),
)
}
// GetNextIdentifier returns the next identifier for an environment(endpoint). // GetNextIdentifier returns the next identifier for an environment(endpoint).
func (service *Service) GetNextIdentifier() int { func (service *Service) GetNextIdentifier() int {
var identifier int var identifier int
+17
View File
@@ -122,6 +122,23 @@ func (service ServiceTx) Create(endpoint *portainer.Endpoint) error {
return nil return nil
} }
func (service ServiceTx) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
return endpoints, service.tx.GetAll(
BucketName,
&portainer.Endpoint{},
dataservices.FilterFn(&endpoints, func(e portainer.Endpoint) bool {
for t := range e.TeamAccessPolicies {
if t == teamID {
return true
}
}
return false
}),
)
}
// GetNextIdentifier returns the next identifier for an environment(endpoint). // GetNextIdentifier returns the next identifier for an environment(endpoint).
func (service ServiceTx) GetNextIdentifier() int { func (service ServiceTx) GetNextIdentifier() int {
return service.tx.GetNextIdentifier(BucketName) return service.tx.GetNextIdentifier(BucketName)
+1
View File
@@ -89,6 +89,7 @@ type (
EndpointService interface { EndpointService interface {
Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error)
EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool) EndpointIDByEdgeID(edgeID string) (portainer.EndpointID, bool)
EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error)
Heartbeat(endpointID portainer.EndpointID) (int64, bool) Heartbeat(endpointID portainer.EndpointID) (int64, bool)
UpdateHeartbeat(endpointID portainer.EndpointID) UpdateHeartbeat(endpointID portainer.EndpointID)
Endpoints() ([]portainer.Endpoint, error) Endpoints() ([]portainer.Endpoint, error)
+4 -4
View File
@@ -50,10 +50,10 @@ func (store *Store) MigrateData() error {
if err != nil { if err != nil {
err = errors.Wrap(err, "failed to migrate database") err = errors.Wrap(err, "failed to migrate database")
log.Warn().Msg("migration failed, restoring database to previous version") log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath}) restorErr := store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
if err != nil { if restorErr != nil {
return errors.Wrap(err, "failed to restore database") return errors.Wrap(restorErr, "failed to restore database")
} }
log.Info().Msg("database restored to previous version") log.Info().Msg("database restored to previous version")
+2 -2
View File
@@ -1,7 +1,7 @@
package datastore package datastore
import ( import (
portaineree "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models" "github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
) )
@@ -72,7 +72,7 @@ func dbVersionToSemanticVersion(dbVersion int) string {
func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) { func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
// Very old versions of portainer did not have a version bucket, lets set some defaults // Very old versions of portainer did not have a version bucket, lets set some defaults
dbVersion := 24 dbVersion := 24
edition := int(portaineree.PortainerCE) edition := int(portainer.PortainerCE)
instanceId := "" instanceId := ""
// If we already have a version key, we don't need to migrate // If we already have a version key, we don't need to migrate
+10 -4
View File
@@ -115,10 +115,16 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
} }
if environmentStatus.Details.Ok { if environmentStatus.Details.Ok {
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{ statusArray = append(statusArray,
Type: portainer.EdgeStackStatusRunning, portainer.EdgeStackDeploymentStatus{
Time: time.Now().Unix(), Type: portainer.EdgeStackStatusDeploymentReceived,
}) Time: time.Now().Unix(),
},
portainer.EdgeStackDeploymentStatus{
Type: portainer.EdgeStackStatusRunning,
Time: time.Now().Unix(),
},
)
} }
if environmentStatus.Details.ImagesPulled { if environmentStatus.Details.ImagesPulled {
+11
View File
@@ -148,6 +148,17 @@ func (m *Migrator) LatestMigrations() Migrations {
return m.migrations[len(m.migrations)-1] return m.migrations[len(m.migrations)-1]
} }
func (m *Migrator) GetMigratorCountOfCurrentAPIVersion() int {
migratorCount := 0
latestMigrations := m.LatestMigrations()
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
migratorCount = len(latestMigrations.MigrationFuncs)
}
return migratorCount
}
// !NOTE: Migration funtions should ideally be idempotent. // !NOTE: Migration funtions should ideally be idempotent.
// ! Which simply means the function can run over the same data many times but only transform it once. // ! Which simply means the function can run over the same data many times but only transform it once.
// ! In practice this really just means an extra check or two to ensure we're not destroying valid data. // ! In practice this really just means an extra check or two to ensure we're not destroying valid data.
@@ -944,6 +944,6 @@
} }
], ],
"version": { "version": {
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" "VERSION": "{\"SchemaVersion\":\"2.19.4\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
} }
} }
+6 -6
View File
@@ -57,20 +57,20 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) { func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithHost(endpoint.URL), client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
) )
} }
func CreateClientFromEnv() (*client.Client, error) { func CreateClientFromEnv() (*client.Client, error) {
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.FromEnv, client.FromEnv,
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
) )
} }
func CreateSimpleClient() (*client.Client, error) { func CreateSimpleClient() (*client.Client, error) {
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
) )
} }
@@ -82,7 +82,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithHost(endpoint.URL), client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli), client.WithHTTPClient(httpCli),
) )
} }
@@ -116,7 +116,7 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithHost(endpointURL), client.WithHost(endpointURL),
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli), client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers), client.WithHTTPHeaders(headers),
) )
@@ -144,7 +144,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
return client.NewClientWithOpts( return client.NewClientWithOpts(
client.WithHost(endpoint.URL), client.WithHost(endpoint.URL),
client.WithVersion(dockerClientVersion), client.WithAPIVersionNegotiation(),
client.WithHTTPClient(httpCli), client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers), client.WithHTTPHeaders(headers),
) )
+23 -3
View File
@@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/registryutils" "github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
) )
// SwarmStackManager represents a service for managing stacks. // SwarmStackManager represents a service for managing stacks.
@@ -64,16 +65,35 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
if registry.Authentication { if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry) err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
if err != nil { if err != nil {
return err log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
continue
} }
username, password, err := registryutils.GetRegEffectiveCredential(&registry) username, password, err := registryutils.GetRegEffectiveCredential(&registry)
if err != nil { if err != nil {
return err log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
continue
} }
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL) registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
runCommandAndCaptureStdErr(command, registryArgs, nil, "") err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
}
} }
} }
+32
View File
@@ -302,6 +302,38 @@ func (service *Service) UpdateStoreStackFileFromBytes(stackIdentifier, fileName
return service.wrapFileStore(stackStorePath), nil return service.wrapFileStore(stackStorePath), nil
} }
// UpdateStoreStackFileFromBytesByVersion makes stack file backup and updates a new file from bytes.
// It returns the path to the folder where the file is stored.
func (service *Service) UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error) {
stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier)
versionStr := ""
if version != 0 {
versionStr = fmt.Sprintf("v%d", version)
}
if commitHash != "" {
versionStr = commitHash
}
if versionStr != "" {
stackStorePath = JoinPaths(stackStorePath, versionStr)
}
composeFilePath := JoinPaths(stackStorePath, fileName)
err := service.createBackupFileInStore(composeFilePath)
if err != nil {
return "", err
}
r := bytes.NewReader(data)
err = service.createFileInStore(composeFilePath, r)
if err != nil {
return "", err
}
return service.wrapFileStore(stackStorePath), nil
}
// RemoveStackFileBackup removes the stack file backup in the ComposeStorePath. // RemoveStackFileBackup removes the stack file backup in the ComposeStorePath.
func (service *Service) RemoveStackFileBackup(stackIdentifier, fileName string) error { func (service *Service) RemoveStackFileBackup(stackIdentifier, fileName string) error {
stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier) stackStorePath := JoinPaths(ComposeStorePath, stackIdentifier)
+1 -2
View File
@@ -27,7 +27,6 @@ require (
github.com/google/go-cmp v0.5.9 github.com/google/go-cmp v0.5.9
github.com/gorilla/handlers v1.5.1 github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/hashicorp/golang-lru v0.5.4 github.com/hashicorp/golang-lru v0.5.4
github.com/joho/godotenv v1.4.0 github.com/joho/godotenv v1.4.0
@@ -41,7 +40,7 @@ require (
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a
github.com/portainer/libhttp v0.0.0-20230615144939-a999f666d9a9 github.com/portainer/libhttp v0.0.0-20230615144939-a999f666d9a9
github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146 github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146 github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146 github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73 github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
+4 -2
View File
@@ -203,8 +203,6 @@ github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q= github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -322,6 +320,10 @@ github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e1
github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146/go.mod h1:x4Lpq/BjFhZmuNB8e8FO0ObRPQ/Z/V9rTe54bMedf1A= github.com/portainer/portainer/pkg/featureflags v0.0.0-20230711022654-64b227b2e146/go.mod h1:x4Lpq/BjFhZmuNB8e8FO0ObRPQ/Z/V9rTe54bMedf1A=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146 h1:1qW7quKyFG4tOnMcnnqyYsDVfL09etO1h/Cu/3ak7KU= github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146 h1:1qW7quKyFG4tOnMcnnqyYsDVfL09etO1h/Cu/3ak7KU=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM= github.com/portainer/portainer/pkg/libhelm v0.0.0-20230711022654-64b227b2e146/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230919060741-8f42ba025479 h1:DbmhSQZpDo5f0cr+CKLJqoqhQiuxp8QFXdZsjPS1lI4=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230919060741-8f42ba025479/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce h1:DQTMXYH1zn2DzuAe+4rT40JqdHLhpHHJ2pzRFhvZ/+c=
github.com/portainer/portainer/pkg/libhelm v0.0.0-20230928223730-157393c965ce/go.mod h1:cFRD6PvOwpd2pf/O1r/IMKl+ZB12pWfo/Evleh3aCfM=
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146 h1:ZGj+j5HoajaO+mXgCm6NzOU+zUdIlJK2amagB+QIDvc= github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146 h1:ZGj+j5HoajaO+mXgCm6NzOU+zUdIlJK2amagB+QIDvc=
github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146/go.mod h1:+zCK2UbsH6A3yEGi0yZ45ec5VFRP7svob5Q2lW6LFgk= github.com/portainer/portainer/pkg/libstack v0.0.0-20230711022654-64b227b2e146/go.mod h1:+zCK2UbsH6A3yEGi0yZ45ec5VFRP7svob5Q2lW6LFgk=
github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73 h1:7bPOnwucE0nor0so1BQJxQKCL5t+vCWO4nAz/S0lci0= github.com/portainer/portainer/third_party/digest v0.0.0-20221201002639-8fd0efa34f73 h1:7bPOnwucE0nor0so1BQJxQKCL5t+vCWO4nAz/S0lci0=
+3 -2
View File
@@ -24,6 +24,7 @@ type Handler struct {
ProxyManager *proxy.Manager ProxyManager *proxy.Manager
KubernetesTokenCacheManager *kubernetes.TokenCacheManager KubernetesTokenCacheManager *kubernetes.TokenCacheManager
passwordStrengthChecker security.PasswordStrengthChecker passwordStrengthChecker security.PasswordStrengthChecker
bouncer security.BouncerService
} }
// NewHandler creates a handler to manage authentication operations. // NewHandler creates a handler to manage authentication operations.
@@ -31,6 +32,7 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
h := &Handler{ h := &Handler{
Router: mux.NewRouter(), Router: mux.NewRouter(),
passwordStrengthChecker: passwordStrengthChecker, passwordStrengthChecker: passwordStrengthChecker,
bouncer: bouncer,
} }
h.Handle("/auth/oauth/validate", h.Handle("/auth/oauth/validate",
@@ -38,7 +40,6 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
h.Handle("/auth", h.Handle("/auth",
rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost)
h.Handle("/auth/logout", h.Handle("/auth/logout",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost) bouncer.PublicAccess(httperror.LoggerHandler(h.logout))).Methods(http.MethodPost)
return h return h
} }
+7 -7
View File
@@ -5,12 +5,12 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/logoutcontext"
) )
// @id Logout // @id Logout
// @summary Logout // @summary Logout
// @description **Access policy**: authenticated // @description **Access policy**: public
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
// @tags auth // @tags auth
@@ -18,12 +18,12 @@ import (
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /auth/logout [post] // @router /auth/logout [post]
func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) logout(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r) tokenData := handler.bouncer.JWTAuthLookup(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err)
}
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID) if tokenData != nil {
handler.KubernetesTokenCacheManager.RemoveUserFromCache(tokenData.ID)
logoutcontext.Cancel(tokenData.Token)
}
return response.Empty(w) return response.Empty(w)
} }
@@ -3,6 +3,7 @@ package customtemplates
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"os" "os"
"regexp" "regexp"
@@ -472,3 +473,29 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
return customTemplate, nil return customTemplate, nil
} }
// @id CustomTemplateCreate
// @summary Create a custom template
// @description Create a custom template.
// @description **Access policy**: authenticated
// @tags custom_templates
// @security ApiKeyAuth
// @security jwt
// @accept json,multipart/form-data
// @produce json
// @param method query string true "method for creating template" Enums(string, file, repository)
// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
// @success 200 {object} portainer.CustomTemplate
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /custom_templates [post]
func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method", err)
}
url := fmt.Sprintf("/custom_templates/create/%s", method)
return url, nil
}
@@ -8,6 +8,7 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
) )
@@ -32,6 +33,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/custom_templates/create/{method}", h.Handle("/custom_templates/create/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost) bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
h.Handle("/custom_templates", h.Handle("/custom_templates",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet) bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
h.Handle("/custom_templates/{id}", h.Handle("/custom_templates/{id}",
@@ -2,6 +2,7 @@ package edgejobs
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -287,3 +288,26 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob) return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
} }
// @id EdgeJobCreate
// @summary Create an EdgeJob
// @description **Access policy**: administrator
// @tags edge_jobs
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param method query string true "Creation Method" Enums(file, string)
// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
// @success 200 {object} portainer.EdgeGroup
// @failure 503 "Edge compute features are disabled"
// @failure 500
// @deprecated
// @router /edge_jobs [post]
func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
return fmt.Sprintf("/edge_jobs/create/%s", method), nil
}
+3
View File
@@ -8,6 +8,7 @@ import (
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@@ -29,6 +30,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/edge_jobs", h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet) bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
h.Handle("/edge_jobs",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/create/{method}", h.Handle("/edge_jobs/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost) bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
h.Handle("/edge_jobs/{id}", h.Handle("/edge_jobs/{id}",
@@ -1,6 +1,7 @@
package edgestacks package edgestacks
import ( import (
"fmt"
"net/http" "net/http"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
@@ -18,6 +19,7 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
if err != nil { if err != nil {
return httperror.BadRequest("Invalid query parameter: method", err) return httperror.BadRequest("Invalid query parameter: method", err)
} }
dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true) dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true)
tokenData, err := security.RetrieveTokenData(r) tokenData, err := security.RetrieveTokenData(r)
@@ -60,3 +62,26 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file") return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
} }
// @id EdgeStackCreate
// @summary Create an EdgeStack
// @description **Access policy**: administrator
// @tags edge_stacks
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param method query string true "Creation Method" Enums(file,string,repository)
// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
// @success 200 {object} portainer.EdgeStack
// @failure 500
// @failure 503 "Edge compute features are disabled"
// @deprecated
// @router /edge_stacks [post]
func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
return fmt.Sprintf("/edge_stacks/create/%s", method), nil
}
+2
View File
@@ -38,6 +38,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/edge_stacks/create/{method}", h.Handle("/edge_stacks/create/{method}",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost) bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
h.Handle("/edge_stacks", h.Handle("/edge_stacks",
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet) bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
h.Handle("/edge_stacks/{id}", h.Handle("/edge_stacks/{id}",
@@ -50,7 +50,7 @@ func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentTyp
entryPoint = stack.ManifestPath entryPoint = stack.ManifestPath
} }
_, err := handler.FileService.StoreEdgeStackFileFromBytesByVersion(stackFolder, entryPoint, stack.Version, config) _, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, config)
if err != nil { if err != nil {
return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err) return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err)
} }
@@ -294,7 +294,7 @@ func shouldReloadTLSConfiguration(endpoint *portainer.Endpoint, payload *endpoin
// When updating Docker API environment, as long as TLS is true and TLSSkipVerify is false, // When updating Docker API environment, as long as TLS is true and TLSSkipVerify is false,
// we assume that new TLS files have been uploaded and we need to reload the TLS configuration. // we assume that new TLS files have been uploaded and we need to reload the TLS configuration.
if endpoint.Type != portainer.DockerEnvironment || if endpoint.Type != portainer.DockerEnvironment ||
!strings.HasPrefix(*payload.URL, "tcp://") || (payload.URL != nil && !strings.HasPrefix(*payload.URL, "tcp://")) ||
payload.TLS == nil || !*payload.TLS { payload.TLS == nil || !*payload.TLS {
return false return false
} }
+19 -3
View File
@@ -34,6 +34,7 @@ type EnvironmentsQuery struct {
edgeCheckInPassedSeconds int edgeCheckInPassedSeconds int
edgeStackId portainer.EdgeStackID edgeStackId portainer.EdgeStackID
edgeStackStatus *portainer.EdgeStackStatusType edgeStackStatus *portainer.EdgeStackStatusType
excludeIds []portainer.EndpointID
} }
func parseQuery(r *http.Request) (EnvironmentsQuery, error) { func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
@@ -69,6 +70,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
return EnvironmentsQuery{}, err return EnvironmentsQuery{}, err
} }
excludeIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds")
if err != nil {
return EnvironmentsQuery{}, err
}
agentVersions := getArrayQueryParameter(r, "agentVersions") agentVersions := getArrayQueryParameter(r, "agentVersions")
name, _ := request.RetrieveQueryParameter(r, "name", true) name, _ := request.RetrieveQueryParameter(r, "name", true)
@@ -97,6 +103,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
types: endpointTypes, types: endpointTypes,
tagIds: tagIDs, tagIds: tagIDs,
endpointIds: endpointIDs, endpointIds: endpointIDs,
excludeIds: excludeIDs,
tagsPartialMatch: tagsPartialMatch, tagsPartialMatch: tagsPartialMatch,
groupIds: groupIDs, groupIds: groupIDs,
status: status, status: status,
@@ -118,6 +125,12 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds) filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds)
} }
if len(query.excludeIds) > 0 {
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
return !slices.Contains(query.excludeIds, endpoint.ID)
})
}
if len(query.groupIds) > 0 { if len(query.groupIds) > 0 {
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds) filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
} }
@@ -208,9 +221,12 @@ func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID
status, ok := edgeStackStatus[envId] status, ok := edgeStackStatus[envId]
// consider that if the env has no status in the stack it is in Pending state // consider that if the env has no status in the stack it is in Pending state
// workaround because Stack.Status[EnvId].Details.Pending is never set to True in the codebase if statusFilter == portainer.EdgeStackStatusPending {
if !ok && statusFilter == portainer.EdgeStackStatusPending { return !ok || len(status.Status) == 0
return true }
if !ok {
return false
} }
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool { return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
+23
View File
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/slices"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -124,6 +125,28 @@ func Test_Filter_edgeFilter(t *testing.T) {
runTests(tests, t, handler, endpoints) runTests(tests, t, handler, endpoints)
} }
func Test_Filter_excludeIDs(t *testing.T) {
ids := []portainer.EndpointID{1, 2, 3, 4, 5, 6, 7, 8, 9}
environments := slices.Map(ids, func(id portainer.EndpointID) portainer.Endpoint {
return portainer.Endpoint{ID: id, GroupID: 1, Type: portainer.DockerEnvironment}
})
handler := setupFilterTest(t, environments)
tests := []filterTest{
{
title: "should exclude IDs 2,5,8",
expected: []portainer.EndpointID{1, 3, 4, 6, 7, 9},
query: EnvironmentsQuery{
excludeIds: []portainer.EndpointID{2, 5, 8},
},
},
}
runTests(tests, t, handler, environments)
}
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) { func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
for _, test := range tests { for _, test := range tests {
t.Run(test.title, func(t *testing.T) { t.Run(test.title, func(t *testing.T) {
+1 -1
View File
@@ -84,7 +84,7 @@ type Handler struct {
} }
// @title PortainerCE API // @title PortainerCE API
// @version 2.19.0 // @version 2.19.4
// @description.markdown api-description.md // @description.markdown api-description.md
// @termsOfService // @termsOfService
@@ -13,6 +13,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackbuilders" "github.com/portainer/portainer/api/stacks/stackbuilders"
@@ -176,6 +177,14 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
handler.KubernetesDeployer, handler.KubernetesDeployer,
user) user)
// Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err == nil {
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, payload.Namespace)
}
stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder) stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder)
_, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) _, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint)
if httpErr != nil { if httpErr != nil {
+3
View File
@@ -14,6 +14,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client" dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/endpointutils"
@@ -58,6 +59,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
h.Handle("/stacks/create/{type}/{method}", h.Handle("/stacks/create/{type}/{method}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost) bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
h.Handle("/stacks",
bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
h.Handle("/stacks", h.Handle("/stacks",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet) bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
h.Handle("/stacks/{id}", h.Handle("/stacks/{id}",
+51
View File
@@ -1,6 +1,7 @@
package stacks package stacks
import ( import (
"fmt"
"net/http" "net/http"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -139,3 +140,53 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
return response.JSON(w, stack) return response.JSON(w, stack)
} }
func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
if err != nil {
return "", err
}
switch stackType {
case 1:
return "swarm", nil
case 2:
return "standalone", nil
case 3:
return "kubernetes", nil
}
return "", errors.New(request.ErrInvalidQueryParameter)
}
// @id StackCreate
// @summary Deploy a new stack
// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
// @description **Access policy**: authenticated
// @tags stacks
// @security ApiKeyAuth
// @security jwt
// @accept json,multipart/form-data
// @produce json
// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @deprecated
// @router /stacks [post]
func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
}
stackType, err := getStackTypeFromQueryParameter(r)
if err != nil {
return "", httperror.BadRequest("Invalid query parameter: type", err)
}
return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
}
+2 -2
View File
@@ -190,7 +190,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerSwarmStack { if stack.Type == portainer.DockerSwarmStack {
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteSwarmStack(stack, endpoint) return handler.StackDeployer.UndeployRemoteSwarmStack(stack, endpoint)
} }
@@ -200,7 +200,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
if stack.Type == portainer.DockerComposeStack { if stack.Type == portainer.DockerComposeStack {
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.UndeployRemoteComposeStack(stack, endpoint) return handler.StackDeployer.UndeployRemoteComposeStack(stack, endpoint)
} }
+21 -5
View File
@@ -117,7 +117,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
stack.AutoUpdate.JobID = jobID stack.AutoUpdate.JobID = jobID
} }
err = handler.startStack(stack, endpoint) err = handler.startStack(stack, endpoint, securityContext)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to start stack", err) return httperror.InternalServerError("Unable to start stack", err)
} }
@@ -136,12 +136,16 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
return response.JSON(w, stack) return response.JSON(w, stack)
} }
func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (handler *Handler) startStack(
stack *portainer.Stack,
endpoint *portainer.Endpoint,
securityContext *security.RestrictedRequestContext,
) error {
switch stack.Type { switch stack.Type {
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint) return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint)
} }
@@ -149,11 +153,23 @@ func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.E
case portainer.DockerSwarmStack: case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StartRemoteSwarmStack(stack, endpoint) return handler.StackDeployer.StartRemoteSwarmStack(stack, endpoint)
} }
return handler.SwarmStackManager.Deploy(stack, true, true, endpoint) user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return fmt.Errorf("unable to load user information from the database: %w", err)
}
registries, err := handler.DataStore.Registry().ReadAll()
if err != nil {
return fmt.Errorf("unable to retrieve registries from the database: %w", err)
}
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
return handler.StackDeployer.DeploySwarmStack(stack, endpoint, filteredRegistries, true, true)
} }
return nil return nil
+2 -2
View File
@@ -125,7 +125,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name) stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteComposeStack(stack, endpoint) return handler.StackDeployer.StopRemoteComposeStack(stack, endpoint)
} }
@@ -133,7 +133,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
case portainer.DockerSwarmStack: case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
return handler.StackDeployer.StopRemoteSwarmStack(stack, endpoint) return handler.StackDeployer.StopRemoteSwarmStack(stack, endpoint)
} }
+10
View File
@@ -198,6 +198,11 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
stack.Env = payload.Env stack.Env = payload.Env
if stack.GitConfig != nil {
// detach from git
stack.GitConfig = nil
}
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) _, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil { if err != nil {
@@ -263,6 +268,11 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
stack.Env = payload.Env stack.Env = payload.Env
if stack.GitConfig != nil {
// detach from git
stack.GitConfig = nil
}
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) _, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil { if err != nil {
@@ -13,6 +13,7 @@ import (
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
k "github.com/portainer/portainer/api/kubernetes" k "github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
@@ -113,6 +114,14 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err) return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err)
} }
// Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err == nil {
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, stack.Namespace)
}
//use temp dir as the stack project path for deployment //use temp dir as the stack project path for deployment
//so if the deployment failed, the original file won't be over-written //so if the deployment failed, the original file won't be over-written
stack.ProjectPath = tempFileDir stack.ProjectPath = tempFileDir
+30 -1
View File
@@ -4,8 +4,12 @@ import (
"net/http" "net/http"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/rs/zerolog/log"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -13,7 +17,8 @@ import (
// Handler is the HTTP handler used to handle team membership operations. // Handler is the HTTP handler used to handle team membership operations.
type Handler struct { type Handler struct {
*mux.Router *mux.Router
DataStore dataservices.DataStore DataStore dataservices.DataStore
K8sClientFactory *cli.ClientFactory
} }
// NewHandler creates a handler to manage team membership operations. // NewHandler creates a handler to manage team membership operations.
@@ -31,3 +36,27 @@ func NewHandler(bouncer security.BouncerService) *Handler {
return h return h
} }
func (handler *Handler) updateUserServiceAccounts(membership *portainer.TeamMembership) {
endpoints, err := handler.DataStore.Endpoint().EndpointsByTeamID(membership.TeamID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environments for team %d", membership.TeamID)
return
}
for _, endpoint := range endpoints {
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
// update kubernenets service accounts if the team is associated with a kubernetes environment
if endpointutils.IsKubernetesEndpoint(&endpoint) {
kubecli, err := handler.K8sClientFactory.GetKubeClient(&endpoint)
if err != nil {
log.Error().Err(err).Msgf("failed getting kube client for environment %d", endpoint.ID)
continue
}
teamIDs := []int{int(membership.TeamID)}
err = kubecli.SetupUserServiceAccount(int(membership.UserID), teamIDs, restrictDefaultNamespace)
if err != nil {
log.Error().Err(err).Msgf("failed setting-up service account for user %d", membership.UserID)
}
}
}
}
@@ -91,5 +91,7 @@ func (handler *Handler) teamMembershipCreate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to persist team memberships inside the database", err) return httperror.InternalServerError("Unable to persist team memberships inside the database", err)
} }
defer handler.updateUserServiceAccounts(membership)
return response.JSON(w, membership) return response.JSON(w, membership)
} }
@@ -52,5 +52,7 @@ func (handler *Handler) teamMembershipDelete(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to remove the team membership from the database", err) return httperror.InternalServerError("Unable to remove the team membership from the database", err)
} }
defer handler.updateUserServiceAccounts(membership)
return response.Empty(w) return response.Empty(w)
} }
@@ -90,5 +90,7 @@ func (handler *Handler) teamMembershipUpdate(w http.ResponseWriter, r *http.Requ
return httperror.InternalServerError("Unable to persist membership changes inside the database", err) return httperror.InternalServerError("Unable to persist membership changes inside the database", err)
} }
defer handler.updateUserServiceAccounts(membership)
return response.JSON(w, membership) return response.JSON(w, membership)
} }
+1
View File
@@ -22,6 +22,7 @@ var (
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator") errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account") errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
errCryptoHashFailure = errors.New("Unable to hash data") errCryptoHashFailure = errors.New("Unable to hash data")
errWrongPassword = errors.New("Wrong password")
) )
func hideFields(user *portainer.User) { func hideFields(user *portainer.User) {
+36 -12
View File
@@ -10,6 +10,13 @@ import (
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
) )
type User struct {
ID portainer.UserID `json:"Id" example:"1"`
Username string `json:"Username" example:"bob"`
// User role (1 for administrator account and 2 for regular account)
Role portainer.UserRole `json:"Role" example:"1"`
}
// @id UserList // @id UserList
// @summary List users // @summary List users
// @description List Portainer users. // @description List Portainer users.
@@ -26,24 +33,25 @@ import (
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /users [get] // @router /users [get]
func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
users, err := handler.DataStore.User().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err) return httperror.InternalServerError("Unable to retrieve info from request context", err)
} }
availableUsers := security.FilterUsers(users, securityContext) if !securityContext.IsAdmin && !securityContext.IsTeamLeader {
for i := range availableUsers { return httperror.Forbidden("Permission denied to access users list", err)
hideFields(&availableUsers[i])
} }
users, err := handler.DataStore.User().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve users from the database", err)
}
availableUsers := security.FilterUsers(users, securityContext)
endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true) endpointID, _ := request.RetrieveNumericQueryParameter(r, "environmentId", true)
if endpointID == 0 { if endpointID == 0 {
return response.JSON(w, availableUsers) return response.JSON(w, sanitizeUsers(availableUsers))
} }
// filter out users who do not have access to the specific endpoint // filter out users who do not have access to the specific endpoint
@@ -57,11 +65,11 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper
return httperror.InternalServerError("Unable to retrieve environment groups from the database", err) return httperror.InternalServerError("Unable to retrieve environment groups from the database", err)
} }
canAccessEndpoint := make([]portainer.User, 0) canAccessEndpoint := make([]User, 0)
for _, user := range availableUsers { for _, user := range availableUsers {
// the users who have the endpoint authorization // the users who have the endpoint authorization
if _, ok := user.EndpointAuthorizations[endpoint.ID]; ok { if _, ok := user.EndpointAuthorizations[endpoint.ID]; ok {
canAccessEndpoint = append(canAccessEndpoint, user) canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
continue continue
} }
@@ -72,9 +80,25 @@ func (handler *Handler) userList(w http.ResponseWriter, r *http.Request) *httper
} }
if security.AuthorizedEndpointAccess(endpoint, endpointGroup, user.ID, teamMemberships) { if security.AuthorizedEndpointAccess(endpoint, endpointGroup, user.ID, teamMemberships) {
canAccessEndpoint = append(canAccessEndpoint, user) canAccessEndpoint = append(canAccessEndpoint, sanitizeUser(user))
} }
} }
return response.JSON(w, canAccessEndpoint) return response.JSON(w, canAccessEndpoint)
} }
func sanitizeUser(user portainer.User) User {
return User{
ID: user.ID,
Username: user.Username,
Role: user.Role,
}
}
func sanitizeUsers(users []portainer.User) []User {
u := make([]User, len(users))
for i := range users {
u[i] = sanitizeUser(users[i])
}
return u
}
+2 -16
View File
@@ -111,28 +111,14 @@ func Test_userList(t *testing.T) {
} }
}) })
t.Run("standard user cannot list amdin users", func(t *testing.T) { t.Run("standard user cannot list users", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/users", nil) req := httptest.NewRequest(http.MethodGet, "/users", nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt)) req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
h.ServeHTTP(rr, req) h.ServeHTTP(rr, req)
is.Equal(http.StatusOK, rr.Code) is.Equal(http.StatusForbidden, rr.Code)
body, err := io.ReadAll(rr.Body)
is.NoError(err, "ReadAll should not return error")
var resp []portainer.User
err = json.Unmarshal(body, &resp)
is.NoError(err, "response should be list json")
is.Len(resp, 2)
if len(resp) > 0 {
for _, user := range resp {
is.NotEqual(portainer.AdministratorRole, user.Role)
}
}
}) })
// Case 2: the user is under an environment group and the environment group has endpoint access. // Case 2: the user is under an environment group and the environment group has endpoint access.
+32 -5
View File
@@ -21,9 +21,10 @@ type themePayload struct {
} }
type userUpdatePayload struct { type userUpdatePayload struct {
Username string `validate:"required" example:"bob"` Username string `validate:"required" example:"bob"`
Password string `validate:"required" example:"cg9Wgky3"` Password string `validate:"required" example:"cg9Wgky3"`
Theme *themePayload NewPassword string `validate:"required" example:"asfj2emv"`
Theme *themePayload
// User role (1 for administrator account and 2 for regular account) // User role (1 for administrator account and 2 for regular account)
Role int `validate:"required" enums:"1,2" example:"2"` Role int `validate:"required" enums:"1,2" example:"2"`
@@ -37,12 +38,14 @@ func (payload *userUpdatePayload) Validate(r *http.Request) error {
if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 { if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 {
return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)") return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)")
} }
return nil return nil
} }
// @id UserUpdate // @id UserUpdate
// @summary Update a user // @summary Update a user
// @description Update user details. A regular user account can only update his details. // @description Update user details. A regular user account can only update his details.
// @description A regular user account cannot change their username or role.
// @description **Access policy**: authenticated // @description **Access policy**: authenticated
// @tags users // @tags users
// @security ApiKeyAuth // @security ApiKeyAuth
@@ -95,6 +98,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
} }
if payload.Username != "" && payload.Username != user.Username { if payload.Username != "" && payload.Username != user.Username {
if tokenData.Role != portainer.AdministratorRole {
return httperror.Forbidden("Permission denied. Unable to update username", httperrors.ErrResourceAccessDenied)
}
sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username) sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username)
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) { if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
return httperror.InternalServerError("Unable to retrieve users from the database", err) return httperror.InternalServerError("Unable to retrieve users from the database", err)
@@ -106,8 +113,28 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
user.Username = payload.Username user.Username = payload.Username
} }
if payload.Password != "" { if payload.Password != "" && payload.NewPassword == "" {
user.Password, err = handler.CryptoService.Hash(payload.Password) if tokenData.Role == portainer.AdministratorRole {
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password as an admin, you only need 'newPassword' in your request"))
}
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password, you must include both 'password' and 'newPassword' in your request"))
}
if payload.NewPassword != "" {
// Non-admins need to supply the previous password
if tokenData.Role != portainer.AdministratorRole {
err := handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match. Password left unchanged", errors.New("Current password does not match the password provided. Please try again"))
}
}
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
}
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure) return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
} }
@@ -87,7 +87,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
} }
if !handler.passwordStrengthChecker.Check(payload.NewPassword) { if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
return httperror.BadRequest("Password does not meet the requirements", nil) return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
} }
user.Password, err = handler.CryptoService.Hash(payload.NewPassword) user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
+17 -3
View File
@@ -9,9 +9,11 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
) )
// @summary Attach a websocket // @summary Attach a websocket
@@ -74,6 +76,13 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request)
} }
func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
r.Header.Del("Origin") r.Header.Del("Origin")
@@ -89,10 +98,15 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque
} }
defer websocketConn.Close() defer websocketConn.Close()
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID) return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
} }
func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, attachID string) error { func hijackAttachStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
attachID string,
token string,
) error {
dial, err := initDial(endpoint) dial, err := initDial(endpoint)
if err != nil { if err != nil {
return err return err
@@ -116,7 +130,7 @@ func hijackAttachStartOperation(websocketConn *websocket.Conn, endpoint *portain
return err return err
} }
return hijackRequest(websocketConn, httpConn, attachStartRequest) return hijackRequest(websocketConn, httpConn, attachStartRequest, token)
} }
func createAttachStartRequest(attachID string) (*http.Request, error) { func createAttachStartRequest(attachID string) (*http.Request, error) {
+18 -3
View File
@@ -11,9 +11,11 @@ import (
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
) )
type execStartOperationPayload struct { type execStartOperationPayload struct {
@@ -80,6 +82,14 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
} }
func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
r.Header.Del("Origin") r.Header.Del("Origin")
if params.endpoint.Type == portainer.AgentOnDockerEnvironment { if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
@@ -94,10 +104,15 @@ func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request
} }
defer websocketConn.Close() defer websocketConn.Close()
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID) return hijackExecStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
} }
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error { func hijackExecStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
execID string,
token string,
) error {
dial, err := initDial(endpoint) dial, err := initDial(endpoint)
if err != nil { if err != nil {
return err return err
@@ -121,7 +136,7 @@ func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer
return err return err
} }
return hijackRequest(websocketConn, httpConn, execStartRequest) return hijackRequest(websocketConn, httpConn, execStartRequest, token)
} }
func createExecStartRequest(execID string) (*http.Request, error) { func createExecStartRequest(execID string) (*http.Request, error) {
+16 -4
View File
@@ -7,9 +7,15 @@ import (
"net/http/httputil" "net/http/httputil"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/portainer/portainer/api/internal/logoutcontext"
) )
func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error { func hijackRequest(
websocketConn *websocket.Conn,
httpConn *httputil.ClientConn,
request *http.Request,
token string,
) error {
// Server hijacks the connection, error 'connection closed' expected // Server hijacks the connection, error 'connection closed' expected
resp, err := httpConn.Do(request) resp, err := httpConn.Do(request)
if !errors.Is(err, httputil.ErrPersistEOF) { if !errors.Is(err, httputil.ErrPersistEOF) {
@@ -29,9 +35,15 @@ func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn,
go streamFromReaderToWebsocket(websocketConn, brw, errorChan) go streamFromReaderToWebsocket(websocketConn, brw, errorChan)
go streamFromWebsocketToWriter(websocketConn, tcpConn, errorChan) go streamFromWebsocketToWriter(websocketConn, tcpConn, errorChan)
err = <-errorChan logoutCtx := logoutcontext.GetContext(token)
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err select {
case <-logoutCtx.Done():
return fmt.Errorf("Your session has been logged out.")
case err = <-errorChan:
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
return err
}
} }
return nil return nil
+76 -29
View File
@@ -1,15 +1,20 @@
package websocket package websocket
import ( import (
"context"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/logoutcontext"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/koding/websocketproxy" "github.com/koding/websocketproxy"
"github.com/portainer/portainer/api/crypto"
"github.com/rs/zerolog/log"
) )
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
@@ -18,33 +23,12 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r
return err return err
} }
endpointURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)) agentURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
if err != nil { if err != nil {
return err return err
} }
endpointURL.Scheme = "ws" return handler.doProxyWebsocketRequest(w, r, params, agentURL, true)
proxy := websocketproxy.NewProxy(endpointURL)
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return err
}
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
}
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
proxy.ServeHTTP(w, r)
return nil
} }
func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
@@ -59,17 +43,41 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
} }
agentURL.Scheme = "ws" agentURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(agentURL) return handler.doProxyWebsocketRequest(w, r, params, agentURL, false)
}
if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify { func (handler *Handler) doProxyWebsocketRequest(
w http.ResponseWriter,
r *http.Request,
params *webSocketRequestParams,
agentURL *url.URL,
isEdge bool,
) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.
Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
enableTLS := !isEdge && (params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify)
agentURL.Scheme = "ws"
if enableTLS {
agentURL.Scheme = "wss" agentURL.Scheme = "wss"
}
proxy := websocketproxy.NewProxy(agentURL)
proxyDialer := *websocket.DefaultDialer
proxy.Dialer = &proxyDialer
if enableTLS {
tlsConfig := crypto.CreateTLSConfiguration() tlsConfig := crypto.CreateTLSConfiguration()
tlsConfig.InsecureSkipVerify = params.endpoint.TLSConfig.TLSSkipVerify tlsConfig.InsecureSkipVerify = params.endpoint.TLSConfig.TLSSkipVerify
proxy.Dialer = &websocket.Dialer{ proxyDialer.TLSClientConfig = tlsConfig
TLSClientConfig: tlsConfig,
}
} }
signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
@@ -84,7 +92,46 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token) out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
} }
if isEdge {
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
}
abortProxyOnLogout(r.Context(), proxy, tokenData.Token)
proxy.ServeHTTP(w, r) proxy.ServeHTTP(w, r)
return nil return nil
} }
func abortProxyOnLogout(ctx context.Context, proxy *websocketproxy.WebsocketProxy, token string) {
var wsConn net.Conn
proxy.Dialer.NetDial = func(network, addr string) (net.Conn, error) {
netDialer := &net.Dialer{}
conn, err := netDialer.DialContext(context.Background(), network, addr)
wsConn = conn
return conn, err
}
logoutCtx := logoutcontext.GetContext(token)
go func() {
log.Debug().
Msg("logout watcher for websocket proxy started")
select {
case <-logoutCtx.Done():
log.Debug().
Msg("logout watcher for websocket proxy stopped as user logged out")
if wsConn != nil {
wsConn.Close()
}
case <-ctx.Done():
log.Debug().
Msg("logout watcher for websocket proxy stopped as the ws connection closed")
}
}()
}
+25
View File
@@ -0,0 +1,25 @@
package middlewares
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/rs/zerolog/log"
)
// deprecate api route
func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
newUrl, err := urlBuilder(w, r)
if err != nil {
httperror.WriteError(w, err.StatusCode, err.Error(), err)
return
}
log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl)
redirectedRequest := r.Clone(r.Context())
redirectedRequest.URL.Path = newUrl
router.ServeHTTP(w, redirectedRequest)
})
}
@@ -6,7 +6,7 @@ import (
func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) { func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
switch request.Method { switch request.Method {
case http.MethodPost, http.MethodPatch: case http.MethodPost, http.MethodPatch, http.MethodPut:
transport.refreshRegistry(request, namespace) transport.refreshRegistry(request, namespace)
} }
+50 -14
View File
@@ -1,10 +1,12 @@
package kubernetes package kubernetes
import ( import (
"fmt"
"os" "os"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
) )
const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token" const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
@@ -43,28 +45,62 @@ func (manager *tokenManager) GetAdminServiceAccountToken() string {
return manager.adminToken return manager.adminToken
} }
func (manager *tokenManager) setupUserServiceAccounts(userID portainer.UserID, endpoint *portainer.Endpoint) error {
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(userID)
if err != nil {
return err
}
teamIds := make([]int, 0, len(memberships))
for _, membership := range memberships {
teamIds = append(teamIds, int(membership.TeamID))
}
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
err = manager.kubecli.SetupUserServiceAccount(int(userID), teamIds, restrictDefaultNamespace)
if err != nil {
return err
}
return nil
}
func (manager *tokenManager) UpdateUserServiceAccountsForEndpoint(endpointID portainer.EndpointID) {
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
log.Error().Err(err).Msgf("failed fetching environments %d", endpointID)
return
}
userIDs := make([]portainer.UserID, 0)
for u := range endpoint.UserAccessPolicies {
userIDs = append(userIDs, u)
}
for t := range endpoint.TeamAccessPolicies {
memberships, _ := manager.dataStore.TeamMembership().TeamMembershipsByTeamID(portainer.TeamID(t))
for _, membership := range memberships {
userIDs = append(userIDs, membership.UserID)
}
}
for _, userID := range userIDs {
if err := manager.setupUserServiceAccounts(userID, endpoint); err != nil {
log.Error().Err(err).Msgf("failed setting-up service account for user %d", userID)
}
}
}
// GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token // GetUserServiceAccountToken setup a user's service account if it does not exist, then retrieve its token
func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) { func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) {
tokenFunc := func() (string, error) { tokenFunc := func() (string, error) {
memberships, err := manager.dataStore.TeamMembership().TeamMembershipsByUserID(portainer.UserID(userID))
if err != nil {
return "", err
}
teamIds := make([]int, 0, len(memberships))
for _, membership := range memberships {
teamIds = append(teamIds, int(membership.TeamID))
}
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID) endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
if err != nil { if err != nil {
log.Error().Err(err).Msgf("failed fetching environment %d", endpointID)
return "", err return "", err
} }
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace if err := manager.setupUserServiceAccounts(portainer.UserID(userID), endpoint); err != nil {
err = manager.kubecli.SetupUserServiceAccount(userID, teamIds, restrictDefaultNamespace) return "", fmt.Errorf("failed setting-up service account for user %d: %w", userID, err)
if err != nil {
return "", err
} }
return manager.kubecli.GetServiceAccountBearerToken(userID) return manager.kubecli.GetServiceAccountBearerToken(userID)
@@ -49,7 +49,17 @@ func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*
apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`) apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/(api|apis/apps)/v[0-9](\.[0-9])?`)
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
endpointRe := regexp.MustCompile(`([0-9]+)`)
endpointIDMatch := endpointRe.FindAllString(request.RequestURI, 1)
endpointID := 0
if len(endpointIDMatch) > 0 {
endpointID, _ = strconv.Atoi(endpointIDMatch[0])
}
switch { switch {
case strings.EqualFold(requestPath, "/namespaces/portainer/configmaps/portainer-config") && (request.Method == "PUT" || request.Method == "POST"):
defer transport.tokenManager.UpdateUserServiceAccountsForEndpoint(portainer.EndpointID(endpointID))
return transport.executeKubernetesRequest(request)
case strings.EqualFold(requestPath, "/namespaces"): case strings.EqualFold(requestPath, "/namespaces"):
return transport.executeKubernetesRequest(request) return transport.executeKubernetesRequest(request)
case strings.HasPrefix(requestPath, "/namespaces"): case strings.HasPrefix(requestPath, "/namespaces"):
+9 -9
View File
@@ -60,15 +60,15 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService dataservices
} }
} }
// PublicAccess defines a security check for public API environments(endpoints). // PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these environments(endpoints). // No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
return mwSecureHeaders(h) return mwSecureHeaders(h)
} }
// AdminAccess defines a security check for API environments(endpoints) that require an authorization check. // AdminAccess defines a security check for API endpoints that require an authorization check.
// Authentication is required to access these environments(endpoints). // Authentication is required to access these endpoints.
// The administrator role is required to use these environments(endpoints). // The administrator role is required to use these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object // The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation // that might be used later to inside the API operation for extra authorization validation
// and resource filtering. // and resource filtering.
@@ -79,8 +79,8 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler {
return h return h
} }
// RestrictedAccess defines a security check for restricted API environments(endpoints). // RestrictedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these environments(endpoints). // Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object // The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation // that might be used later to inside the API operation for extra authorization validation
// and resource filtering. // and resource filtering.
@@ -104,8 +104,8 @@ func (bouncer *RequestBouncer) TeamLeaderAccess(h http.Handler) http.Handler {
return h return h
} }
// AuthenticatedAccess defines a security check for restricted API environments(endpoints). // AuthenticatedAccess defines a security check for restricted API endpoints.
// Authentication is required to access these environments(endpoints). // Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object // The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to inside the API operation for extra authorization validation // that might be used later to inside the API operation for extra authorization validation
// and resource filtering. // and resource filtering.
+1
View File
@@ -100,6 +100,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
endpointGroup := getAssociatedGroup(&endpoint, groups) endpointGroup := getAssociatedGroup(&endpoint, groups)
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) { if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
endpoint.UserAccessPolicies = nil
endpoints[n] = endpoint endpoints[n] = endpoint
n++ n++
} }
+1
View File
@@ -259,6 +259,7 @@ func (server *Server) Start() error {
var teamMembershipHandler = teammemberships.NewHandler(requestBouncer) var teamMembershipHandler = teammemberships.NewHandler(requestBouncer)
teamMembershipHandler.DataStore = server.DataStore teamMembershipHandler.DataStore = server.DataStore
teamMembershipHandler.K8sClientFactory = server.KubernetesClientFactory
var systemHandler = system.NewHandler(requestBouncer, var systemHandler = system.NewHandler(requestBouncer,
server.Status, server.Status,
@@ -0,0 +1,20 @@
package logoutcontext
import (
"context"
)
const LogoutPrefix = "logout-"
func GetContext(token string) context.Context {
return GetService(logoutToken(token)).GetLogoutCtx()
}
func Cancel(token string) {
GetService(logoutToken(token)).Cancel()
RemoveService(logoutToken(token))
}
func logoutToken(token string) string {
return LogoutPrefix + token
}
+28
View File
@@ -0,0 +1,28 @@
package logoutcontext
import (
"context"
)
type (
Service struct {
ctx context.Context
cancel context.CancelFunc
}
)
func NewService() *Service {
ctx, cancel := context.WithCancel(context.Background())
return &Service{
ctx: ctx,
cancel: cancel,
}
}
func (s *Service) Cancel() {
s.cancel()
}
func (s *Service) GetLogoutCtx() context.Context {
return s.ctx
}
@@ -0,0 +1,34 @@
package logoutcontext
import "sync"
type (
ServiceFactory struct {
mu sync.Mutex
services map[string]*Service
}
)
var serviceFactory = ServiceFactory{
services: make(map[string]*Service),
}
func GetService(token string) *Service {
serviceFactory.mu.Lock()
defer serviceFactory.mu.Unlock()
service, ok := serviceFactory.services[token]
if !ok {
service = NewService()
serviceFactory.services[token] = service
}
return service
}
func RemoveService(token string) {
serviceFactory.mu.Lock()
defer serviceFactory.mu.Unlock()
delete(serviceFactory.services, token)
}
+16
View File
@@ -0,0 +1,16 @@
package securecookie
import (
"crypto/rand"
"io"
)
// GenerateRandomKey generates a random key of specified length
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
func GenerateRandomKey(length int) []byte {
k := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, k); err != nil {
return nil
}
return k
}
+9
View File
@@ -63,3 +63,12 @@ func RemoveIndex[T any](s []T, index int) []T {
s[index] = s[len(s)-1] s[index] = s[len(s)-1]
return s[:len(s)-1] return s[:len(s)-1]
} }
// Map applies the given function to each element of the slice and returns a new slice with the results
func Map[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
+13
View File
@@ -301,6 +301,19 @@ func (s *stubEndpointService) GetNextIdentifier() int {
return len(s.endpoints) return len(s.endpoints)
} }
func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
for _, e := range s.endpoints {
for t := range e.TeamAccessPolicies {
if t == teamID {
endpoints = append(endpoints, e)
}
}
}
return endpoints, nil
}
// WithEndpoints option will instruct testDatastore to return provided environments(endpoints) // WithEndpoints option will instruct testDatastore to return provided environments(endpoints)
func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption { func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
return func(d *testDatastore) { return func(d *testDatastore) {
+4 -1
View File
@@ -90,7 +90,10 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error
} }
func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error { func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error {
cli, err := client.NewClientWithOpts(client.FromEnv) cli, err := client.NewClientWithOpts(
client.FromEnv,
client.WithAPIVersionNegotiation(),
)
if err != nil { if err != nil {
return errors.Wrap(err, "failed to create docker client") return errors.Wrap(err, "failed to create docker client")
} }
+2 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/gorilla/securecookie" "github.com/portainer/portainer/api/internal/securecookie"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -137,6 +137,7 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
ID: portainer.UserID(cl.UserID), ID: portainer.UserID(cl.UserID),
Username: cl.Username, Username: cl.Username,
Role: portainer.UserRole(cl.Role), Role: portainer.UserRole(cl.Role),
Token: token,
}, nil }, nil
} }
} }
+73 -36
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"time" "time"
@@ -154,17 +155,29 @@ func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.En
}, nil }, nil
} }
// CreateClient returns a pointer to a new Clientset instance // CreateClient returns a pointer to a new Clientset instance.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
switch endpoint.Type { switch endpoint.Type {
case portainer.KubernetesLocalEnvironment: case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment:
return buildLocalClient() c, err := factory.CreateConfig(endpoint)
case portainer.AgentOnKubernetesEnvironment: if err != nil {
return factory.buildAgentClient(endpoint) return nil, err
case portainer.EdgeAgentOnKubernetesEnvironment: }
return factory.buildEdgeClient(endpoint) return kubernetes.NewForConfig(c)
} }
return nil, errors.New("unsupported environment type")
}
// CreateConfig returns a pointer to a new kubeconfig ready to create a client.
func (factory *ClientFactory) CreateConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
switch endpoint.Type {
case portainer.KubernetesLocalEnvironment:
return buildLocalConfig()
case portainer.AgentOnKubernetesEnvironment:
return factory.buildAgentConfig(endpoint)
case portainer.EdgeAgentOnKubernetesEnvironment:
return factory.buildEdgeConfig(endpoint)
}
return nil, errors.New("unsupported environment type") return nil, errors.New("unsupported environment type")
} }
@@ -184,20 +197,64 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response,
return rt.roundTripper.RoundTrip(req) return rt.roundTripper.RoundTrip(req)
} }
func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL) var clientURL strings.Builder
if !strings.HasPrefix(endpoint.URL, "http") {
clientURL.WriteString("https://")
}
clientURL.WriteString(endpoint.URL)
clientURL.WriteString("/kubernetes")
return factory.createRemoteClient(endpointURL) signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
config, err := clientcmd.BuildConfigFromFlags(clientURL.String(), "")
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return config, nil
} }
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint) tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed activating tunnel") return nil, errors.Wrap(err, "failed activating tunnel")
} }
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port) endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
return factory.createRemoteClient(endpointURL) config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return config, nil
} }
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) { func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
@@ -227,34 +284,14 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete
} }
func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) { func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) {
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL) config, err := factory.CreateConfig(endpoint)
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to create metrics KubeConfig")
} }
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return &agentHeaderRoundTripper{
signatureHeader: signature,
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
roundTripper: rt,
}
})
return metricsv.NewForConfig(config) return metricsv.NewForConfig(config)
} }
func buildLocalClient() (*kubernetes.Clientset, error) { func buildLocalConfig() (*rest.Config, error) {
config, err := rest.InClusterConfig() config, err := rest.InClusterConfig()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -263,7 +300,7 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
config.QPS = DefaultKubeClientQPS config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst config.Burst = DefaultKubeClientBurst
return kubernetes.NewForConfig(config) return config, nil
} }
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error { func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {
+11 -1
View File
@@ -301,6 +301,8 @@ type (
// StackDeploymentInfo records the information of a deployed stack // StackDeploymentInfo records the information of a deployed stack
StackDeploymentInfo struct { StackDeploymentInfo struct {
// Version is the version of the stack and also is the deployed version in edge agent
Version int `json:"Version"`
// FileVersion is the version of the stack file, used to detect changes // FileVersion is the version of the stack file, used to detect changes
FileVersion int `json:"FileVersion"` FileVersion int `json:"FileVersion"`
// ConfigHash is the commit hash of the git repository used for deploying the stack // ConfigHash is the commit hash of the git repository used for deploying the stack
@@ -1267,6 +1269,7 @@ type (
Username string Username string
Role UserRole Role UserRole
ForceChangePassword bool ForceChangePassword bool
Token string
} }
// TunnelDetails represents information associated to a tunnel // TunnelDetails represents information associated to a tunnel
@@ -1401,6 +1404,7 @@ type (
StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
StoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, data []byte) (string, error) StoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, data []byte) (string, error)
UpdateStoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) UpdateStoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
UpdateStoreStackFileFromBytesByVersion(stackIdentifier, fileName string, version int, commitHash string, data []byte) (string, error)
RemoveStackFileBackup(stackIdentifier, fileName string) error RemoveStackFileBackup(stackIdentifier, fileName string) error
RemoveStackFileBackupByVersion(stackIdentifier string, version int, fileName string) error RemoveStackFileBackupByVersion(stackIdentifier string, version int, fileName string) error
RollbackStackFile(stackIdentifier, fileName string) error RollbackStackFile(stackIdentifier, fileName string) error
@@ -1557,7 +1561,7 @@ type (
const ( const (
// APIVersion is the version number of the Portainer API // APIVersion is the version number of the Portainer API
APIVersion = "2.19.0" APIVersion = "2.19.4"
// Edition is what this edition of Portainer is called // Edition is what this edition of Portainer is called
Edition = PortainerCE Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1678,6 +1682,12 @@ const (
EdgeStackStatusDeploying EdgeStackStatusDeploying
// EdgeStackStatusRemoving represents an Edge stack which is being removed // EdgeStackStatusRemoving represents an Edge stack which is being removed
EdgeStackStatusRemoving EdgeStackStatusRemoving
// EdgeStackStatusPausedDeploying represents a paused Edge stack
EdgeStackStatusPausedDeploying
// EdgeStackStatusRollingBack represents an Edge stack which is being rolled back
EdgeStackStatusRollingBack
// EdgeStackStatusRolledBack represents an Edge stack which has rolled back
EdgeStackStatusRolledBack
) )
const ( const (
+27 -5
View File
@@ -17,6 +17,18 @@ type Scheduler struct {
mu sync.Mutex mu sync.Mutex
} }
type PermanentError struct {
err error
}
func NewPermanentError(err error) *PermanentError {
return &PermanentError{err: err}
}
func (e *PermanentError) Error() string {
return e.err.Error()
}
func NewScheduler(ctx context.Context) *Scheduler { func NewScheduler(ctx context.Context) *Scheduler {
crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger))) crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
crontab.Start() crontab.Start()
@@ -84,14 +96,24 @@ func (s *Scheduler) StopJob(jobID string) error {
func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string { func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
j := cron.FuncJob(func() { jobFn := cron.FuncJob(func() {
if err := job(); err != nil { err := job()
log.Debug().Msg("job returned an error") if err == nil {
cancel() return
} }
var permErr *PermanentError
if errors.As(err, &permErr) {
log.Error().Err(permErr).Msg("job returned a permanent error, it will be stopped")
cancel()
return
}
log.Error().Err(err).Msg("job returned an error, it will be rescheduled")
}) })
entryID := s.crontab.Schedule(cron.Every(duration), j) entryID := s.crontab.Schedule(cron.Every(duration), jobFn)
s.mu.Lock() s.mu.Lock()
s.activeJobs[entryID] = cancel s.activeJobs[entryID] = cancel
+24 -2
View File
@@ -49,7 +49,7 @@ func Test_JobCanBeStopped(t *testing.T) {
assert.False(t, workDone, "job shouldn't had a chance to run") assert.False(t, workDone, "job shouldn't had a chance to run")
} }
func Test_JobShouldStop_UponError(t *testing.T) { func Test_JobShouldStop_UponPermError(t *testing.T) {
s := NewScheduler(context.Background()) s := NewScheduler(context.Background())
defer s.Shutdown() defer s.Shutdown()
@@ -58,7 +58,7 @@ func Test_JobShouldStop_UponError(t *testing.T) {
s.StartJobEvery(jobInterval, func() error { s.StartJobEvery(jobInterval, func() error {
acc++ acc++
close(ch) close(ch)
return fmt.Errorf("failed") return NewPermanentError(fmt.Errorf("failed"))
}) })
<-time.After(3 * jobInterval) <-time.After(3 * jobInterval)
@@ -66,6 +66,28 @@ func Test_JobShouldStop_UponError(t *testing.T) {
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error") assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
} }
func Test_JobShouldNotStop_UponError(t *testing.T) {
s := NewScheduler(context.Background())
defer s.Shutdown()
var acc int
ch := make(chan struct{})
s.StartJobEvery(jobInterval, func() error {
acc++
if acc == 2 {
close(ch)
return NewPermanentError(fmt.Errorf("failed"))
}
return errors.New("non-permanent error")
})
<-time.After(3 * jobInterval)
<-ch
assert.Equal(t, 2, acc)
}
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) { func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
s := NewScheduler(context.Background()) s := NewScheduler(context.Background())
+46 -5
View File
@@ -1,13 +1,17 @@
package deployments package deployments
import ( import (
"crypto/tls"
"fmt" "fmt"
"time" "time"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/agent"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/git/update" "github.com/portainer/portainer/api/git/update"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -29,7 +33,9 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack") log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
stack, err := datastore.Stack().Read(stackID) stack, err := datastore.Stack().Read(stackID)
if err != nil { if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
} else if err != nil {
return errors.WithMessagef(err, "failed to get the stack %v", stackID) return errors.WithMessagef(err, "failed to get the stack %v", stackID)
} }
@@ -38,7 +44,15 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
} }
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID) endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
if err != nil { if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(
errors.WithMessagef(err,
"failed to find the environment %v associated to the stack %v",
stack.EndpointID,
stack.ID,
),
)
} else if err != nil {
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID) return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
} }
@@ -59,6 +73,10 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return &StackAuthorMissingErr{int(stack.ID), author} return &StackAuthorMissingErr{int(stack.ID), author}
} }
if !isEnvironmentOnline(endpoint) {
return nil
}
var gitCommitChangedOrForceUpdate bool var gitCommitChangedOrForceUpdate bool
if !stack.FromAppTemplate { if !stack.FromAppTemplate {
updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stackID), stack.GitConfig, false, false, stack.ProjectPath) updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stackID), stack.GitConfig, false, false, stack.ProjectPath)
@@ -78,14 +96,16 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
} }
registries, err := getUserRegistries(datastore, user, endpoint.ID) registries, err := getUserRegistries(datastore, user, endpoint.ID)
if err != nil { if dataservices.IsErrObjectNotFound(err) {
return scheduler.NewPermanentError(err)
} else if err != nil {
return err return err
} }
switch stack.Type { switch stack.Type {
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false) err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
} else { } else {
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false) err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
@@ -95,7 +115,7 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID) return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
} }
case portainer.DockerSwarmStack: case portainer.DockerSwarmStack:
if stackutils.IsGitStack(stack) { if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true) err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
} else { } else {
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true) err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
@@ -116,6 +136,8 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type) return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
} }
stack.Status = portainer.StackStatusActive
if err := datastore.Stack().Update(stack.ID, stack); err != nil { if err := datastore.Stack().Update(stack.ID, stack); err != nil {
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID) return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
} }
@@ -147,3 +169,22 @@ func getUserRegistries(datastore dataservices.DataStore, user *portainer.User, e
return filteredRegistries, nil return filteredRegistries, nil
} }
func isEnvironmentOnline(endpoint *portainer.Endpoint) bool {
if endpoint.Type != portainer.AgentOnDockerEnvironment &&
endpoint.Type != portainer.AgentOnKubernetesEnvironment {
return true
}
var err error
var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS {
tlsConfig, err = crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return false
}
}
_, _, err = agent.GetAgentVersionAndPlatform(endpoint.URL, tlsConfig)
return err == nil
}
+102 -1
View File
@@ -1,18 +1,78 @@
package deployments package deployments
import ( import (
"context"
"crypto/tls"
"errors" "errors"
"net/http"
"strconv"
"strings" "strings"
"testing" "testing"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
const localhostCert = `-----BEGIN CERTIFICATE-----
MIIEOjCCAiKgAwIBAgIRALg8rJET2/9LjKSxHj0dQhYwDQYJKoZIhvcNAQELBQAw
FzEVMBMGA1UEAxMMUG9ydGFpbmVyIENBMB4XDTIzMTAxMTE5NDcxMVoXDTI1MDQx
MTE5NTM0MVowFDESMBAGA1UEAxMJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmz
YqOIYGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3
c2FzQ3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw
6scV6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X0
38Pu178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdt
Z+GN26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABo4GDMIGAMA4GA1Ud
DwEB/wQEAwIDuDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0O
BBYEFPCefmK5Szzlfs8FRCa5+kRCIEWuMB8GA1UdIwQYMBaAFKZZ074SR/ajD3zE
gxpLGRvFT3XAMA8GA1UdEQQIMAaHBH8AAAEwDQYJKoZIhvcNAQELBQADggIBABcQ
/WPSUpuQvrcVBsmIlOMz74cDZYuIDls/mAcB/yP3mm+oWlO0qvH/F/BMs1P/bkgj
fByQZq8Zmi6/TEZNlGvW7KGx077VxDKi8jd1jL3gLDPmkFjYuGeIWQusgxBu1y3m
0WoTTqnkoism1mzV/dgNwrm3YQIV4H/fi9EEdQSm0UFRTKSAGBkwS7N2pmNb5yQO
U8glFpyznCv4evDJbs/JUUXKYExgFFhWUd25P7iBRLXg/BFfqdSTiUGUj/Msz0pO
Evqmq78eIiXjyyKSxzve6/mEIeq6AE3AC9zH+fwTd6Mhp+T2P/S/iO4EU19IMR4m
sbNBd6h/3GvRekO1KbqQ42awuMnxvWT0NVclSxiU1lMpAmRmk/w9z7wB3r4n7oh4
iiOTl5VSw1UBkcLDOJw+HB/FU2PdVFfIJKRfjLCZOGrcJX9vEcz7dYGpB5HrdqOc
/8q5j1g6f/pGE+20HITrtz6ChguETzqw5dLNeKeolC6bVH8yEtmpnP2n8VPnT9Di
V+hnONcJ+wd/dkBqabGr7LPG24Kj1F2Zp3CDDvJA94FaEsgaLfSg3JD+43uRCOWM
RuqU8bGuhQRqilR2dSIOrFaW2+MeUHsb24cUn/pkHqKpSg+RBEnf6QfGDlIgqYEl
19f/HFVBc/a8lM/D81lMyDbjQ9zH4LDYj4ipBbkL
-----END CERTIFICATE-----`
const localhostKey = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAx4nNGiwcCqUCxZyVLIHqvjTy20ZtZDVCedssTv1W5tmzYqOI
YGaW3CqzlRn6vBHu9bMHXef4+XfS0igKBn76MAKn5IcTccIWIal+5jq48pI3c2Fz
Q3qNujX2zqZPjAjhJnVeVCP3kJu4wUtuubswLPBVLdktGa6EkL+8nu6o0Phw6scV
6s3gUmQk5/lpH4FIff8M7NAdTOxiFImQ1M0vplKtaEeiCnskpgyI8CbZl7X038Pu
178W3+LqB7N4iMy2gKnBwjsXzw/+1dfUGkKjYdDBD+kNEKrQ4dwkjkrkQVdtZ+GN
26NvXHoeeyX/MLnVgdLbiIjvsf0DDIhabKqTcwIDAQABAoIBAQCqSP6BPG195A52
iEeCISksw9ERsou+fflKNvIcQvV7swP0xOyooERUhhiVwQMKpx9QDUXXLRV8CHch
JExR+OEYQdv4GhJM/b6XYafLYQfe80thKyQLzTXQWSdUeffe4OEMShODKOKoRUyp
oO9Qj9/wKfX3V6S2iwnU4dxdofztv+YP9rYQyjnhKbv/9OfeCp2Pb9eFKKRsA+QQ
xneDz1+wr8ToTuiTn8HBPNSeSAKvhzXuzyluI7VAetRloNgCtumrA9kpVbW2cDgE
Gk0q3RY125ejFELQO/cOJFuBsqoJlvPxzg8/vHyfyF9hFMqbqvcUw2e1eqHpnJd5
dP4+ZGYZAoGBAOOFuPXMLBts0rN9mfNbVfx36H+aOCL77SafZvWm0D+rH69QN3/q
/ZSWQEjwH5Tzn1e+NVcl/Um2vL/dIyEGBklXQ7yAyJo25gpEOD/rt1U94HKzMOwy
yKtsKghRAOx0piie7ORS6MGbEOQxU3/1Eg1uvd0qoSnALqJ/le75QpFXAoGBAOCD
aZQTszzDddr1cFPzLyqjIGJWfPcDYSONXVcCeQmhvC7mkfw9SWdIfku7JbdNgFYq
ZAAU0klsLX0lEe8f4A12FnHNylKoxmTWdE3wWPptejdA1KUgzt/2kNljgOMFuY0Q
rlCEW/Jabrg5aFMwVVG8bHLZR0xalfniDvXLvnFFAoGACdztJLKiIto31BIYz2Th
OF2WVZnA3ztej3MPioydsHThnb7zePcd4QgWZ1MJe3KIMMyNEWcTMNPcINEcSb0y
HpHK3OwURiMlG8LTUWoNe4OALFi6QTL+YfgBZnTkflucLFyfVlKFxobLV6kPvpdI
Hg7z6heD/wRWwTKYtFBX42cCgYBIeoQJ9rYlRqB0eEm0AEzYweLBfFRJVgD0/j0E
ytqSPnFG3s6AFLTur9t9zUPmwhFNP9Aaqp4cb9zbiq0YejzVe6rRQHMxbiTmBslz
I8VFyzPqRHahfE7sxGeMlm/UWlPFc34ipigcvA8EUBwaxv60LVUBWp2Gy7OhANZ9
iTHI1QKBgQCdHFj9dnbpaEHA426CoaPsyj5cv2nBLRf8p1cs71sq+qQOGlGJfajm
L9x22ol5c5rToZa1qKSnSdSDCud298MyRujMUy2UcUKHeNs3MK9AT41sDv266I7b
vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg==
-----END RSA PRIVATE KEY-----`
type noopDeployer struct{} type noopDeployer struct{}
// without unpacker // without unpacker
@@ -54,6 +114,42 @@ func (s *noopDeployer) StopRemoteSwarmStack(stack *portainer.Stack, endpoint *po
return nil return nil
} }
func agentServer(t *testing.T) string {
h := http.NewServeMux()
h.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(portainer.PortainerAgentHeader, "v2.19.0")
w.Header().Set(portainer.HTTPResponseAgentPlatform, strconv.Itoa(int(portainer.AgentPlatformDocker)))
response.Empty(w)
})
cert, err := tls.X509KeyPair([]byte(localhostCert), []byte(localhostKey))
require.NoError(t, err)
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
}
l, err := tls.Listen("tcp", "127.0.0.1:0", tlsConfig)
require.NoError(t, err)
s := &http.Server{
Handler: h,
}
go func() {
err := s.Serve(l)
require.ErrorIs(t, err, http.ErrServerClosed)
}()
t.Cleanup(func() {
s.Shutdown(context.Background())
})
return "http://" + l.Addr().String()
}
func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) { func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, true) _, store := datastore.MustNewTestStore(t, true, true)
@@ -114,7 +210,12 @@ func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
assert.NoError(t, err, "error creating an admin") assert.NoError(t, err, "error creating an admin")
err = store.Endpoint().Create(&portainer.Endpoint{ err = store.Endpoint().Create(&portainer.Endpoint{
ID: 0, ID: 0,
URL: agentServer(t),
TLSConfig: portainer.TLSConfiguration{
TLS: true,
TLSSkipVerify: true,
},
}) })
assert.NoError(t, err, "error creating environment") assert.NoError(t, err, "error creating environment")
+28 -3
View File
@@ -1,7 +1,9 @@
package deployments package deployments
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
@@ -12,6 +14,7 @@ import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/pkg/stdcopy"
"github.com/pkg/errors" "github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
@@ -184,16 +187,18 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
case <-statusCh: case <-statusCh:
} }
stdErr := &bytes.Buffer{}
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}) out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
if err != nil { if err != nil {
log.Error().Err(err).Msg("unable to get logs from unpacker container") log.Error().Err(err).Msg("unable to get logs from unpacker container")
} else { } else {
outputBytes, err := io.ReadAll(out) _, err = stdcopy.StdCopy(io.Discard, stdErr, out)
if err != nil { if err != nil {
log.Error().Err(err).Msg("unable to parse logs from unpacker container") log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
} else { } else {
log.Info(). log.Info().
Str("output", string(outputBytes)). Str("output", stdErr.String()).
Msg("Stack deployment output") Msg("Stack deployment output")
} }
} }
@@ -204,6 +209,26 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
} }
if status.State.ExitCode != 0 { if status.State.ExitCode != 0 {
dec := json.NewDecoder(stdErr)
for {
errorStruct := struct {
Level string
Error string
}{}
if err := dec.Decode(&errorStruct); errors.Is(err, io.EOF) {
break
} else if err != nil {
log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
continue
}
if errorStruct.Level == "error" {
return fmt.Errorf("an error occurred while running unpacker container with exit code %d: %s", status.State.ExitCode, errorStruct.Error)
}
}
return fmt.Errorf("an error occurred while running unpacker container with exit code %d", status.State.ExitCode) return fmt.Errorf("an error occurred while running unpacker container with exit code %d", status.State.ExitCode)
} }
@@ -84,7 +84,7 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
return err return err
} }
} }
if stackutils.IsGitStack(config.stack) { if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate) return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
} }
@@ -78,7 +78,7 @@ func (config *SwarmStackDeploymentConfig) Deploy() error {
} }
} }
if stackutils.IsGitStack(config.stack) { if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteSwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage) return config.StackDeployer.DeployRemoteSwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage)
} }
+7
View File
@@ -47,3 +47,10 @@ func SanitizeLabel(value string) string {
func IsGitStack(stack *portainer.Stack) bool { func IsGitStack(stack *portainer.Stack) bool {
return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0 return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0
} }
// IsRelativePathStack checks if the stack is a git stack or not
func IsRelativePathStack(stack *portainer.Stack) bool {
// Always return false in CE
// This function is only for code consistency with EE
return false
}
+3 -3
View File
@@ -87,7 +87,7 @@
--orange-1: #e86925; --orange-1: #e86925;
--BE-only: var(--ui-warning-7); --BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color); --text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color); --text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
@@ -259,8 +259,7 @@
/* Dark Theme */ /* Dark Theme */
[theme='dark'] { [theme='dark'] {
--BE-only: var(--ui-blue-8); --BE-only: var(--ui-gray-6);
--bg-BE-only: rgba(225, 223, 223, 0.08);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color); --text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color); --text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
@@ -434,6 +433,7 @@
/* High Contrast Theme */ /* High Contrast Theme */
[theme='highcontrast'] { [theme='highcontrast'] {
--BE-only: var(--ui-gray-6);
--text-log-viewer-color-json-grey: var(--text-log-viewer-color); --text-log-viewer-color-json-grey: var(--text-log-viewer-color);
--text-log-viewer-color-json-magenta: var(--text-log-viewer-color); --text-log-viewer-color-json-magenta: var(--text-log-viewer-color);
--text-log-viewer-color-json-yellow: var(--text-log-viewer-color); --text-log-viewer-color-json-yellow: var(--text-log-viewer-color);
+11 -10
View File
@@ -7,10 +7,8 @@ angular.module('portainer.docker').factory('ConfigHelper', [
return { return {
Id: config.ConfigID, Id: config.ConfigID,
Name: config.ConfigName, Name: config.ConfigName,
FileName: config.File.Name, ...(config.File ? { FileName: config.File.Name, Uid: config.File.UID, Gid: config.File.GID, Mode: config.File.Mode } : {}),
Uid: config.File.UID, credSpec: !!config.Runtime,
Gid: config.File.GID,
Mode: config.File.Mode,
}; };
} }
return {}; return {};
@@ -20,12 +18,15 @@ angular.module('portainer.docker').factory('ConfigHelper', [
return { return {
ConfigID: config.Id, ConfigID: config.Id,
ConfigName: config.Name, ConfigName: config.Name,
File: { File: config.credSpec
Name: config.FileName || config.Name, ? null
UID: config.Uid || '0', : {
GID: config.Gid || '0', Name: config.FileName || config.Name,
Mode: config.Mode || 292, UID: config.Uid || '0',
}, GID: config.Gid || '0',
Mode: config.Mode || 292,
},
Runtime: config.credSpec ? {} : null,
}; };
} }
return {}; return {};
@@ -66,7 +66,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
} }
const params = { const params = {
token: LocalStorage.getJWT(),
endpointId: $state.params.endpointId, endpointId: $state.params.endpointId,
id: attachId, id: attachId,
}; };
@@ -107,7 +106,6 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
ContainerService.createExec(execConfig) ContainerService.createExec(execConfig)
.then(function success(data) { .then(function success(data) {
const params = { const params = {
token: LocalStorage.getJWT(),
endpointId: $state.params.endpointId, endpointId: $state.params.endpointId,
id: data.Id, id: data.Id,
}; };
@@ -166,6 +164,9 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
if ($transition$.params().nodeName) { if ($transition$.params().nodeName) {
url += '&nodeName=' + $transition$.params().nodeName; url += '&nodeName=' + $transition$.params().nodeName;
} }
url += '&token=' + LocalStorage.getJWT();
if (url.indexOf('https') > -1) { if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://'); url = url.replace('https://', 'wss://');
} else { } else {
+34 -21
View File
@@ -1,6 +1,7 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal'; import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDelete } from '@@/modals/confirm';
angular.module('portainer.docker').controller('ImageController', [ angular.module('portainer.docker').controller('ImageController', [
'$async', '$async',
@@ -120,30 +121,42 @@ angular.module('portainer.docker').controller('ImageController', [
} }
$scope.removeTag = function (repository) { $scope.removeTag = function (repository) {
ImageService.deleteImage(repository, false) return $async(async () => {
.then(function success() { if (!(await confirmDelete('Are you sure you want to delete this tag?'))) {
if ($scope.image.RepoTags.length === 1) { return;
Notifications.success('Image successfully deleted', repository); }
$state.go('docker.images', {}, { reload: true });
} else { ImageService.deleteImage(repository, false)
Notifications.success('Tag successfully deleted', repository); .then(function success() {
$state.go('docker.images.image', { id: $transition$.params().id }, { reload: true }); if ($scope.image.RepoTags.length === 1) {
} Notifications.success('Image successfully deleted', repository);
}) $state.go('docker.images', {}, { reload: true });
.catch(function error(err) { } else {
Notifications.error('Failure', err, 'Unable to remove image'); Notifications.success('Tag successfully deleted', repository);
}); $state.go('docker.images.image', { id: $transition$.params().id }, { reload: true });
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
});
}; };
$scope.removeImage = function (id) { $scope.removeImage = function (id) {
ImageService.deleteImage(id, false) return $async(async () => {
.then(function success() { if (!(await confirmDelete('Deleting this image will also delete all associated tags. Are you sure you want to delete this image?'))) {
Notifications.success('Image successfully deleted', id); return;
$state.go('docker.images', {}, { reload: true }); }
})
.catch(function error(err) { ImageService.deleteImage(id, false)
Notifications.error('Failure', err, 'Unable to remove image'); .then(function success() {
}); Notifications.success('Image successfully deleted', id);
$state.go('docker.images', {}, { reload: true });
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
});
});
}; };
function exportImage(image) { function exportImage(image) {
+3 -2
View File
@@ -57,7 +57,8 @@ angular.module('portainer.docker').controller('ImagesController', [
function confirmImageForceRemoval() { function confirmImageForceRemoval() {
return confirmDestructive({ return confirmDestructive({
title: 'Are you sure?', title: 'Are you sure?',
message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.', message:
"Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?",
confirmButton: buildConfirmButton('Remove the image', 'danger'), confirmButton: buildConfirmButton('Remove the image', 'danger'),
}); });
} }
@@ -65,7 +66,7 @@ angular.module('portainer.docker').controller('ImagesController', [
function confirmRegularRemove() { function confirmRegularRemove() {
return confirmDestructive({ return confirmDestructive({
title: 'Are you sure?', title: 'Are you sure?',
message: 'Removing the image will remove all tags associated to that image. Are you sure you want to remove the image?', message: 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?',
confirmButton: buildConfirmButton('Remove the image', 'danger'), confirmButton: buildConfirmButton('Remove the image', 'danger'),
}); });
} }
@@ -4,7 +4,7 @@
<rd-widget-body classes="no-padding"> <rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate"> <div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
Add a config: Add a config:
<select class="form-control !h-[30px] !text-[13px]" ng-options="config.Name for config in configs | orderBy: 'Name'" ng-model="newConfig"> <select class="form-control !h-[30px] !text-[13px]" ng-options="config.Name for config in filterConfigs(configs) | orderBy: 'Name'" ng-model="newConfig">
<option selected disabled hidden value="">Select a config</option> <option selected disabled hidden value="">Select a config</option>
</select> </select>
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)"> <pr-icon icon="'plus'"></pr-icon> add config </a> <a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)"> <pr-icon icon="'plus'"></pr-icon> add config </a>
@@ -22,10 +22,10 @@
</thead> </thead>
<tbody> <tbody>
<tr ng-repeat="config in service.ServiceConfigs"> <tr ng-repeat="config in service.ServiceConfigs">
<td
><a ui-sref="docker.configs.config({id: config.Id})">{{ config.Name }}</a></td
>
<td> <td>
<a ui-sref="docker.configs.config({id: config.Id})">{{ config.Name }}</a>
</td>
<td ng-if="!config.credSpec">
<input <input
class="form-control" class="form-control"
ng-model="config.FileName" ng-model="config.FileName"
@@ -33,11 +33,13 @@
placeholder="e.g. /path/in/container" placeholder="e.g. /path/in/container"
required required
disable-authorization="DockerServiceUpdate" disable-authorization="DockerServiceUpdate"
ng-disabled="config.credSpec"
/> />
</td> </td>
<td>{{ config.Uid }}</td> <td ng-if="!config.credSpec">{{ config.Uid }}</td>
<td>{{ config.Gid }}</td> <td ng-if="!config.credSpec">{{ config.Gid }}</td>
<td>{{ config.Mode }}</td> <td ng-if="!config.credSpec">{{ config.Mode }}</td>
<td ng-if="config.credSpec" colspan="4">Credential Spec</td>
<td authorization="DockerServiceUpdate"> <td authorization="DockerServiceUpdate">
<button class="btn btn-dangerlight pull-right" type="button" ng-click="removeConfig(service, $index)" ng-disabled="isUpdating"> <button class="btn btn-dangerlight pull-right" type="button" ng-click="removeConfig(service, $index)" ng-disabled="isUpdating">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon> <pr-icon icon="'trash-2'" size="'md'"></pr-icon>
@@ -91,6 +91,7 @@ angular.module('portainer.docker').controller('ServiceController', [
endpoint endpoint
) { ) {
$scope.resourceType = ResourceControlType.Service; $scope.resourceType = ResourceControlType.Service;
$scope.WebhookExists = false;
$scope.onUpdateResourceControlSuccess = function () { $scope.onUpdateResourceControlSuccess = function () {
$state.reload(); $state.reload();
@@ -462,6 +463,27 @@ angular.module('portainer.docker').controller('ServiceController', [
config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : []; config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : [];
config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : []; config.TaskTemplate.ContainerSpec.Configs = service.ServiceConfigs ? service.ServiceConfigs.map(ConfigHelper.configConfig) : [];
// support removal and (future) editing of credential specs
const credSpec = service.ServiceConfigs.find((config) => config.credSpec);
const credSpecId = credSpec ? credSpec.Id : '';
const oldCredSpecId =
(config.TaskTemplate.ContainerSpec.Privileges &&
config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec &&
config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec.Config) ||
'';
if (oldCredSpecId && !credSpecId) {
delete config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec;
} else if (credSpec && oldCredSpecId !== credSpec) {
config.TaskTemplate.ContainerSpec.Privileges = {
...(config.TaskTemplate.ContainerSpec.Privileges || {}),
CredentialSpec: {
...((config.TaskTemplate.ContainerSpec.Privileges && config.TaskTemplate.ContainerSpec.Privileges.CredentialSpec) || {}),
Config: credSpec,
},
};
}
config.TaskTemplate.ContainerSpec.Hosts = service.Hosts ? ServiceHelper.translateHostnameIPToHostsEntries(service.Hosts) : []; config.TaskTemplate.ContainerSpec.Hosts = service.Hosts ? ServiceHelper.translateHostnameIPToHostsEntries(service.Hosts) : [];
if (service.Mode === 'replicated') { if (service.Mode === 'replicated') {
@@ -582,8 +604,7 @@ angular.module('portainer.docker').controller('ServiceController', [
} }
$scope.updateService = function updateService(service) { $scope.updateService = function updateService(service) {
let config = {}; const config = buildChanges(service);
service, (config = buildChanges(service));
ServiceService.update(service, config).then( ServiceService.update(service, config).then(
function (data) { function (data) {
if (data.message && data.message.match(/^rpc error:/)) { if (data.message && data.message.match(/^rpc error:/)) {
@@ -735,7 +756,6 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.isAdmin = Authentication.isAdmin(); $scope.isAdmin = Authentication.isAdmin();
$scope.availableNetworks = data.availableNetworks; $scope.availableNetworks = data.availableNetworks;
$scope.swarmNetworks = _.filter($scope.availableNetworks, (network) => network.Scope === 'swarm'); $scope.swarmNetworks = _.filter($scope.availableNetworks, (network) => network.Scope === 'swarm');
$scope.WebhookExists = false;
const serviceNetworks = _.uniqBy(_.concat($scope.service.Model.Spec.Networks || [], $scope.service.Model.Spec.TaskTemplate.Networks || []), 'Target'); const serviceNetworks = _.uniqBy(_.concat($scope.service.Model.Spec.Networks || [], $scope.service.Model.Spec.TaskTemplate.Networks || []), 'Target');
const networks = _.filter( const networks = _.filter(
@@ -832,6 +852,15 @@ angular.module('portainer.docker').controller('ServiceController', [
return networks.filter((network) => !network.Ingress && (network.Id === current.Id || $scope.service.Networks.every((serviceNetwork) => network.Id !== serviceNetwork.Id))); return networks.filter((network) => !network.Ingress && (network.Id === current.Id || $scope.service.Networks.every((serviceNetwork) => network.Id !== serviceNetwork.Id)));
} }
$scope.filterConfigs = filterConfigs;
function filterConfigs(configs) {
if (!configs) {
return [];
}
return configs.filter((config) => $scope.service.ServiceConfigs.every((serviceConfig) => config.Id !== serviceConfig.Id));
}
function updateServiceArray(service, name) { function updateServiceArray(service, name) {
previousServiceValues.push(name); previousServiceValues.push(name);
service.hasChanges = true; service.hasChanges = true;
+5
View File
@@ -72,6 +72,11 @@ angular
component: 'editEdgeStackView', component: 'editEdgeStackView',
}, },
}, },
params: {
status: {
dynamic: true,
},
},
}; };
const edgeJobs = { const edgeJobs = {
-1
View File
@@ -92,7 +92,6 @@ export const componentsModule = angular
'query', 'query',
'title', 'title',
'data-cy', 'data-cy',
'hideEnvironmentIds',
]) ])
) )
.component( .component(
@@ -1,4 +1,5 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { confirmDelete } from '@@/modals/confirm';
export class EdgeGroupsController { export class EdgeGroupsController {
/* @ngInject */ /* @ngInject */
@@ -26,6 +27,10 @@ export class EdgeGroupsController {
} }
async removeActionAsync(selectedItems) { async removeActionAsync(selectedItems) {
if (!(await confirmDelete('Do you want to remove the selected Edge Group(s)?'))) {
return;
}
for (let item of selectedItems) { for (let item of selectedItems) {
try { try {
await this.EdgeGroupService.remove(item.Id); await this.EdgeGroupService.remove(item.Id);
@@ -15,7 +15,7 @@ export class EdgeJobsViewController {
} }
removeAction(selectedItems) { removeAction(selectedItems) {
confirmDelete('Do you want to remove the selected edge job(s)?').then((confirmed) => { confirmDelete('Do you want to remove the selected Edge job(s)?').then((confirmed) => {
if (!confirmed) { if (!confirmed) {
return; return;
} }
@@ -14,9 +14,13 @@
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
Switch to advanced mode to copy and paste multiple key/values Switch to advanced mode to copy and paste multiple key/values
</div> </div>
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple"> <div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'configmap'">
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
Generate a configuration entry per line, use YAML format Generate a ConfigMap entry per line, use YAML format
</div>
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'secret'">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Generate a Secret entry per line, use YAML format
</div> </div>
</div> </div>
@@ -8,5 +8,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
isValid: '=', isValid: '=',
isCreation: '=', isCreation: '=',
isEditorDirty: '=', isEditorDirty: '=',
type: '<',
}, },
}); });
+1 -2
View File
@@ -16,7 +16,6 @@ import {
ApplicationSummaryWidget, ApplicationSummaryWidget,
ApplicationDetailsWidget, ApplicationDetailsWidget,
} from '@/react/kubernetes/applications/DetailsView'; } from '@/react/kubernetes/applications/DetailsView';
import { withUserProvider } from '@/react/test-utils/withUserProvider';
import { withFormValidation } from '@/react-tools/withFormValidation'; import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withCurrentUser } from '@/react-tools/withCurrentUser';
@@ -104,7 +103,7 @@ export const ngModule = angular
.component( .component(
'applicationDetailsWidget', 'applicationDetailsWidget',
r2a( r2a(
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))), withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsWidget))),
[] []
) )
); );
@@ -352,7 +352,7 @@
<!-- #region CONFIGMAPS --> <!-- #region CONFIGMAPS -->
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 vertical-center"> <div class="col-sm-12 vertical-center">
<label class="control-label !pt-0 text-left">ConfigMap</label> <label class="control-label !pt-0 text-left">ConfigMaps</label>
</div> </div>
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length"> <div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length">
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
@@ -503,7 +503,7 @@
<!-- #region SECRETS --> <!-- #region SECRETS -->
<div class="form-group"> <div class="form-group">
<div class="col-sm-12 vertical-center pt-2.5"> <div class="col-sm-12 vertical-center pt-2.5">
<label class="control-label !pt-0 text-left">Secret</label> <label class="control-label !pt-0 text-left">Secrets</label>
</div> </div>
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Secrets.length"> <div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Secrets.length">
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
@@ -1302,16 +1302,18 @@
</div> </div>
<!-- kubernetes services options --> <!-- kubernetes services options -->
<kube-services-form <div ng-if="ctrl.formValues.ResourcePool">
on-change="(ctrl.onServicesChange)" <kube-services-form
values="ctrl.formValues.Services" on-change="(ctrl.onServicesChange)"
load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()" values="ctrl.formValues.Services"
app-name="ctrl.formValues.Name" load-balancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
selector="ctrl.formValues.Selector" app-name="ctrl.formValues.Name"
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}" selector="ctrl.formValues.Selector"
is-edit-mode="ctrl.state.isEdit" validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
namespace="ctrl.formValues.ResourcePool.Namespace.Name" is-edit-mode="ctrl.state.isEdit"
></kube-services-form> namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></kube-services-form>
</div>
<!-- kubernetes services options --> <!-- kubernetes services options -->
<!-- summary --> <!-- summary -->
@@ -1353,15 +1355,17 @@
</div> </div>
</div> </div>
<!-- kubernetes services options --> <!-- kubernetes services options -->
<kube-services-form <div ng-if="ctrl.formValues.ResourcePool">
on-change="(ctrl.onServicesChange)" <kube-services-form
values="ctrl.formValues.Services" on-change="(ctrl.onServicesChange)"
app-name="ctrl.formValues.Name" values="ctrl.formValues.Services"
selector="ctrl.formValues.Selector" app-name="ctrl.formValues.Name"
validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}" selector="ctrl.formValues.Selector"
is-edit-mode="ctrl.state.isEdit" validation-data="{nodePortServices: ctrl.state.nodePortServices, formServices: ctrl.formValues.Services, ingressPaths: ctrl.ingressPaths, originalIngressPaths: ctrl.originalIngressPaths}"
namespace="ctrl.formValues.ResourcePool.Namespace.Name" is-edit-mode="ctrl.state.isEdit"
></kube-services-form> namespace="ctrl.formValues.ResourcePool.Namespace.Name"
></kube-services-form>
</div>
<!-- kubernetes services options --> <!-- kubernetes services options -->
</div> </div>
@@ -1376,7 +1380,7 @@
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
type="button" type="button"
class="btn btn-primary btn-sm !ml-0" class="btn btn-primary btn-sm !ml-0"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid() || ctrl.hasPortErrors()" ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid() || ctrl.hasPortErrors() || !ctrl.formValues.ResourcePool"
ng-click="ctrl.deployApplication()" ng-click="ctrl.deployApplication()"
button-spinner="ctrl.state.actionInProgress" button-spinner="ctrl.state.actionInProgress"
data-cy="k8sAppCreate-deployButton" data-cy="k8sAppCreate-deployButton"
@@ -26,95 +26,93 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading> <kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady"> <information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics"> <span class="small text-warning vertical-center">
<span class="small text-warning vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
configured. </span>
</span> </information-panel>
</information-panel> <div class="row" ng-if="ctrl.state.getMetrics">
<div class="row" ng-if="ctrl.state.getMetrics"> <div class="col-md-12">
<div class="col-md-12"> <rd-widget>
<rd-widget> <div class="toolBar px-5 pt-5">
<div class="toolBar px-5 pt-5"> <div class="toolBarTitle flex">
<div class="toolBarTitle flex"> <div class="widget-icon space-right">
<div class="widget-icon space-right"> <pr-icon icon="'info'"></pr-icon>
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div> </div>
<span class="vertical-center"> About statistics </span>
</div> </div>
<rd-widget-body> </div>
<form class="form-horizontal"> <rd-widget-body>
<div class="form-group"> <form class="form-horizontal">
<div class="col-sm-12"> <div class="form-group">
<span class="small text-warning"> <div class="col-sm-12">
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b <span class="small text-warning">
>. This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
</span> >.
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</span> </span>
</div> </div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable"> </div>
<div class="col-sm-12"> <div class="form-group">
<span class="small text-muted"> <label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> <div class="col-sm-3 col-md-2">
Network stats are unavailable for this container. <select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
</span> <option value="30">30s</option>
</div> <option value="60">60s</option>
</select>
</div> </div>
</form> <span>
</rd-widget-body> <pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</rd-widget> </span>
</div> </div>
</div> <div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<div class="row" ng-if="ctrl.state.getMetrics"> <span class="small text-muted">
<div class="col-lg-6 col-md-12 col-sm-12"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<rd-widget> Network stats are unavailable for this container.
<div class="toolBar px-5 pt-5"> </span>
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div> </div>
<span class="vertical-center"> Memory usage </span>
</div> </div>
</div> </form>
<rd-widget-body> </rd-widget-body>
<div class="chart-container" style="position: relative"> </rd-widget>
<canvas id="memoryChart" width="770" height="300"></canvas> </div>
</div> </div>
</rd-widget-body>
</rd-widget> <div class="row" ng-if="ctrl.state.getMetrics">
</div> <div class="col-lg-6 col-md-12 col-sm-12">
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable"> <rd-widget>
<rd-widget> <div class="toolBar px-5 pt-5">
<div class="toolBar px-5 pt-5"> <div class="toolBarTitle flex">
<div class="toolBarTitle flex"> <div class="widget-icon space-right">
<div class="widget-icon space-right"> <pr-icon icon="'svg-memory'"></pr-icon>
<pr-icon icon="'cpu'"></pr-icon> </div>
</div> <span class="vertical-center"> Memory usage </span>
<span class="vertical-center"> CPU usage </span> </div>
</div> </div>
</div> <rd-widget-body>
<rd-widget-body> <div class="chart-container" style="position: relative">
<div class="chart-container" style="position: relative"> <canvas id="memoryChart" width="770" height="300"></canvas>
<canvas id="cpuChart" width="770" height="300"></canvas> </div>
</div> </rd-widget-body>
</rd-widget-body> </rd-widget>
</rd-widget> </div>
</div> <div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div> </div>
</div> </div>
@@ -19,6 +19,7 @@ class KubernetesApplicationStatsController {
this.ChartService = ChartService; this.ChartService = ChartService;
this.onInit = this.onInit.bind(this); this.onInit = this.onInit.bind(this);
this.initCharts = this.initCharts.bind(this);
} }
changeUpdateRepeater() { changeUpdateRepeater() {
@@ -68,17 +69,26 @@ class KubernetesApplicationStatsController {
} }
initCharts() { initCharts() {
const cpuChartCtx = $('#cpuChart'); let i = 0;
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); const findCharts = setInterval(() => {
this.cpuChart = cpuChart; let cpuChartCtx = $('#cpuChart');
let memoryChartCtx = $('#memoryChart');
const memoryChartCtx = $('#memoryChart'); if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
this.memoryChart = memoryChart; this.cpuChart = cpuChart;
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.updateCPUChart(); this.memoryChart = memoryChart;
this.updateMemoryChart(); this.updateCPUChart();
this.setUpdateRepeater(); this.updateMemoryChart();
this.setUpdateRepeater();
clearInterval(findCharts);
return;
}
i++;
if (i >= 10) {
clearInterval(findCharts);
}
}, 200);
} }
getStats() { getStats() {
@@ -15,86 +15,84 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading> <kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady"> <information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics"> <span class="small text-muted vertical-center">
<span class="small text-muted vertical-center"> <pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon> Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured. </span>
</span> </information-panel>
</information-panel> <div class="row" ng-if="ctrl.state.getMetrics">
<div class="row" ng-if="ctrl.state.getMetrics"> <div class="col-md-12">
<div class="col-md-12"> <rd-widget>
<rd-widget> <div class="toolBar px-5 pt-5">
<div class="toolBar px-5 pt-5"> <div class="toolBarTitle flex">
<div class="toolBarTitle flex"> <div class="widget-icon space-right">
<div class="widget-icon space-right"> <pr-icon icon="'info'"></pr-icon>
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div> </div>
<span class="vertical-center"> About statistics </span>
</div> </div>
<rd-widget-body> </div>
<form class="form-horizontal"> <rd-widget-body>
<div class="form-group"> <form class="form-horizontal">
<div class="col-sm-12"> <div class="form-group">
<span class="small text-muted"> <div class="col-sm-12">
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b <span class="small text-muted">
>. This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
</span> >.
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span> </span>
</div> </div>
</form> </div>
</rd-widget-body> <div class="form-group">
</rd-widget> <label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
</div> <div class="col-sm-3 col-md-2">
</div> <select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
<option value="30">30s</option>
<div class="row" ng-show="ctrl.state.getMetrics"> <option value="60">60s</option>
<div class="col-lg-6 col-md-12 col-sm-12"> </select>
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div> </div>
<span class="vertical-center"> Memory usage </span> <span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div> </div>
</div> </form>
<rd-widget-body> </rd-widget-body>
<div class="chart-node" style="position: relative"> </rd-widget>
<canvas id="memoryChart" width="770" height="300"></canvas> </div>
</div> </div>
</rd-widget-body>
</rd-widget> <div class="row" ng-show="ctrl.state.getMetrics">
</div> <div class="col-lg-6 col-md-12 col-sm-12">
<div class="col-lg-6 col-md-12 col-sm-12"> <rd-widget>
<rd-widget> <div class="toolBar px-5 pt-5">
<div class="toolBar px-5 pt-5"> <div class="toolBarTitle flex">
<div class="toolBarTitle flex"> <div class="widget-icon space-right">
<div class="widget-icon space-right"> <pr-icon icon="'svg-memory'"></pr-icon>
<pr-icon icon="'cpu'"></pr-icon> </div>
</div> <span class="vertical-center"> Memory usage </span>
<span class="vertical-center"> CPU usage </span> </div>
</div> </div>
</div> <rd-widget-body>
<rd-widget-body> <div class="chart-node" style="position: relative">
<div class="chart-node" style="position: relative"> <canvas id="memoryChart" width="770" height="300"></canvas>
<canvas id="cpuChart" width="770" height="300"></canvas> </div>
</div> </rd-widget-body>
</rd-widget-body> </rd-widget>
</rd-widget> </div>
</div> <div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div> </div>
</div> </div>
@@ -17,6 +17,7 @@ class KubernetesNodeStatsController {
this.ChartService = ChartService; this.ChartService = ChartService;
this.onInit = this.onInit.bind(this); this.onInit = this.onInit.bind(this);
this.initCharts = this.initCharts.bind(this);
} }
changeUpdateRepeater() { changeUpdateRepeater() {
@@ -63,17 +64,20 @@ class KubernetesNodeStatsController {
} }
initCharts() { initCharts() {
const cpuChartCtx = $('#cpuChart'); const findCharts = setInterval(() => {
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); let cpuChartCtx = $('#cpuChart');
this.cpuChart = cpuChart; let memoryChartCtx = $('#memoryChart');
if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
const memoryChartCtx = $('#memoryChart'); const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); this.cpuChart = cpuChart;
this.memoryChart = memoryChart; const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
this.memoryChart = memoryChart;
this.updateCPUChart(); this.updateCPUChart();
this.updateMemoryChart(); this.updateMemoryChart();
this.setUpdateRepeater(); this.setUpdateRepeater();
clearInterval(findCharts);
}
}, 200);
} }
getStats() { getStats() {
@@ -84,7 +88,7 @@ class KubernetesNodeStatsController {
const memory = filesizeParser(stats.usage.memory); const memory = filesizeParser(stats.usage.memory);
const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu); const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu);
this.stats = { this.stats = {
read: stats.creationTimestamp, read: stats.metadata.creationTimestamp,
MemoryUsage: memory, MemoryUsage: memory,
CPUUsage: (cpu / this.nodeCPU) * 100, CPUUsage: (cpu / this.nodeCPU) * 100,
}; };
@@ -118,12 +122,6 @@ class KubernetesNodeStatsController {
this.nodeCPU = node.CPU || 1; this.nodeCPU = node.CPU || 1;
await this.getStats(); await this.getStats();
if (this.state.getMetrics) {
this.$document.ready(() => {
this.initCharts();
});
}
} else { } else {
this.state.getMetrics = false; this.state.getMetrics = false;
} }
@@ -132,6 +130,11 @@ class KubernetesNodeStatsController {
this.Notifications.error('Failure', err, 'Unable to retrieve node stats'); this.Notifications.error('Failure', err, 'Unable to retrieve node stats');
} finally { } finally {
this.state.viewReady = true; this.state.viewReady = true;
if (this.state.getMetrics) {
this.$document.ready(() => {
this.initCharts();
});
}
} }
} }
@@ -88,6 +88,7 @@
is-valid="ctrl.state.isDataValid" is-valid="ctrl.state.isDataValid"
on-change-validation="ctrl.isFormValid()" on-change-validation="ctrl.isFormValid()"
is-creation="true" is-creation="true"
type="'configmap'"
is-editor-dirty="ctrl.state.isEditorDirty" is-editor-dirty="ctrl.state.isEditorDirty"
></kubernetes-configuration-data> ></kubernetes-configuration-data>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More