Compare commits

..

107 Commits

Author SHA1 Message Date
deviantony
a3a74c4d72 version: bump version to 2.21.0-rc1 2024-08-14 18:52:56 +00:00
deviantony
9fd5669a23 version: revert bump to rc1 2024-08-14 18:50:52 +00:00
deviantony
efc2bf9292 version: bump version to 2.21.0-rc1 2024-08-14 18:40:52 +00:00
Oscar Zhou
4d74a00492 fix(group): create group twice when associating devices [EE-7418] (#12091) 2024-08-12 17:09:44 +12:00
LP B
68011fb293 fix(app/registries): enforce user accesses on registries (#12088) 2024-08-10 11:53:19 +02:00
andres-portainer
25f84c0b3e fix(compose): avoid the need to pass the file to remove the stack BE-11057 (#12064)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: Yajith Dayarathna <yajith.dayarathna@portainer.io>
2024-08-09 10:59:10 -03:00
Oscar Zhou
13766cc465 fix(stack/remote): pass forceRecreate setting [EE-7374] (#12050) 2024-08-06 09:02:12 +12:00
Yajith Dayarathna
5e6e3048d5 Installing docker-compose during test-server step - 2.21 (#12076) 2024-08-05 11:29:01 +12:00
andres-portainer
18e755e30e fix(pendingactions): remove excessive logging BE-11094 (#12070) 2024-08-02 16:35:08 -03:00
Oscar Zhou
a63bd2cea4 fix(table): delete item doesn't refresh the table [BE-11064] (#12060) 2024-07-31 20:39:06 +12:00
andres-portainer
ef8e611e0a fix(snapshots): remove the attempt to snapshot untrusted environments EE-7407 (#12045) 2024-07-23 18:43:26 -03:00
Ali
cc60836bb8 fix(placements) filter out empty items in the required node affinity array [BE-11022] (#12036)
Co-authored-by: testa113 <testa113>
2024-07-23 09:31:13 +12:00
LP B
e2830019d7 fix(docker/container): use nodeName to build links to networks used by containers (#12004) 2024-07-17 14:40:10 +02:00
Oscar Zhou
20de243299 fix(host): show clear host info message [EE-7075] (#12010) 2024-07-12 08:45:44 +12:00
Oscar Zhou
e8af981746 fix(stack): excessive alias count error [EE-7305] (#11991) 2024-07-11 14:09:46 +12:00
andres-portainer
fa4711946d fix(snapshots): fix background snapshots on environment creation EE-7273 (#12022) 2024-07-09 15:18:17 -03:00
LP B
566e37535f fix(docker/network): send target nodeName when removing a network on swarm (#12000) 2024-07-08 17:31:27 +02:00
Steven Kang
3529a36f92 fix(cve): remediate cves detected in docker scout (#12012) 2024-07-08 10:24:36 +12:00
andres-portainer
9c775396fd fix(kube): improve error handling EE-7196 (#11977) 2024-06-27 10:45:16 -03:00
andres-portainer
65871207f0 fix(kube): improve error handling EE-7199 (#11975) 2024-06-27 10:43:49 -03:00
andres-portainer
dc3e20acac fix(snapshots): enable the background snapshotter EE-7273 (#11972) 2024-06-26 18:27:44 -03:00
cmeng
91a477d9fb fix(host-info) host info improvement EE-7075 (#11883) 2024-06-26 12:18:29 -03:00
LP B
9339d10233 fix(app): properly update the app state when losing connectivity to a remote environment while browsing it (#11943) 2024-06-19 13:45:02 +02:00
Ali
e7af3296fc fix(custom-templates): relax custom template validation and enforce stack name validation [EE-7102] (#11937)
Co-authored-by: testa113 <testa113>
2024-06-17 09:24:50 +12:00
Chaim Lev-Ari
5182220d0a fix(edge/update): show environment count when more than 100 [EE-6424] (#11916) 2024-06-14 18:37:41 -03:00
Chaim Lev-Ari
9d7173fb5f fix(endpoints): show toaster on delete [EE-7170] (#11888) 2024-06-13 18:32:23 -03:00
Ali
69f9a509c8 fix(namespace): sanitize owner label [EE-7122] (#11936)
Co-authored-by: testa113 <testa113>
2024-06-13 11:06:08 +12:00
James Carppe
93ce33fac8 Add support for specifying the NFS server address in the mount point EE-7019 (#11922) 2024-06-12 11:23:21 -03:00
Dakota Walsh
55706cbf35 fix(kubernetes): cluster setup screen text on own line EE-7112 (#11904) 2024-06-12 08:43:13 +12:00
Oscar Zhou
e0934bb7fa fix(customtemplate): duplicated error handling [EE-7197] (#11912) 2024-06-11 22:11:08 +12:00
Chaim Lev-Ari
c4235c84a7 feat(edge/stacks): default refresh rate to 10s [EE-7155] (#11890) 2024-06-09 14:17:16 +03:00
Matt Hook
4fd4aa8138 Revert "feat(dashboard): dashboard api [EE-7111]" (#11906) 2024-06-07 14:08:08 +12:00
Matt Hook
8aec5adb66 fix(db): fix missing portainer.edb in backups when encrypted portainer db is used [EE-6417] (#11886) 2024-06-06 12:37:08 +12:00
Matt Hook
d490061c1f fix(pendingactions): fix deadlock and improve debug logging [EE-7049] (#11868) 2024-05-30 14:55:09 +12:00
Oscar Zhou
b23b0f7c8d fix(compose): add project directory option to compose command [EE-7093] (#11859) 2024-05-30 08:46:48 +12:00
matias-portainer
c94cfb1308 fix(waiting-room): add support for bulk deletion in waiting room EE-7136 (#11880) 2024-05-28 17:18:20 -03:00
andres-portainer
4be2c061f5 fix(tunnels): make the tunnels more robust EE-7042 (#11878) 2024-05-28 16:43:01 -03:00
andres-portainer
0356288fd9 fix(tls): add support for more cipher suites EE-7150 (#11875) 2024-05-28 15:49:21 -03:00
andres-portainer
3b95c333fc task(endpoints): change the definition of /endpoints/remove EE-7126 (#11872) 2024-05-28 09:05:26 -03:00
cmeng
11404aaecb fix(deletion): delete registries batch by batch EE-7084 (#11856) 2024-05-24 14:30:36 +12:00
Oscar Zhou
ccb6dd7f1a fix(api/docker): no authorized user can call restricted api [EE-6808] (#11478) 2024-05-22 09:08:51 +12:00
Matt Hook
6e0dd34cc8 feat(dashboard): dashboard api [EE-7111] (#11844) 2024-05-21 11:09:34 +12:00
Oscar Zhou
61ef133bb8 fix(edge/stack): edge stack env table pagination and action [EE-6836] (#11836) 2024-05-21 09:40:03 +12:00
Matt Hook
f5d896bce1 fix(console): fix command not found [EE-6982] (#11832) 2024-05-20 14:35:40 +12:00
andres-portainer
6c98271e43 fix(endpoints): remove all the endpoints in the same transaction EE-7095 (#11840) 2024-05-17 16:45:12 -03:00
cmeng
4700e38e5d fix(deletion): delete objects batch by batch EE-7084 (#11834) 2024-05-16 14:34:47 +12:00
Matt Hook
2f1b5ec979 fix(pending-actions): correctly detect unreachable/down cluster [EE-7049] (#11811) 2024-05-16 09:02:47 +12:00
Ali
39088b16b3 fix(app): ensure placement errors surface per node [EE-7065] (#11822)
Co-authored-by: testa113 <testa113>
2024-05-14 15:03:12 +12:00
Oscar Zhou
680cb3b36a fix(image): github registry image truncated [EE-7021] (#11768) 2024-05-10 09:01:47 +12:00
Oscar Zhou
1fce2b83d7 fix(api): list docker volume performance [EE-6896] (#11540) 2024-05-09 13:02:49 +12:00
Dakota Walsh
92c8692bbe fix(metadata): add mutli endpoint delete api EE-6872 (#11789) 2024-05-08 17:09:03 -04:00
Matt Hook
65060725df fix(pending-action): pending action data format [EE-7064] (#11794)
Co-authored-by: Prabhat Khera <91852476+prabhat-portainer@users.noreply.github.com>
2024-05-09 08:15:37 +12:00
cmeng
b920c542dd fix(docker): keep /docker url prefix for DockerHandler EE-7073 (#11800) 2024-05-08 14:26:36 +12:00
Ali
be99f646f8 fix(auth logs): fix typo in search keyword [EE-6742] (#11792)
Co-authored-by: testa113 <testa113>
2024-05-08 09:16:09 +12:00
Ali
6d2775a42e fix(be-overlay): consistency overlay with variants [EE-6742] (#11776)
Co-authored-by: testa113 <testa113>
2024-05-07 16:16:55 +12:00
Ali
4bcefb4866 fix(app): show one tooltip to describe rollback feature [EE-6825] (#11779)
Co-authored-by: testa113 <testa113>
2024-05-07 15:27:28 +12:00
cmeng
f16d2e5d28 fix(container): specify node name when get a container EE-6981 (#11749) 2024-05-07 11:34:42 +12:00
Steven Kang
9960137a7c fix: windows container capability [EE-5814] (#11763) 2024-05-03 10:56:31 +12:00
Ali
62c625c446 fix(stacks): hide stack cols [EE-6949] (#11753) 2024-05-03 09:12:39 +12:00
Matt Hook
acaa564557 fix(kube): correctly extract namespace from namespace manifest [EE-6555] (#11675)
Co-authored-by: Prabhat Khera <prabhat.khera@portainer.io>
2024-05-02 14:28:07 +12:00
Ali
d727cbc373 fix(app): explain rollback tooltip [EE-6825] (#11700)
Co-authored-by: testa113 <testa113>
2024-05-02 14:10:30 +12:00
Dakota Walsh
31403bfc7e fix(migration): improper version EE-7048 (#11713) 2024-04-30 21:30:46 -04:00
Matt Hook
cf91a8352d fix(kube): fix text in activity and authentication logs teasers [EE-6742] (#11681) 2024-04-30 19:17:22 +12:00
cmeng
549579d935 fix(edge-stack): add completed status EE-6210 (#11635) 2024-04-30 13:44:14 +12:00
Ali
0be1888f11 fix(version): reduce github requests [EE-7017] (#11679) 2024-04-26 08:46:13 +12:00
Ali
2856d0ed64 fix(app): avoid 'no label' error when deleting external app [EE-6019] (#11673) 2024-04-26 08:42:17 +12:00
Chaim Lev-Ari
d70812be0d fix(users): return json from create token [EE-6856] (#11578) 2024-04-25 10:10:45 +03:00
Ali
efc88c0073 fix(migration): run post init migrations for edge after server starts [EE-6905] (#11548)
Co-authored-by: testa113 <testa113>
2024-04-23 16:15:23 +12:00
Matt Hook
00d8391a02 fix(published-ports): fix published port link and into a new component [EE-6592] (#11657) 2024-04-23 13:47:51 +12:00
Matt Hook
4403503d82 fix(settings): fix crash during settings update when not using oauth [EE-7031] (#11661) 2024-04-23 12:58:21 +12:00
Prabhat Khera
8ae64523b3 fix(stack): correct documentation link for stack ENV variables [EE-6902] (#11653) 2024-04-23 08:35:25 +12:00
Oscar Zhou
ccfd5e4500 feat(setting/oauth): add authstyle option [EE-6038] (#11590) 2024-04-22 10:35:26 +12:00
Oscar Zhou
a755e6be15 fix(stack/git): option to overwrite target path during dir move [EE-6871] (#11627) 2024-04-22 10:34:38 +12:00
cmeng
17561c1c0c fix(docker-client): explicitly set docker client scheme EE-6935 (#11519) 2024-04-22 09:00:48 +12:00
Ali
d73b162d16 fix(stacks): conditionally hide node and namespace stacks [EE-6949] (#11528) 2024-04-19 17:33:26 +12:00
Prabhat Khera
d5c4671320 fix(swagger): swagger docs for http status code 409 [EE-5767] (#11536) 2024-04-19 15:19:10 +12:00
Matt Hook
f9065367b9 chore(kubectl): update kubectl to latest point release [EE-7018] (#11622) 2024-04-19 11:47:19 +12:00
andres-portainer
218b8bf300 fix(workflows): upgrade Go to v1.21.9 EE-6939 (#11642) 2024-04-18 19:03:17 -03:00
Prabhat Khera
aa9e73002f fix(stack): fix stack env variable link [EE-6902] (#11626) 2024-04-19 07:00:15 +12:00
andres-portainer
b86b721b0f fix(mingit): upgrade to v2.44.0.1 EE-7023 (#11639) 2024-04-18 15:22:15 -03:00
Prabhat Khera
95292d20f4 fix(images): consider stopped containers for unused label [EE-6983] (#11631) 2024-04-18 17:15:03 +12:00
andres-portainer
fb7c23a241 fix(docker): upgrade to v24.0.9 EE-7016 (#11618) 2024-04-17 19:38:11 -03:00
andres-portainer
a3146fff36 fix(go): upgrade Go to v1.21.9 in the nightly security scan EE-6939 (#11615) 2024-04-17 18:09:47 -03:00
Matt Hook
483aa80e40 fix(auth): prevent user enumeration attack [EE-6832] (#11588) 2024-04-17 16:08:48 +12:00
Prabhat Khera
fb4ffaec35 fix(pending-actions): clean pending actions for deleted environment [EE-6545] (#11600) 2024-04-17 08:32:32 +12:00
Oscar Zhou
1a801d86f0 fix(api/endpoint): filter status for async devices [EE-6958] (#11508) 2024-04-16 13:36:55 +12:00
Matt Hook
fe41d23244 chore(docker): bump docker client to 26.0.1 [EE-6941] (#11593) 2024-04-16 08:27:48 +12:00
Prabhat Khera
0e163adf8d fix(stacks): update info text for stack environment variables [EE-6902] (#11558) 2024-04-16 08:03:51 +12:00
Prabhat Khera
eb4a06d422 fix(pending-actions): fix create kubeclient to check endpoint status [EE-6545] (#11586) 2024-04-16 07:40:49 +12:00
Matt Hook
1fee55ddd2 chore(api): bump docker and protobuf pkgs [EE-6941] (#11565) 2024-04-15 10:53:07 +12:00
Prabhat Khera
2cbc5027aa chore(unpacker): use APIVersion as unpacker image tag [EE-6974] (#11497)
Co-authored-by: hookenz <hookenz@gmail.com>
2024-04-15 10:30:01 +12:00
Prabhat Khera
305a7a354a chore(unpacker): use APIVersion as unpacker image tag [EE-6974] (#11507) 2024-04-15 10:29:17 +12:00
Matt Hook
948e2bb715 bump helm version (#11564) 2024-04-15 09:17:09 +12:00
andres-portainer
02b6829819 fix(protobuf): upgrade protobuf to v1.33 EE-6945 (#11569) 2024-04-12 17:52:43 -03:00
andres-portainer
3d3d65d44e fix(go): upgrade Go to v1.21.9 EE-6939 (#11555) 2024-04-12 17:08:15 -03:00
Matt Hook
6714cba2f8 fix(backups): improved archive encryption [EE-6764] (#11488) 2024-04-10 10:58:31 +12:00
Matt Hook
582b1a16fc fix(logs): add NOCOLOR option for use when exporting to greylog etc [EE-6696] (#11107) (#11521) 2024-04-10 09:24:14 +12:00
Matt Hook
2be897c4a1 fix(services): speed up service count on the kubernetes dashboard [EE-6967] (#11525) 2024-04-09 15:50:26 +12:00
Matt Hook
8616f9b742 fix(apikey): don't authenticate api key for external auth [EE-6932] (#11462) 2024-04-08 11:03:13 +12:00
LP B
5d7d68bc44 fix(app): replace fields removed by Docker 25 and 26 (#11493)
* fix(app/volume): make optional Container and ContainerConfig fields removed in docker 26

* fix(app/image): use image.Size instead of image.VirtualSize removed in Docker 25
2024-04-05 17:03:14 +02:00
Matt Hook
cea9969463 fix(kube): use https when port is 443 in various tables [EE-6592] (#11444) 2024-04-04 13:47:39 +13:00
Ali
5fb5ea7ae0 fix(app): port namespace limit refresh from EE to CE [EE-6835] (#11485)
Co-authored-by: testa113 <testa113>
2024-04-04 08:19:09 +13:00
Ali
5f89255d9c fix(namespace): wait for system ns setting to load before selecting existing ns [EE-6917] (#11484)
Co-authored-by: testa113 <testa113>
2024-04-04 08:18:27 +13:00
cmeng
e5f6363244 fix(edge-stack): avoid reference of undefined EE-6914 (#11464) 2024-03-27 16:08:10 +13:00
Oscar Zhou
5d9423b93c fix(stack): filter out orphan stacks that have same name as normal stacks [EE-6791] (#11445) 2024-03-27 08:44:51 +13:00
andres-portainer
2443a0f568 fix(kubernetes): avoid a deadlock EE-6901 (#11447) 2024-03-25 14:19:28 -03:00
Prabhat Khera
08643ed872 chore(version): version bump to 2.21.0 [EE-6897] (#11437)
* chore(version): version bump to 2.21.0 [EE-6897]

* fix tests
2024-03-22 14:37:23 +13:00
159 changed files with 2135 additions and 1444 deletions

View File

@@ -22,7 +22,7 @@ on:
env:
DOCKER_HUB_REPO: portainerci/portainer-ce
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
GO_VERSION: 1.21.9
GO_VERSION: 1.21.11
NODE_VERSION: 18.x
jobs:

View File

@@ -5,6 +5,7 @@ env:
NODE_VERSION: 18.x
on:
workflow_dispatch:
pull_request:
branches:
- master
@@ -27,15 +28,22 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- name: 'checkout the current branch'
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: 'set up node.js'
uses: actions/setup-node@v4.0.1
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
- run: yarn --frozen-lockfile
- name: Run tests
run: make test-client ARGS="--maxWorkers=2 --minWorkers=1"
test-server:
strategy:
matrix:
@@ -48,9 +56,21 @@ jobs:
if: github.event.pull_request.draft == false
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- name: 'checkout the current branch'
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: 'set up golang'
uses: actions/setup-go@v5.0.0
with:
go-version: ${{ env.GO_VERSION }}
- name: Run tests
- name: 'install dependencies'
run: make test-deps PLATFORM=linux ARCH=amd64
- name: 'update $PATH'
run: echo "$(pwd)/dist" >> $GITHUB_PATH
- name: 'run tests'
run: make test-server

View File

@@ -64,6 +64,9 @@ clean: ## Remove all build and download artifacts
.PHONY: test test-client test-server
test: test-server test-client ## Run all tests
test-deps: init-dist
./build/download_docker_compose_binary.sh $(PLATFORM) $(ARCH) $(shell jq -r '.dockerCompose' < "./binary-version.json")
test-client: ## Run client tests
yarn test $(ARGS)

View File

@@ -82,7 +82,8 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
}
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
_, err := datastore.Backup(filepath.Join(backupDirPath, "portainer.db"))
dbFileName := datastore.Connection().GetDatabaseFileName()
_, err := datastore.Backup(filepath.Join(backupDirPath, dbFileName))
return err
}

View File

@@ -5,6 +5,17 @@ import (
"github.com/portainer/portainer/api/internal/edge/cache"
)
// EdgeJobs retrieves the edge jobs for the given environment
func (service *Service) EdgeJobs(endpointID portainer.EndpointID) []portainer.EdgeJob {
service.mu.RLock()
defer service.mu.RUnlock()
return append(
make([]portainer.EdgeJob, 0, len(service.edgeJobs[endpointID])),
service.edgeJobs[endpointID]...,
)
}
// AddEdgeJob register an EdgeJob inside the tunnel details associated to an environment(endpoint).
func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portainer.EdgeJob) {
if endpoint.Edge.AsyncMode {
@@ -12,10 +23,10 @@ func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portai
}
service.mu.Lock()
tunnel := service.getTunnelDetails(endpoint.ID)
defer service.mu.Unlock()
existingJobIndex := -1
for idx, existingJob := range tunnel.Jobs {
for idx, existingJob := range service.edgeJobs[endpoint.ID] {
if existingJob.ID == edgeJob.ID {
existingJobIndex = idx
@@ -24,30 +35,28 @@ func (service *Service) AddEdgeJob(endpoint *portainer.Endpoint, edgeJob *portai
}
if existingJobIndex == -1 {
tunnel.Jobs = append(tunnel.Jobs, *edgeJob)
service.edgeJobs[endpoint.ID] = append(service.edgeJobs[endpoint.ID], *edgeJob)
} else {
tunnel.Jobs[existingJobIndex] = *edgeJob
service.edgeJobs[endpoint.ID][existingJobIndex] = *edgeJob
}
cache.Del(endpoint.ID)
service.mu.Unlock()
}
// RemoveEdgeJob will remove the specified Edge job from each tunnel it was registered with.
func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
service.mu.Lock()
for endpointID, tunnel := range service.tunnelDetailsMap {
for endpointID := range service.edgeJobs {
n := 0
for _, edgeJob := range tunnel.Jobs {
for _, edgeJob := range service.edgeJobs[endpointID] {
if edgeJob.ID != edgeJobID {
tunnel.Jobs[n] = edgeJob
service.edgeJobs[endpointID][n] = edgeJob
n++
}
}
tunnel.Jobs = tunnel.Jobs[:n]
service.edgeJobs[endpointID] = service.edgeJobs[endpointID][:n]
cache.Del(endpointID)
}
@@ -57,19 +66,17 @@ func (service *Service) RemoveEdgeJob(edgeJobID portainer.EdgeJobID) {
func (service *Service) RemoveEdgeJobFromEndpoint(endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID) {
service.mu.Lock()
tunnel := service.getTunnelDetails(endpointID)
defer service.mu.Unlock()
n := 0
for _, edgeJob := range tunnel.Jobs {
for _, edgeJob := range service.edgeJobs[endpointID] {
if edgeJob.ID != edgeJobID {
tunnel.Jobs[n] = edgeJob
service.edgeJobs[endpointID][n] = edgeJob
n++
}
}
tunnel.Jobs = tunnel.Jobs[:n]
service.edgeJobs[endpointID] = service.edgeJobs[endpointID][:n]
cache.Del(endpointID)
service.mu.Unlock()
}

View File

@@ -19,7 +19,6 @@ import (
const (
tunnelCleanupInterval = 10 * time.Second
requiredTimeout = 15 * time.Second
activeTimeout = 4*time.Minute + 30*time.Second
pingTimeout = 3 * time.Second
)
@@ -28,32 +27,54 @@ const (
// It is used to start a reverse tunnel server and to manage the connection status of each tunnel
// connected to the tunnel server.
type Service struct {
serverFingerprint string
serverPort string
tunnelDetailsMap map[portainer.EndpointID]*portainer.TunnelDetails
dataStore dataservices.DataStore
snapshotService portainer.SnapshotService
chiselServer *chserver.Server
shutdownCtx context.Context
ProxyManager *proxy.Manager
mu sync.Mutex
fileService portainer.FileService
serverFingerprint string
serverPort string
activeTunnels map[portainer.EndpointID]*portainer.TunnelDetails
edgeJobs map[portainer.EndpointID][]portainer.EdgeJob
dataStore dataservices.DataStore
snapshotService portainer.SnapshotService
chiselServer *chserver.Server
shutdownCtx context.Context
ProxyManager *proxy.Manager
mu sync.RWMutex
fileService portainer.FileService
defaultCheckinInterval int
}
// NewService returns a pointer to a new instance of Service
func NewService(dataStore dataservices.DataStore, shutdownCtx context.Context, fileService portainer.FileService) *Service {
defaultCheckinInterval := portainer.DefaultEdgeAgentCheckinIntervalInSeconds
settings, err := dataStore.Settings().Settings()
if err == nil {
defaultCheckinInterval = settings.EdgeAgentCheckinInterval
} else {
log.Error().Err(err).Msg("unable to retrieve the settings from the database")
}
return &Service{
tunnelDetailsMap: make(map[portainer.EndpointID]*portainer.TunnelDetails),
dataStore: dataStore,
shutdownCtx: shutdownCtx,
fileService: fileService,
activeTunnels: make(map[portainer.EndpointID]*portainer.TunnelDetails),
edgeJobs: make(map[portainer.EndpointID][]portainer.EdgeJob),
dataStore: dataStore,
shutdownCtx: shutdownCtx,
fileService: fileService,
defaultCheckinInterval: defaultCheckinInterval,
}
}
// pingAgent ping the given agent so that the agent can keep the tunnel alive
func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
tunnel := service.GetTunnelDetails(endpointID)
requestURL := fmt.Sprintf("http://127.0.0.1:%d/ping", tunnel.Port)
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return err
}
tunnelAddr, err := service.TunnelAddr(endpoint)
if err != nil {
return err
}
requestURL := fmt.Sprintf("http://%s/ping", tunnelAddr)
req, err := http.NewRequest(http.MethodHead, requestURL, nil)
if err != nil {
return err
@@ -76,47 +97,49 @@ func (service *Service) pingAgent(endpointID portainer.EndpointID) error {
// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done
func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
go func() {
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: start")
go service.keepTunnelAlive(endpointID, ctx, maxAlive)
}
maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop()
func (service *Service) keepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) {
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("max_alive_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: start")
pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop()
maxAliveTicker := time.NewTicker(maxAlive)
defer maxAliveTicker.Stop()
for {
select {
case <-pingTicker.C:
service.SetTunnelStatusToActive(endpointID)
err := service.pingAgent(endpointID)
if err != nil {
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: ping agent")
}
case <-maxAliveTicker.C:
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: tunnel keep alive timeout")
pingTicker := time.NewTicker(tunnelCleanupInterval)
defer pingTicker.Stop()
return
case <-ctx.Done():
err := ctx.Err()
for {
select {
case <-pingTicker.C:
service.UpdateLastActivity(endpointID)
if err := service.pingAgent(endpointID); err != nil {
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: tunnel stop")
return
Msg("KeepTunnelAlive: ping agent")
}
case <-maxAliveTicker.C:
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("timeout_minutes", maxAlive.Minutes()).
Msg("KeepTunnelAlive: tunnel keep alive timeout")
return
case <-ctx.Done():
err := ctx.Err()
log.Debug().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("KeepTunnelAlive: tunnel stop")
return
}
}()
}
}
// StartTunnelServer starts a tunnel server on the specified addr and port.
@@ -126,7 +149,6 @@ func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx con
// The snapshotter is used in the tunnel status verification process.
func (service *Service) StartTunnelServer(addr, port string, snapshotService portainer.SnapshotService) error {
privateKeyFile, err := service.retrievePrivateKeyFile()
if err != nil {
return err
}
@@ -144,21 +166,21 @@ func (service *Service) StartTunnelServer(addr, port string, snapshotService por
service.serverFingerprint = chiselServer.GetFingerprint()
service.serverPort = port
err = chiselServer.Start(addr, port)
if err != nil {
if err := chiselServer.Start(addr, port); err != nil {
return err
}
service.chiselServer = chiselServer
// TODO: work-around Chisel default behavior.
// By default, Chisel will allow anyone to connect if no user exists.
username, password := generateRandomCredentials()
err = service.chiselServer.AddUser(username, password, "127.0.0.1")
if err != nil {
if err = service.chiselServer.AddUser(username, password, "127.0.0.1"); err != nil {
return err
}
service.snapshotService = snapshotService
go service.startTunnelVerificationLoop()
return nil
@@ -172,37 +194,39 @@ func (service *Service) StopTunnelServer() error {
func (service *Service) retrievePrivateKeyFile() (string, error) {
privateKeyFile := service.fileService.GetDefaultChiselPrivateKeyPath()
exist, _ := service.fileService.FileExists(privateKeyFile)
if !exist {
log.Debug().
Str("private-key", privateKeyFile).
Msg("Chisel private key file does not exist")
privateKey, err := ccrypto.GenerateKey("")
if err != nil {
log.Error().
Err(err).
Msg("Failed to generate chisel private key")
return "", err
}
err = service.fileService.StoreChiselPrivateKey(privateKey)
if err != nil {
log.Error().
Err(err).
Msg("Failed to save Chisel private key to disk")
return "", err
} else {
log.Info().
Str("private-key", privateKeyFile).
Msg("Generated a new Chisel private key file")
}
} else {
if exists, _ := service.fileService.FileExists(privateKeyFile); exists {
log.Info().
Str("private-key", privateKeyFile).
Msg("Found Chisel private key file on disk")
Msg("found Chisel private key file on disk")
return privateKeyFile, nil
}
log.Debug().
Str("private-key", privateKeyFile).
Msg("chisel private key file does not exist")
privateKey, err := ccrypto.GenerateKey("")
if err != nil {
log.Error().
Err(err).
Msg("failed to generate chisel private key")
return "", err
}
if err = service.fileService.StoreChiselPrivateKey(privateKey); err != nil {
log.Error().
Err(err).
Msg("failed to save Chisel private key to disk")
return "", err
}
log.Info().
Str("private-key", privateKeyFile).
Msg("generated a new Chisel private key file")
return privateKeyFile, nil
}
@@ -230,63 +254,45 @@ func (service *Service) startTunnelVerificationLoop() {
}
}
// checkTunnels finds the first tunnel that has not had any activity recently
// and attempts to take a snapshot, then closes it and returns
func (service *Service) checkTunnels() {
tunnels := make(map[portainer.EndpointID]portainer.TunnelDetails)
service.mu.RLock()
service.mu.Lock()
for key, tunnel := range service.tunnelDetailsMap {
if tunnel.LastActivity.IsZero() || tunnel.Status == portainer.EdgeAgentIdle {
continue
}
if tunnel.Status == portainer.EdgeAgentManagementRequired && time.Since(tunnel.LastActivity) < requiredTimeout {
continue
}
if tunnel.Status == portainer.EdgeAgentActive && time.Since(tunnel.LastActivity) < activeTimeout {
continue
}
tunnels[key] = *tunnel
}
service.mu.Unlock()
for endpointID, tunnel := range tunnels {
for endpointID, tunnel := range service.activeTunnels {
elapsed := time.Since(tunnel.LastActivity)
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Float64("last_activity_seconds", elapsed.Seconds()).
Msg("environment tunnel monitoring")
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed > requiredTimeout {
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Float64("timeout_seconds", requiredTimeout.Seconds()).
Msg("REQUIRED state timeout exceeded")
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed < activeTimeout {
continue
}
if tunnel.Status == portainer.EdgeAgentActive && elapsed > activeTimeout {
log.Debug().
Int("endpoint_id", int(endpointID)).
Str("status", tunnel.Status).
Float64("status_time_seconds", elapsed.Seconds()).
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("ACTIVE state timeout exceeded")
tunnelPort := tunnel.Port
err := service.snapshotEnvironment(endpointID, tunnel.Port)
if err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
}
service.mu.RUnlock()
log.Debug().
Int("endpoint_id", int(endpointID)).
Float64("last_activity_seconds", elapsed.Seconds()).
Float64("timeout_seconds", activeTimeout.Seconds()).
Msg("last activity timeout exceeded")
if err := service.snapshotEnvironment(endpointID, tunnelPort); err != nil {
log.Error().
Int("endpoint_id", int(endpointID)).
Err(err).
Msg("unable to snapshot Edge environment")
}
service.SetTunnelStatusToIdle(portainer.EndpointID(endpointID))
service.close(portainer.EndpointID(endpointID))
return
}
service.mu.RUnlock()
}
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {

View File

@@ -7,14 +7,22 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestPingAgentPanic(t *testing.T) {
endpointID := portainer.EndpointID(1)
endpoint := &portainer.Endpoint{
ID: 1,
EdgeID: "test-edge-id",
Type: portainer.EdgeAgentOnDockerEnvironment,
UserTrusted: true,
}
s := NewService(nil, nil, nil)
_, store := datastore.MustNewTestStore(t, true, true)
s := NewService(store, nil, nil)
defer func() {
require.Nil(t, recover())
@@ -32,8 +40,9 @@ func TestPingAgentPanic(t *testing.T) {
require.NoError(t, http.Serve(ln, mux))
}()
s.getTunnelDetails(endpointID)
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
err = s.Open(endpoint)
require.NoError(t, err)
s.activeTunnels[endpoint.ID].Port = ln.Addr().(*net.TCPAddr).Port
require.Error(t, s.pingAgent(endpointID))
require.Error(t, s.pingAgent(endpoint.ID))
}

View File

@@ -5,14 +5,18 @@ import (
"errors"
"fmt"
"math/rand"
"net"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/pkg/libcrypto"
"github.com/dchest/uniuri"
"github.com/rs/zerolog/log"
)
const (
@@ -20,18 +24,191 @@ const (
maxAvailablePort = 65535
)
var (
ErrNonEdgeEnv = errors.New("cannot open a tunnel for non-edge environments")
ErrAsyncEnv = errors.New("cannot open a tunnel for async edge environments")
ErrInvalidEnv = errors.New("cannot open a tunnel for an invalid environment")
)
// Open will mark the tunnel as REQUIRED so the agent opens it
func (s *Service) Open(endpoint *portainer.Endpoint) error {
if !endpointutils.IsEdgeEndpoint(endpoint) {
return ErrNonEdgeEnv
}
if endpoint.Edge.AsyncMode {
return ErrAsyncEnv
}
if endpoint.ID == 0 || endpoint.EdgeID == "" || !endpoint.UserTrusted {
return ErrInvalidEnv
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.activeTunnels[endpoint.ID]; ok {
return nil
}
defer cache.Del(endpoint.ID)
tun := &portainer.TunnelDetails{
Status: portainer.EdgeAgentManagementRequired,
Port: s.getUnusedPort(),
LastActivity: time.Now(),
}
username, password := generateRandomCredentials()
if s.chiselServer != nil {
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tun.Port)
if err := s.chiselServer.AddUser(username, password, authorizedRemote); err != nil {
return err
}
}
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
if err != nil {
return err
}
tun.Credentials = credentials
s.activeTunnels[endpoint.ID] = tun
return nil
}
// close removes the tunnel from the map so the agent will close it
func (s *Service) close(endpointID portainer.EndpointID) {
s.mu.Lock()
defer s.mu.Unlock()
tun, ok := s.activeTunnels[endpointID]
if !ok {
return
}
if len(tun.Credentials) > 0 && s.chiselServer != nil {
user, _, _ := strings.Cut(tun.Credentials, ":")
s.chiselServer.DeleteUser(user)
}
if s.ProxyManager != nil {
s.ProxyManager.DeleteEndpointProxy(endpointID)
}
delete(s.activeTunnels, endpointID)
cache.Del(endpointID)
}
// Config returns the tunnel details needed for the agent to connect
func (s *Service) Config(endpointID portainer.EndpointID) portainer.TunnelDetails {
s.mu.RLock()
defer s.mu.RUnlock()
if tun, ok := s.activeTunnels[endpointID]; ok {
return *tun
}
return portainer.TunnelDetails{Status: portainer.EdgeAgentIdle}
}
// TunnelAddr returns the address of the local tunnel, including the port, it
// will block until the tunnel is ready
func (s *Service) TunnelAddr(endpoint *portainer.Endpoint) (string, error) {
if err := s.Open(endpoint); err != nil {
return "", err
}
tun := s.Config(endpoint.ID)
checkinInterval := time.Duration(s.tryEffectiveCheckinInterval(endpoint)) * time.Second
for t0 := time.Now(); ; {
if time.Since(t0) > 2*checkinInterval {
s.close(endpoint.ID)
return "", errors.New("unable to open the tunnel")
}
// Check if the tunnel is established
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: tun.Port})
if err != nil {
time.Sleep(checkinInterval / 100)
continue
}
conn.Close()
break
}
s.UpdateLastActivity(endpoint.ID)
return fmt.Sprintf("127.0.0.1:%d", tun.Port), nil
}
// tryEffectiveCheckinInterval avoids a potential deadlock by returning a
// previous known value after a timeout
func (s *Service) tryEffectiveCheckinInterval(endpoint *portainer.Endpoint) int {
ch := make(chan int, 1)
go func() {
ch <- edge.EffectiveCheckinInterval(s.dataStore, endpoint)
}()
select {
case <-time.After(50 * time.Millisecond):
s.mu.RLock()
defer s.mu.RUnlock()
return s.defaultCheckinInterval
case i := <-ch:
s.mu.Lock()
s.defaultCheckinInterval = i
s.mu.Unlock()
return i
}
}
// UpdateLastActivity sets the current timestamp to avoid the tunnel timeout
func (s *Service) UpdateLastActivity(endpointID portainer.EndpointID) {
s.mu.Lock()
defer s.mu.Unlock()
if tun, ok := s.activeTunnels[endpointID]; ok {
tun.LastActivity = time.Now()
}
}
// NOTE: it needs to be called with the lock acquired
// getUnusedPort is used to generate an unused random port in the dynamic port range.
// Dynamic ports (also called private ports) are 49152 to 65535.
func (service *Service) getUnusedPort() int {
port := randomInt(minAvailablePort, maxAvailablePort)
for _, tunnel := range service.tunnelDetailsMap {
for _, tunnel := range service.activeTunnels {
if tunnel.Port == port {
return service.getUnusedPort()
}
}
conn, err := net.DialTCP("tcp", nil, &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: port})
if err == nil {
conn.Close()
log.Debug().
Int("port", port).
Msg("selected port is in use, trying a different one")
return service.getUnusedPort()
}
return port
}
@@ -39,152 +216,10 @@ func randomInt(min, max int) int {
return min + rand.Intn(max-min)
}
// NOTE: it needs to be called with the lock acquired
func (service *Service) getTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails {
if tunnel, ok := service.tunnelDetailsMap[endpointID]; ok {
return tunnel
}
tunnel := &portainer.TunnelDetails{
Status: portainer.EdgeAgentIdle,
}
service.tunnelDetailsMap[endpointID] = tunnel
cache.Del(endpointID)
return tunnel
}
// GetTunnelDetails returns information about the tunnel associated to an environment(endpoint).
func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) portainer.TunnelDetails {
service.mu.Lock()
defer service.mu.Unlock()
return *service.getTunnelDetails(endpointID)
}
// GetActiveTunnel retrieves an active tunnel which allows communicating with edge agent
func (service *Service) GetActiveTunnel(endpoint *portainer.Endpoint) (portainer.TunnelDetails, error) {
if endpoint.Edge.AsyncMode {
return portainer.TunnelDetails{}, errors.New("cannot open tunnel on async endpoint")
}
tunnel := service.GetTunnelDetails(endpoint.ID)
if tunnel.Status == portainer.EdgeAgentActive {
// update the LastActivity
service.SetTunnelStatusToActive(endpoint.ID)
}
if tunnel.Status == portainer.EdgeAgentIdle || tunnel.Status == portainer.EdgeAgentManagementRequired {
err := service.SetTunnelStatusToRequired(endpoint.ID)
if err != nil {
return portainer.TunnelDetails{}, fmt.Errorf("failed opening tunnel to endpoint: %w", err)
}
if endpoint.EdgeCheckinInterval == 0 {
settings, err := service.dataStore.Settings().Settings()
if err != nil {
return portainer.TunnelDetails{}, fmt.Errorf("failed fetching settings from db: %w", err)
}
endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
time.Sleep(2 * time.Duration(endpoint.EdgeCheckinInterval) * time.Second)
}
return service.GetTunnelDetails(endpoint.ID), nil
}
// SetTunnelStatusToActive update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to ACTIVE.
func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) {
service.mu.Lock()
tunnel := service.getTunnelDetails(endpointID)
tunnel.Status = portainer.EdgeAgentActive
tunnel.Credentials = ""
tunnel.LastActivity = time.Now()
service.mu.Unlock()
cache.Del(endpointID)
}
// SetTunnelStatusToIdle update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to IDLE.
// It removes any existing credentials associated to the tunnel.
func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) {
service.mu.Lock()
tunnel := service.getTunnelDetails(endpointID)
tunnel.Status = portainer.EdgeAgentIdle
tunnel.Port = 0
tunnel.LastActivity = time.Now()
credentials := tunnel.Credentials
if credentials != "" {
tunnel.Credentials = ""
if service.chiselServer != nil {
service.chiselServer.DeleteUser(strings.Split(credentials, ":")[0])
}
}
service.ProxyManager.DeleteEndpointProxy(endpointID)
service.mu.Unlock()
cache.Del(endpointID)
}
// SetTunnelStatusToRequired update the status of the tunnel associated to the specified environment(endpoint).
// It sets the status to REQUIRED.
// If no port is currently associated to the tunnel, it will associate a random unused port to the tunnel
// and generate temporary credentials that can be used to establish a reverse tunnel on that port.
// Credentials are encrypted using the Edge ID associated to the environment(endpoint).
func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error {
defer cache.Del(endpointID)
tunnel := service.getTunnelDetails(endpointID)
service.mu.Lock()
defer service.mu.Unlock()
if tunnel.Port == 0 {
endpoint, err := service.dataStore.Endpoint().Endpoint(endpointID)
if err != nil {
return err
}
tunnel.Status = portainer.EdgeAgentManagementRequired
tunnel.Port = service.getUnusedPort()
tunnel.LastActivity = time.Now()
username, password := generateRandomCredentials()
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tunnel.Port)
if service.chiselServer != nil {
err = service.chiselServer.AddUser(username, password, authorizedRemote)
if err != nil {
return err
}
}
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
if err != nil {
return err
}
tunnel.Credentials = credentials
}
return nil
}
func generateRandomCredentials() (string, string) {
username := uniuri.NewLen(8)
password := uniuri.NewLen(8)
return username, password
}

View File

@@ -62,7 +62,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
MaxBatchDelay: kingpin.Flag("max-batch-delay", "Maximum delay before a batch starts").Duration(),
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("PRETTY", "JSON"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
}
kingpin.Parse()

View File

@@ -42,6 +42,13 @@ func setLoggingMode(mode string) {
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
})
case "NOCOLOR":
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
NoColor: true,
})
case "JSON":
log.Logger = log.Output(os.Stderr)
}

View File

@@ -36,6 +36,7 @@ type (
}
DataStore interface {
Connection() portainer.Connection
Open() (newStore bool, err error)
Init() error
Close() error

View File

@@ -941,6 +941,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.20.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.21.0-rc1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@@ -3,7 +3,6 @@ package client
import (
"bytes"
"errors"
"fmt"
"io"
"maps"
"net/http"
@@ -50,12 +49,12 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
case portainer.AgentOnDockerEnvironment:
return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout)
case portainer.EdgeAgentOnDockerEnvironment:
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
endpointURL := "http://" + tunnelAddr
return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout)
}

View File

@@ -3,7 +3,6 @@ package exec
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path"
@@ -186,11 +185,11 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnel, err := manager.reverseTunnelService.GetActiveTunnel(endpoint)
tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return "", nil, err
}
endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port)
endpointURL = "tcp://" + tunnelAddr
}
args = append(args, "-H", endpointURL)

View File

@@ -70,8 +70,7 @@ func (handler *Handler) customTemplateGitFetch(w http.ResponseWriter, r *http.Re
if err != nil {
log.Warn().Err(err).Msg("failed to download git repository")
if err != nil {
rbErr := rollbackCustomTemplate(backupPath, customTemplate.ProjectPath)
if rbErr := rollbackCustomTemplate(backupPath, customTemplate.ProjectPath); rbErr != nil {
return httperror.InternalServerError("Failed to rollback the custom template folder", rbErr)
}

View File

@@ -2,6 +2,7 @@ package customtemplates
import (
"bytes"
"errors"
"io"
"io/fs"
"net/http"
@@ -19,6 +20,7 @@ import (
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
@@ -49,6 +51,19 @@ func (f *TestFileService) GetFileContent(projectPath, configFilePath string) ([]
return os.ReadFile(filepath.Join(projectPath, configFilePath))
}
type InvalidTestGitService struct {
portainer.GitService
targetFilePath string
}
func (g *InvalidTestGitService) CloneRepository(dest, repoUrl, refName, username, password string, tlsSkipVerify bool) error {
return errors.New("simulate network error")
}
func (g *InvalidTestGitService) LatestCommitID(repositoryURL, referenceName, username, password string, tlsSkipVerify bool) (string, error) {
return "", nil
}
func createTestFile(targetPath string) error {
f, err := os.Create(targetPath)
if err != nil {
@@ -174,4 +189,28 @@ func Test_customTemplateGitFetch(t *testing.T) {
singleAPIRequest(h, jwt2, is, "gfedcba")
})
t.Run("restore git repository if it is failed to download the new git repository", func(t *testing.T) {
invalidGitService := &InvalidTestGitService{
targetFilePath: filepath.Join(template1.ProjectPath, template1.GitConfig.ConfigFilePath),
}
h := NewHandler(requestBouncer, store, fileService, invalidGitService)
req := httptest.NewRequest(http.MethodPut, "/custom_templates/1/git_fetch", bytes.NewBuffer([]byte("{}")))
testhelpers.AddTestSecurityCookie(req, jwt1)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
is.Equal(http.StatusInternalServerError, rr.Code)
var errResp httperror.HandlerError
err = json.NewDecoder(rr.Body).Decode(&errResp)
assert.NoError(t, err, "failed to parse error body")
assert.FileExists(t, gitService.targetFilePath, "previous git repository is not restored")
fileContent, err := os.ReadFile(gitService.targetFilePath)
assert.NoError(t, err, "failed to read target file")
assert.Equal(t, "gfedcba", string(fileContent))
})
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
@@ -30,7 +31,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dataStore d
}
router := h.PathPrefix(routePrefix).Subrouter()
router.Use(bouncer.AuthenticatedAccess)
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet)
router.Handle("/{containerId}/recreate", httperror.LoggerHandler(h.recreate)).Methods(http.MethodPost)

View File

@@ -40,14 +40,14 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
}
// endpoints
endpointRouter := h.PathPrefix("/{id}").Subrouter()
endpointRouter := h.PathPrefix("/docker/{id}").Subrouter()
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
endpointRouter.Use(dockerOnlyMiddleware)
containersHandler := containers.NewHandler("/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
containersHandler := containers.NewHandler("/docker/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
endpointRouter.PathPrefix("/containers").Handler(containersHandler)
imagesHandler := images.NewHandler("/{id}/images", bouncer, dockerClientFactory)
imagesHandler := images.NewHandler("/docker/{id}/images", bouncer, dockerClientFactory)
endpointRouter.PathPrefix("/images").Handler(imagesHandler)
return h
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -25,7 +26,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dockerClien
}
router := h.PathPrefix(routePrefix).Subrouter()
router.Use(bouncer.AuthenticatedAccess)
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
router.Handle("", httperror.LoggerHandler(h.imagesList)).Methods(http.MethodGet)
return h

View File

@@ -19,8 +19,9 @@ import (
// @security jwt
// @param id path int true "EdgeGroup Id"
// @success 204
// @failure 409 "Edge group is in use by an Edge stack or Edge job"
// @failure 503 "Edge compute features are disabled"
// @failure 500
// @failure 500 "Server error"
// @router /edge_groups/{id} [delete]
func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
edgeGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")

View File

@@ -15,10 +15,13 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/edge"
"github.com/portainer/portainer/api/internal/edge/cache"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
type stackStatusResponse struct {
@@ -92,6 +95,8 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
return httperror.Forbidden("Permission denied to access environment", errors.New("the device has not been trusted yet"))
}
firstConn := endpoint.LastCheckInDate == 0
err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint)
if err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
@@ -106,7 +111,7 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
var statusResponse *endpointEdgeStatusInspectResponse
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
statusResponse, err = handler.inspectStatus(tx, r, portainer.EndpointID(endpointID))
statusResponse, err = handler.inspectStatus(tx, r, portainer.EndpointID(endpointID), firstConn)
return err
})
if err != nil {
@@ -121,7 +126,7 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
return cacheResponse(w, endpoint.ID, *statusResponse)
}
func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID) (*endpointEdgeStatusInspectResponse, error) {
func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Request, endpointID portainer.EndpointID, firstConn bool) (*endpointEdgeStatusInspectResponse, error) {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if err != nil {
return nil, err
@@ -133,8 +138,10 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
}
// Take an initial snapshot
if endpoint.LastCheckInDate == 0 {
handler.ReverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
if firstConn {
if err := handler.ReverseTunnelService.Open(endpoint); err != nil {
log.Error().Err(err).Msg("could not open the tunnel")
}
}
agentPlatform, agentPlatformErr := parseAgentPlatform(r)
@@ -153,34 +160,21 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
return nil, httperror.InternalServerError("Unable to persist environment changes inside the database", err)
}
checkinInterval := endpoint.EdgeCheckinInterval
if endpoint.EdgeCheckinInterval == 0 {
settings, err := tx.Settings().Settings()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
checkinInterval = settings.EdgeAgentCheckinInterval
}
tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID)
tunnel := handler.ReverseTunnelService.Config(endpoint.ID)
statusResponse := endpointEdgeStatusInspectResponse{
Status: tunnel.Status,
Port: tunnel.Port,
CheckinInterval: checkinInterval,
CheckinInterval: edge.EffectiveCheckinInterval(tx, endpoint),
Credentials: tunnel.Credentials,
}
schedules, handlerErr := handler.buildSchedules(endpoint.ID, tunnel)
schedules, handlerErr := handler.buildSchedules(endpoint.ID)
if handlerErr != nil {
return nil, handlerErr
}
statusResponse.Schedules = schedules
if tunnel.Status == portainer.EdgeAgentManagementRequired {
handler.ReverseTunnelService.SetTunnelStatusToActive(endpoint.ID)
}
edgeStacksStatus, handlerErr := handler.buildEdgeStacks(tx, endpoint.ID)
if handlerErr != nil {
return nil, handlerErr
@@ -213,9 +207,9 @@ func parseAgentPlatform(r *http.Request) (portainer.EndpointType, error) {
}
}
func (handler *Handler) buildSchedules(endpointID portainer.EndpointID, tunnel portainer.TunnelDetails) ([]edgeJobResponse, *httperror.HandlerError) {
func (handler *Handler) buildSchedules(endpointID portainer.EndpointID) ([]edgeJobResponse, *httperror.HandlerError) {
schedules := []edgeJobResponse{}
for _, job := range tunnel.Jobs {
for _, job := range handler.ReverseTunnelService.EdgeJobs(endpointID) {
var collectLogs bool
if _, ok := job.GroupLogsCollection[endpointID]; ok {
collectLogs = job.GroupLogsCollection[endpointID].CollectLogs

View File

@@ -34,7 +34,7 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
}
_, err := handler.ReverseTunnelService.GetActiveTunnel(endpoint)
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to get the active tunnel", err)
}

View File

@@ -34,7 +34,7 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h
return httperror.InternalServerError("No Edge agent registered with the environment", errors.New("No agent available"))
}
_, err := handler.ReverseTunnelService.GetActiveTunnel(endpoint)
_, err := handler.ReverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return httperror.InternalServerError("Unable to get the active tunnel", err)
}

View File

@@ -59,8 +59,6 @@ func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http
return httperror.InternalServerError("Failed persisting environment in database", err)
}
handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID)
return response.Empty(w)
}

View File

@@ -201,6 +201,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
// @param Gpus formData string false "List of GPUs - json stringified array of {name, value} structs"
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 409 "Name is not unique"
// @failure 500 "Server error"
// @router /endpoints [post]
func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -2,6 +2,7 @@ package endpoints
import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
@@ -17,19 +18,40 @@ import (
"github.com/rs/zerolog/log"
)
type endpointDeleteRequest struct {
ID int `json:"id"`
DeleteCluster bool `json:"deleteCluster"`
}
type endpointDeleteBatchPayload struct {
Endpoints []endpointDeleteRequest `json:"endpoints"`
}
type endpointDeleteBatchPartialResponse struct {
Deleted []int `json:"deleted"`
Errors []int `json:"errors"`
}
func (payload *endpointDeleteBatchPayload) Validate(r *http.Request) error {
if payload == nil || len(payload.Endpoints) == 0 {
return fmt.Errorf("invalid request payload. You must provide a list of environments to delete")
}
return nil
}
// @id EndpointDelete
// @summary Remove an environment(endpoint)
// @description Remove an environment(endpoint).
// @description **Access policy**: administrator
// @summary Remove an environment
// @description Remove the environment associated to the specified identifier and optionally clean-up associated resources.
// @description **Access policy**: Administrator only.
// @tags endpoints
// @security ApiKeyAuth
// @security jwt
// @security ApiKeyAuth || jwt
// @param id path int true "Environment(Endpoint) identifier"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Environment(Endpoint) not found"
// @failure 500 "Server error"
// @success 204 "Environment successfully deleted."
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 404 "Unable to find the environment with the specified identifier inside the database."
// @failure 500 "Server error occurred while attempting to delete the environment."
// @router /endpoints/{id} [delete]
func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
@@ -62,6 +84,63 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
return response.Empty(w)
}
// @id EndpointDeleteBatch
// @summary Remove multiple environments
// @description Remove multiple environments and optionally clean-up associated resources.
// @description **Access policy**: Administrator only.
// @tags endpoints
// @security ApiKeyAuth || jwt
// @accept json
// @produce json
// @param body body endpointDeleteBatchPayload true "List of environments to delete, with optional deleteCluster flag to clean-up assocaited resources (cloud environments only)"
// @success 204 "Environment(s) successfully deleted."
// @failure 207 {object} endpointDeleteBatchPartialResponse "Partial success. Some environments were deleted successfully, while others failed."
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 403 "Unauthorized access or operation not allowed."
// @failure 500 "Server error occurred while attempting to delete the specified environments."
// @router /endpoints [delete]
func (handler *Handler) endpointDeleteBatch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var p endpointDeleteBatchPayload
if err := request.DecodeAndValidateJSONPayload(r, &p); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
resp := endpointDeleteBatchPartialResponse{
Deleted: []int{},
Errors: []int{},
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
for _, e := range p.Endpoints {
if handler.demoService.IsDemoEnvironment(portainer.EndpointID(e.ID)) {
resp.Errors = append(resp.Errors, e.ID)
log.Warn().Err(httperrors.ErrNotAvailableInDemo).Msgf("Unable to remove demo environment %d", e.ID)
continue
}
if err := handler.deleteEndpoint(tx, portainer.EndpointID(e.ID), e.DeleteCluster); err != nil {
resp.Errors = append(resp.Errors, e.ID)
log.Warn().Err(err).Int("environment_id", e.ID).Msg("Unable to remove environment")
continue
}
resp.Deleted = append(resp.Deleted, e.ID)
}
return nil
}); err != nil {
return httperror.InternalServerError("Unable to delete environments", err)
}
if len(resp.Errors) > 0 {
return response.JSONWithStatus(w, resp, http.StatusPartialContent)
}
return response.Empty(w)
}
func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, deleteCluster bool) error {
endpoint, err := tx.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if tx.IsErrObjectNotFound(err) {
@@ -78,23 +157,20 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
}
err = tx.Snapshot().Delete(endpointID)
if err != nil {
log.Warn().Err(err).Msgf("Unable to remove the snapshot from the database")
if err := tx.Snapshot().Delete(endpointID); err != nil {
log.Warn().Err(err).Msg("Unable to remove the snapshot from the database")
}
handler.ProxyManager.DeleteEndpointProxy(endpoint.ID)
if len(endpoint.UserAccessPolicies) > 0 || len(endpoint.TeamAccessPolicies) > 0 {
err = handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update user authorizations")
if err := handler.AuthorizationService.UpdateUsersAuthorizationsTx(tx); err != nil {
log.Warn().Err(err).Msg("Unable to update user authorizations")
}
}
err = tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID)
if err != nil {
log.Warn().Err(err).Msgf("Unable to remove environment relation from the database")
if err := tx.EndpointRelation().DeleteEndpointRelation(endpoint.ID); err != nil {
log.Warn().Err(err).Msg("Unable to remove environment relation from the database")
}
for _, tagID := range endpoint.TagIDs {
@@ -106,9 +182,9 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
}
if handler.DataStore.IsErrObjectNotFound(err) {
log.Warn().Err(err).Msgf("Unable to find tag inside the database")
log.Warn().Err(err).Msg("Unable to find tag inside the database")
} else if err != nil {
log.Warn().Err(err).Msgf("Unable to delete tag relation from the database")
log.Warn().Err(err).Msg("Unable to delete tag relation from the database")
}
}
@@ -122,40 +198,39 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
return e == endpoint.ID
})
err = tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge group")
if err := tx.EdgeGroup().Update(edgeGroup.ID, &edgeGroup); err != nil {
log.Warn().Err(err).Msg("Unable to update edge group")
}
}
edgeStacks, err := tx.EdgeStack().EdgeStacks()
if err != nil {
log.Warn().Err(err).Msgf("Unable to retrieve edge stacks from the database")
log.Warn().Err(err).Msg("Unable to retrieve edge stacks from the database")
}
for idx := range edgeStacks {
edgeStack := &edgeStacks[idx]
if _, ok := edgeStack.Status[endpoint.ID]; ok {
delete(edgeStack.Status, endpoint.ID)
err = tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge stack")
if err := tx.EdgeStack().UpdateEdgeStack(edgeStack.ID, edgeStack); err != nil {
log.Warn().Err(err).Msg("Unable to update edge stack")
}
}
}
registries, err := tx.Registry().ReadAll()
if err != nil {
log.Warn().Err(err).Msgf("Unable to retrieve registries from the database")
log.Warn().Err(err).Msg("Unable to retrieve registries from the database")
}
for idx := range registries {
registry := &registries[idx]
if _, ok := registry.RegistryAccesses[endpoint.ID]; ok {
delete(registry.RegistryAccesses, endpoint.ID)
err = tx.Registry().Update(registry.ID, registry)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update registry accesses")
if err := tx.Registry().Update(registry.ID, registry); err != nil {
log.Warn().Err(err).Msg("Unable to update registry accesses")
}
}
}
@@ -163,7 +238,7 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
if endpointutils.IsEdgeEndpoint(endpoint) {
edgeJobs, err := handler.DataStore.EdgeJob().ReadAll()
if err != nil {
log.Warn().Err(err).Msgf("Unable to retrieve edge jobs from the database")
log.Warn().Err(err).Msg("Unable to retrieve edge jobs from the database")
}
for idx := range edgeJobs {
@@ -171,18 +246,16 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
if _, ok := edgeJob.Endpoints[endpoint.ID]; ok {
delete(edgeJob.Endpoints, endpoint.ID)
err = tx.EdgeJob().Update(edgeJob.ID, edgeJob)
if err != nil {
log.Warn().Err(err).Msgf("Unable to update edge job")
if err := tx.EdgeJob().Update(edgeJob.ID, edgeJob); err != nil {
log.Warn().Err(err).Msg("Unable to update edge job")
}
}
}
}
// delete the pending actions
err = tx.PendingActions().DeleteByEndpointID(endpoint.ID)
if err != nil {
log.Warn().Err(err).Int("endpointId", int(endpoint.ID)).Msgf("Unable to delete pending actions")
if err := tx.PendingActions().DeleteByEndpointID(endpoint.ID); err != nil {
log.Warn().Err(err).Int("endpointId", int(endpoint.ID)).Msg("Unable to delete pending actions")
}
err = tx.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID))

View File

@@ -3,15 +3,16 @@ package endpoints
import (
"net/http"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/pkg/errors"
)
// @id endpointRegistriesList
@@ -127,7 +128,7 @@ func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, name
return true, nil
}
if namespace == "default" {
if !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace && namespace == kubernetes.DefaultNamespace {
return true, nil
}

View File

@@ -69,6 +69,7 @@ func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 404 "Environment(Endpoint) not found"
// @failure 409 "Name is not unique"
// @failure 500 "Server error"
// @router /endpoints/{id} [put]
func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -334,11 +334,16 @@ func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []portai
status := endpoint.Status
if endpointutils.IsEdgeEndpoint(&endpoint) {
isCheckValid := false
edgeCheckinInterval := endpoint.EdgeCheckinInterval
if endpoint.EdgeCheckinInterval == 0 {
if edgeCheckinInterval == 0 {
edgeCheckinInterval = settings.EdgeAgentCheckinInterval
}
if endpoint.Edge.AsyncMode {
edgeCheckinInterval = getShortestAsyncInterval(&endpoint, settings)
}
if edgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 {
isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(edgeCheckinInterval*EdgeDeviceIntervalMultiplier+EdgeDeviceIntervalAdd)
}
@@ -629,3 +634,29 @@ func getEdgeStackStatusParam(r *http.Request) (*portainer.EdgeStackStatusType, e
return &edgeStackStatus, nil
}
func getShortestAsyncInterval(endpoint *portainer.Endpoint, settings *portainer.Settings) int {
var edgeIntervalUseDefault int = -1
pingInterval := endpoint.Edge.PingInterval
if pingInterval == edgeIntervalUseDefault {
pingInterval = settings.Edge.PingInterval
}
shortestAsyncInterval := pingInterval
snapshotInterval := endpoint.Edge.SnapshotInterval
if snapshotInterval == edgeIntervalUseDefault {
snapshotInterval = settings.Edge.SnapshotInterval
}
if shortestAsyncInterval > snapshotInterval {
shortestAsyncInterval = snapshotInterval
}
commandInterval := endpoint.Edge.CommandInterval
if commandInterval == edgeIntervalUseDefault {
commandInterval = settings.Edge.CommandInterval
}
if shortestAsyncInterval > commandInterval {
shortestAsyncInterval = commandInterval
}
return shortestAsyncInterval
}

View File

@@ -71,6 +71,8 @@ func NewHandler(bouncer security.BouncerService, demoService *demo.Service) *Han
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
h.Handle("/endpoints/{id}",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
h.Handle("/endpoints",
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDeleteBatch))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}/snapshot",

View File

@@ -85,7 +85,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.20.3
// @version 2.21.0
// @description.markdown api-description.md
// @termsOfService
@@ -199,7 +199,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/kubernetes"):
http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/docker"):
http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r)
http.StripPrefix("/api", h.DockerHandler).ServeHTTP(w, r)
// Helm subpath under kubernetes -> /api/endpoints/{id}/kubernetes/helm
case strings.HasPrefix(r.URL.Path, "/api/endpoints/") && strings.Contains(r.URL.Path, "/kubernetes/helm"):

View File

@@ -7,12 +7,16 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/kubernetes"
"github.com/portainer/portainer/api/kubernetes/cli"
"github.com/portainer/portainer/api/pendingactions"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/gorilla/mux"
"github.com/pkg/errors"
)
func hideFields(registry *portainer.Registry, hideAccesses bool) {
@@ -83,29 +87,88 @@ func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Re
return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
}
func (handler *Handler) userHasRegistryAccess(r *http.Request) (hasAccess bool, isAdmin bool, err error) {
// this function validates that
//
// 1. user has the appropriate authorizations to perform the request
//
// 2. user has a direct or indirect access to the registry
func (handler *Handler) userHasRegistryAccess(r *http.Request, registry *portainer.Registry) (hasAccess bool, isAdmin bool, err error) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return false, false, err
}
user, err := handler.DataStore.User().Read(securityContext.UserID)
if err != nil {
return false, false, err
}
// Portainer admins always have access to everything
if securityContext.IsAdmin {
return true, true, nil
}
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return false, false, err
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
// mandatory query param that should become a path param
endpointIdStr, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
return false, false, err
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
endpointId := portainer.EndpointID(endpointIdStr)
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointId)
if err != nil {
return false, false, err
}
return true, false, nil
// validate that the request is allowed for the user (READ/WRITE authorization on request path)
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); errors.Is(err, security.ErrAuthorizationRequired) {
return false, false, nil
} else if err != nil {
return false, false, err
}
memberships, err := handler.DataStore.TeamMembership().TeamMembershipsByUserID(user.ID)
if err != nil {
return false, false, nil
}
// validate access for kubernetes namespaces (leverage registry.RegistryAccesses[endpointId].Namespaces)
if endpointutils.IsKubernetesEndpoint(endpoint) {
kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint)
if err != nil {
return false, false, errors.Wrap(err, "unable to retrieve kubernetes client to validate registry access")
}
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
if err != nil {
return false, false, errors.Wrap(err, "unable to retrieve environment's namespaces policies to validate registry access")
}
authorizedNamespaces := registry.RegistryAccesses[endpointId].Namespaces
for _, namespace := range authorizedNamespaces {
// when the default namespace is authorized to use a registry, all users have the ability to use it
// unless the default namespace is restricted: in this case continue to search for other potential accesses authorizations
if namespace == kubernetes.DefaultNamespace && !endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
return true, false, nil
}
namespacePolicy := accessPolicies[namespace]
if security.AuthorizedAccess(user.ID, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies) {
return true, false, nil
}
}
return false, false, nil
}
// validate access for docker environments
// leverage registry.RegistryAccesses[endpointId].UserAccessPolicies (direct access)
// and registry.RegistryAccesses[endpointId].TeamAccessPolicies (indirect access via his teams)
if security.AuthorizedRegistryAccess(registry, user, memberships, endpoint.ID) {
return true, false, nil
}
// when user has no access via their role, direct grant or indirect grant
// then they don't have access to the registry
return false, false, nil
}

View File

@@ -89,6 +89,7 @@ func (payload *registryCreatePayload) Validate(_ *http.Request) error {
// @param body body registryCreatePayload true "Registry details"
// @success 200 {object} portainer.Registry "Success"
// @failure 400 "Invalid request"
// @failure 409 "Another registry with the same name or same URL & credentials already exists"
// @failure 500 "Server error"
// @router /registries [post]
func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -26,14 +26,6 @@ import (
// @failure 500 "Server error"
// @router /registries/{id} [get]
func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if !hasAccess {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid registry identifier route variable", err)
@@ -46,6 +38,14 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
return httperror.InternalServerError("Unable to find a registry with the specified identifier inside the database", err)
}
hasAccess, isAdmin, err := handler.userHasRegistryAccess(r, registry)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
if !hasAccess {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
hideFields(registry, !isAdmin)
return response.JSON(w, registry)
}

View File

@@ -52,7 +52,7 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error {
// @success 200 {object} portainer.Registry "Success"
// @failure 400 "Invalid request"
// @failure 404 "Registry not found"
// @failure 409 "Another registry with the same URL already exists"
// @failure 409 "Another registry with the same name or same URL & credentials already exists"
// @failure 500 "Server error"
// @router /registries/{id} [put]
func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -63,7 +63,7 @@ func (payload *resourceControlCreatePayload) Validate(r *http.Request) error {
// @param body body resourceControlCreatePayload true "Resource control details"
// @success 200 {object} portainer.ResourceControl "Success"
// @failure 400 "Invalid request"
// @failure 409 "Resource control already exists"
// @failure 409 "A resource control is already associated to this resource"
// @failure 500 "Server error"
// @router /resource_controls [post]
func (handler *Handler) resourceControlCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -229,6 +229,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
// @param body body composeStackFromGitRepositoryPayload true "stack config"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/standalone/repository [post]
func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {

View File

@@ -195,6 +195,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
// @param endpointId query int true "Identifier of the environment that will be used to deploy the stack"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/kubernetes/repository [post]
func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {

View File

@@ -188,6 +188,7 @@ func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference
// @param body body swarmStackFromGitRepositoryPayload true "stack config"
// @success 200 {object} portainer.Stack
// @failure 400 "Invalid request"
// @failure 409 "Stack name or webhook ID already exists"
// @failure 500 "Server error"
// @router /stacks/create/swarm/repository [post]
func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {

View File

@@ -46,6 +46,7 @@ func (payload *stackMigratePayload) Validate(r *http.Request) error {
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Stack not found"
// @failure 409 "A stack with the same name is already running on the target environment(endpoint)"
// @failure 500 "Server error"
// @router /stacks/{id}/migrate [post]
func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -29,6 +29,7 @@ import (
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Not found"
// @failure 409 "Stack name is not unique"
// @failure 500 "Server error"
// @router /stacks/{id}/start [post]
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -19,7 +19,7 @@ import (
// @param webhookID path string true "Stack identifier"
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 409 "Conflict"
// @failure 409 "Autoupdate for the stack isn't available"
// @failure 500 "Server error"
// @router /stacks/webhooks/{webhookID} [post]
func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -35,7 +35,7 @@ func (payload *tagCreatePayload) Validate(r *http.Request) error {
// @produce json
// @param body body tagCreatePayload true "Tag details"
// @success 200 {object} portainer.Tag "Success"
// @failure 409 "Tag name exists"
// @failure 409 "This name is already associated to a tag"
// @failure 500 "Server error"
// @router /tags [post]
func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -38,7 +38,7 @@ func (payload *teamCreatePayload) Validate(r *http.Request) error {
// @param body body teamCreatePayload true "details"
// @success 200 {object} portainer.Team "Success"
// @failure 400 "Invalid request"
// @failure 409 "Team already exists"
// @failure 409 "A team with the same name already exists"
// @failure 500 "Server error"
// @router /teams [post]
func (handler *Handler) teamCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {

View File

@@ -118,9 +118,9 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return response.JSONWithStatus(w, accessTokenResponse{rawAPIKey, *apiKey}, http.StatusCreated)
}
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
// userid 1 is the admin user and always uses internal auth
if userid == 1 {
func (handler *Handler) usesInternalAuthentication(userID portainer.UserID) (bool, error) {
// userID 1 is the admin user and always uses internal auth
if userID == 1 {
return true, nil
}

View File

@@ -45,9 +45,9 @@ func (payload *webhookCreatePayload) Validate(r *http.Request) error {
// @produce json
// @param body body webhookCreatePayload true "Webhook data"
// @success 200 {object} portainer.Webhook
// @failure 400
// @failure 409
// @failure 500
// @failure 400 "Invalid request"
// @failure 409 "A webhook for this resource already exists"
// @failure 500 "Server error"
// @router /webhooks [post]
func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload webhookCreatePayload

View File

@@ -18,12 +18,12 @@ import (
)
func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tunnel, err := handler.ReverseTunnelService.GetActiveTunnel(params.endpoint)
tunnelAddr, err := handler.ReverseTunnelService.TunnelAddr(params.endpoint)
if err != nil {
return err
}
agentURL, err := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port))
agentURL, err := url.Parse("http://" + tunnelAddr)
if err != nil {
return err
}
@@ -93,7 +93,7 @@ func (handler *Handler) doProxyWebsocketRequest(
}
if isEdge {
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
handler.ReverseTunnelService.UpdateLastActivity(params.endpoint.ID)
handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive)
}

View File

@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
requesthelpers "github.com/portainer/portainer/pkg/libhttp/request"
@@ -63,3 +64,22 @@ func FetchEndpoint(request *http.Request) (*portainer.Endpoint, error) {
return contextData.(*portainer.Endpoint), nil
}
func CheckEndpointAuthorization(bouncer security.BouncerService) mux.MiddlewareFunc {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
endpoint, err := FetchEndpoint(r)
if err != nil {
httperror.WriteError(w, http.StatusNotFound, "Unable to find an environment on request context", err)
return
}
if err = bouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
httperror.WriteError(w, http.StatusForbidden, "Permission denied to access environment", err)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -26,12 +26,12 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
urlString := endpoint.URL
if endpointutils.IsEdgeEndpoint(endpoint) {
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed starting tunnel")
}
urlString = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
urlString = "http://" + tunnelAddr
}
endpointURL, err := url.ParseURL(urlString)

View File

@@ -1,7 +1,6 @@
package factory
import (
"fmt"
"io"
"net/http"
"strings"
@@ -35,8 +34,11 @@ func (factory *ProxyFactory) newDockerLocalProxy(endpoint *portainer.Endpoint) (
func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
rawURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
rawURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
rawURL = "http://" + tunnelAddr
}
endpointURL, err := url.ParseURL(rawURL)

View File

@@ -138,9 +138,7 @@ func (transport *Transport) executeDockerRequest(request *http.Request) (*http.R
}
if err == nil {
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
} else {
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID)
}
return response, err

View File

@@ -51,8 +51,11 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
}
func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID)
rawURL := fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, err
}
rawURL := "http://" + tunnelAddr
endpointURL, err := url.Parse(rawURL)
if err != nil {

View File

@@ -59,9 +59,7 @@ func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response
response, err := transport.baseTransport.RoundTrip(request)
if err == nil {
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
} else {
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
transport.reverseTunnelService.UpdateLastActivity(transport.endpoint.ID)
}
return response, err

View File

@@ -61,6 +61,7 @@ import (
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/internal/ssl"
"github.com/portainer/portainer/api/internal/upgrade"
k8s "github.com/portainer/portainer/api/kubernetes"
@@ -380,9 +381,7 @@ func (server *Server) Start() error {
}
go shutdown(server.ShutdownCtx, httpsServer)
// Temporarily disable for EE-6905 until we have a solution for the snapshotter
// go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
return httpsServer.ListenAndServeTLS("", "")
}

View File

@@ -430,6 +430,155 @@ func DefaultPortainerAuthorizations() portainer.Authorizations {
}
}
// RemoveTeamAccessPolicies will remove all existing access policies associated to the specified team
func (service *Service) RemoveTeamAccessPolicies(tx dataservices.DataStoreTx, teamID portainer.TeamID) error {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
for policyTeamID := range endpoint.TeamAccessPolicies {
if policyTeamID == teamID {
delete(endpoint.TeamAccessPolicies, policyTeamID)
err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
break
}
}
}
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return err
}
for _, endpointGroup := range endpointGroups {
for policyTeamID := range endpointGroup.TeamAccessPolicies {
if policyTeamID == teamID {
delete(endpointGroup.TeamAccessPolicies, policyTeamID)
err := tx.EndpointGroup().Update(endpointGroup.ID, &endpointGroup)
if err != nil {
return err
}
break
}
}
}
registries, err := tx.Registry().ReadAll()
if err != nil {
return err
}
// iterate over all environments for all registries
// and evict all direct accesses to the registries the team had
// we could have built a range of the teams's environments accesses while removing them above
// but ranging over all environments (registryAccessPolicy is indexed by environmentId)
// makes sure we cleanup all resources in case an access was not removed when a team was removed from an env
for _, registry := range registries {
updateRegistry := false
for _, registryAccessPolicy := range registry.RegistryAccesses {
if _, ok := registryAccessPolicy.TeamAccessPolicies[teamID]; ok {
delete(registryAccessPolicy.TeamAccessPolicies, teamID)
updateRegistry = true
}
}
if updateRegistry {
if err := tx.Registry().Update(registry.ID, &registry); err != nil {
return err
}
}
}
return service.UpdateUsersAuthorizationsTx(tx)
}
// RemoveUserAccessPolicies will remove all existing access policies associated to the specified user
func (service *Service) RemoveUserAccessPolicies(tx dataservices.DataStoreTx, userID portainer.UserID) error {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
for policyUserID := range endpoint.UserAccessPolicies {
if policyUserID == userID {
delete(endpoint.UserAccessPolicies, policyUserID)
err := tx.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
break
}
}
}
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return err
}
for _, endpointGroup := range endpointGroups {
for policyUserID := range endpointGroup.UserAccessPolicies {
if policyUserID == userID {
delete(endpointGroup.UserAccessPolicies, policyUserID)
err := tx.EndpointGroup().Update(endpointGroup.ID, &endpointGroup)
if err != nil {
return err
}
break
}
}
}
registries, err := tx.Registry().ReadAll()
if err != nil {
return err
}
// iterate over all environments for all registries
// and evict all direct accesses to the registries the user had
// we could have built a range of the user's environments accesses while removing them above
// but ranging over all environments (registryAccessPolicy is indexed by environmentId)
// makes sure we cleanup all resources in case an access was not removed when a user was removed from an env
for _, registry := range registries {
updateRegistry := false
for _, registryAccessPolicy := range registry.RegistryAccesses {
if _, ok := registryAccessPolicy.UserAccessPolicies[userID]; ok {
delete(registryAccessPolicy.UserAccessPolicies, userID)
updateRegistry = true
}
}
if updateRegistry {
if err := tx.Registry().Update(registry.ID, &registry); err != nil {
return err
}
}
}
return nil
}
// UpdateUserAuthorizations will update the authorizations for the provided userid
func (service *Service) UpdateUserAuthorizations(tx dataservices.DataStoreTx, userID portainer.UserID) error {
err := service.updateUserAuthorizations(tx, userID)
if err != nil {
return err
}
return nil
}
// UpdateUsersAuthorizations will trigger an update of the authorizations for all the users.
func (service *Service) UpdateUsersAuthorizations() error {
return service.UpdateUsersAuthorizationsTx(service.dataStore)

View File

@@ -1,6 +1,9 @@
package edge
import portainer "github.com/portainer/portainer/api"
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
)
// EndpointRelatedEdgeStacks returns a list of Edge stacks related to this Environment(Endpoint)
func EndpointRelatedEdgeStacks(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) []portainer.EdgeStackID {
@@ -24,3 +27,15 @@ func EndpointRelatedEdgeStacks(endpoint *portainer.Endpoint, endpointGroup *port
return relatedEdgeStacks
}
func EffectiveCheckinInterval(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) int {
if endpoint.EdgeCheckinInterval != 0 {
return endpoint.EdgeCheckinInterval
}
if settings, err := tx.Settings().Settings(); err == nil {
return settings.EdgeAgentCheckinInterval
}
return portainer.DefaultEdgeAgentCheckinIntervalInSeconds
}

View File

@@ -79,21 +79,26 @@ func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService
if endpoint.Kubernetes.Flags.IsServerIngressClassDetected {
return
}
defer func() {
endpoint.Kubernetes.Flags.IsServerIngressClassDetected = true
endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
if err := endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Debug().Err(err).Msg("unable to store found IngressClasses inside the database")
}
}()
cli, err := factory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msg("unable to create kubernetes client for ingress class detection")
return
}
controllers, err := cli.GetIngressControllers()
if err != nil {
log.Debug().Err(err).Msg("failed to fetch ingressclasses")
return
}
@@ -106,69 +111,68 @@ func InitialIngressClassDetection(endpoint *portainer.Endpoint, endpointService
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
if err != nil {
log.Debug().Err(err).Msg("unable to store found IngressClasses inside the database")
return
}
}
func InitialMetricsDetection(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) {
if endpoint.Kubernetes.Flags.IsServerMetricsDetected {
return
}
defer func() {
endpoint.Kubernetes.Flags.IsServerMetricsDetected = true
endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
if err := endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database")
}
}()
cli, err := factory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msg("unable to create kubernetes client for initial metrics detection")
return
}
_, err = cli.GetMetrics()
if err != nil {
if _, err := cli.GetMetrics(); err != nil {
log.Debug().Err(err).Msg("unable to fetch metrics: leaving metrics collection disabled.")
return
}
endpoint.Kubernetes.Configuration.UseServerMetrics = true
if err != nil {
log.Debug().Err(err).Msg("unable to enable UseServerMetrics inside the database")
return
}
}
func storageDetect(endpoint *portainer.Endpoint, endpointService dataservices.EndpointService, factory *cli.ClientFactory) error {
if endpoint.Kubernetes.Flags.IsServerStorageDetected {
return nil
}
defer func() {
endpoint.Kubernetes.Flags.IsServerStorageDetected = true
if err := endpointService.UpdateEndpoint(endpoint.ID, endpoint); err != nil {
log.Info().Err(err).Msg("unable to enable storage class inside the database")
}
}()
cli, err := factory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msg("unable to create Kubernetes client for initial storage detection")
return err
}
storage, err := cli.GetStorage()
if err != nil {
log.Debug().Err(err).Msg("unable to fetch storage classes: leaving storage classes disabled")
return err
}
if len(storage) == 0 {
} else if len(storage) == 0 {
log.Info().Err(err).Msg("zero storage classes found: they may be still building, retrying in 30 seconds")
return fmt.Errorf("zero storage classes found: they may be still building, retrying in 30 seconds")
}
endpoint.Kubernetes.Configuration.StorageClasses = storage
err = endpointService.UpdateEndpoint(
portainer.EndpointID(endpoint.ID),
endpoint,
)
if err != nil {
log.Debug().Err(err).Msg("unable to enable storage class inside the database")
return err
}
return nil
}

View File

@@ -57,8 +57,6 @@ func NewService(
// NewBackgroundSnapshotter queues snapshots of existing edge environments that
// do not have one already
func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService portainer.ReverseTunnelService) {
var endpointIDs []portainer.EndpointID
err := dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
@@ -66,14 +64,16 @@ func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService po
}
for _, e := range endpoints {
if !endpointutils.IsEdgeEndpoint(&e) {
if !endpointutils.IsEdgeEndpoint(&e) || e.Edge.AsyncMode || !e.UserTrusted {
continue
}
s, err := tx.Snapshot().Read(e.ID)
if dataservices.IsErrObjectNotFound(err) ||
(err == nil && s.Docker == nil && s.Kubernetes == nil) {
endpointIDs = append(endpointIDs, e.ID)
if err := tunnelService.Open(&e); err != nil {
log.Error().Err(err).Msg("could not open the tunnel")
}
}
}
@@ -83,11 +83,6 @@ func NewBackgroundSnapshotter(dataStore dataservices.DataStore, tunnelService po
log.Error().Err(err).Msg("background snapshotter failure")
return
}
for _, endpointID := range endpointIDs {
tunnelService.SetTunnelStatusToActive(endpointID)
time.Sleep(10 * time.Second)
}
}
func parseSnapshotFrequency(snapshotInterval string, dataStore dataservices.DataStore) (float64, error) {
@@ -312,10 +307,7 @@ func updateEndpointStatus(tx dataservices.DataStoreTx, endpoint *portainer.Endpo
// Run the pending actions
if latestEndpointReference.Status == portainer.EndpointStatusUp {
err = pendingActionsService.Execute(endpoint.ID)
if err != nil {
log.Error().Err(err).Msg("background schedule error (environment snapshot), unable to execute pending actions")
}
pendingActionsService.Execute(endpoint.ID)
}
}

View File

@@ -4,6 +4,7 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/dataservices/errors"
)
@@ -34,6 +35,7 @@ type testDatastore struct {
version dataservices.VersionService
webhook dataservices.WebhookService
pendingActionsService dataservices.PendingActionsService
connection portainer.Connection
}
func (d *testDatastore) Backup(path string) (string, error) { return "", nil }
@@ -88,6 +90,10 @@ func (d *testDatastore) PendingActions() dataservices.PendingActionsService {
return d.pendingActionsService
}
func (d *testDatastore) Connection() portainer.Connection {
return d.connection
}
func (d *testDatastore) IsErrObjectNotFound(e error) bool {
return false
}
@@ -105,7 +111,8 @@ type datastoreOption = func(d *testDatastore)
// NewDatastore creates new instance of testDatastore.
// Will apply options before returning, opts will be applied from left to right.
func NewDatastore(options ...datastoreOption) *testDatastore {
d := testDatastore{}
conn, _ := database.NewDatabase("boltdb", "", nil)
d := testDatastore{connection: conn}
for _, o := range options {
o(&d)
}

View File

@@ -240,11 +240,11 @@ func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*r
}
func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
if err != nil {
return nil, errors.Wrap(err, "failed activating tunnel")
}
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
endpointURL := fmt.Sprintf("http://%s/kubernetes", tunnelAddr)
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
if err != nil {

View File

@@ -8,6 +8,7 @@ import (
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
@@ -64,8 +65,8 @@ func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, er
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
portainerLabels := map[string]string{
"io.portainer.kubernetes.resourcepool.name": info.Name,
"io.portainer.kubernetes.resourcepool.owner": info.Owner,
"io.portainer.kubernetes.resourcepool.name": stackutils.SanitizeLabel(info.Name),
"io.portainer.kubernetes.resourcepool.owner": stackutils.SanitizeLabel(info.Owner),
}
var ns v1.Namespace

View File

@@ -0,0 +1,7 @@
package cli
import "k8s.io/apimachinery/pkg/version"
func (kcl *KubeClient) ServerVersion() (*version.Info, error) {
return kcl.cli.Discovery().ServerVersion()
}

View File

@@ -4,11 +4,11 @@ import (
"bytes"
"fmt"
"io"
"regexp"
"strconv"
"strings"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/stacks/stackutils"
"gopkg.in/yaml.v3"
)
@@ -28,19 +28,13 @@ type KubeAppLabels struct {
Kind string
}
// convert string to valid kubernetes label by replacing invalid characters with periods
func sanitizeLabel(value string) string {
re := regexp.MustCompile(`[^A-Za-z0-9\.\-\_]+`)
return re.ReplaceAllString(value, ".")
}
// ToMap converts KubeAppLabels to a map[string]string
func (kal *KubeAppLabels) ToMap() map[string]string {
return map[string]string{
labelPortainerAppStackID: strconv.Itoa(kal.StackID),
labelPortainerAppStack: kal.StackName,
labelPortainerAppName: kal.StackName,
labelPortainerAppOwner: sanitizeLabel(kal.Owner),
labelPortainerAppStack: stackutils.SanitizeLabel(kal.StackName),
labelPortainerAppName: stackutils.SanitizeLabel(kal.StackName),
labelPortainerAppOwner: stackutils.SanitizeLabel(kal.Owner),
labelPortainerAppKind: kal.Kind,
}
}
@@ -49,7 +43,7 @@ func (kal *KubeAppLabels) ToMap() map[string]string {
func GetHelmAppLabels(name, owner string) map[string]string {
return map[string]string{
labelPortainerAppName: name,
labelPortainerAppOwner: sanitizeLabel(owner),
labelPortainerAppOwner: stackutils.SanitizeLabel(owner),
}
}

View File

@@ -55,14 +55,19 @@ func (service *PendingActionsService) Create(r portainer.PendingActions) error {
return service.dataStore.PendingActions().Create(&r)
}
func (service *PendingActionsService) Execute(id portainer.EndpointID) error {
func (service *PendingActionsService) Execute(id portainer.EndpointID) {
// Run in a goroutine to avoid blocking the main thread due to db tx =
go service.execute(id)
}
func (service *PendingActionsService) execute(environmentID portainer.EndpointID) {
service.mu.Lock()
defer service.mu.Unlock()
endpoint, err := service.dataStore.Endpoint().Endpoint(id)
endpoint, err := service.dataStore.Endpoint().Endpoint(environmentID)
if err != nil {
return fmt.Errorf("failed to retrieve environment %d: %w", id, err)
log.Debug().Msgf("failed to retrieve environment %d: %v", environmentID, err)
return
}
isKubernetesEndpoint := endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint)
@@ -70,42 +75,50 @@ func (service *PendingActionsService) Execute(id portainer.EndpointID) error {
// EndpointStatusUp is only relevant for non-Kubernetes endpoints
// Sometimes the endpoint is UP but the status is not updated in the database
if !isKubernetesEndpoint && endpoint.Status != portainer.EndpointStatusUp {
log.Debug().Msgf("Environment %q (id: %d) is not up", endpoint.Name, id)
return fmt.Errorf("environment %q (id: %d) is not up", endpoint.Name, id)
log.Debug().Msgf("failed to create Kubernetes client for environment %d: %v", environmentID, err)
return
}
// For Kubernetes endpoints, we need to check if the endpoint is up by creating a kube client
if isKubernetesEndpoint {
_, err := service.kubeFactory.GetKubeClient(endpoint)
if err != nil {
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, id)
return fmt.Errorf("environment %q (id: %d) is not up", endpoint.Name, id)
if client, _ := service.kubeFactory.GetKubeClient(endpoint); client != nil {
if _, err = client.ServerVersion(); err != nil {
log.Debug().Err(err).Msgf("Environment %q (id: %d) is not up", endpoint.Name, environmentID)
return
}
}
}
pendingActions, err := service.dataStore.PendingActions().ReadAll()
if err != nil {
log.Error().Err(err).Msgf("failed to retrieve pending actions")
return fmt.Errorf("failed to retrieve pending actions for environment %d: %w", id, err)
return
}
for _, endpointPendingAction := range pendingActions {
if endpointPendingAction.EndpointID == id {
if len(pendingActions) > 0 {
log.Debug().Msgf("Found %d pending actions", len(pendingActions))
}
for i, endpointPendingAction := range pendingActions {
if endpointPendingAction.EndpointID == environmentID {
if i == 0 {
// We have at least 1 pending action for this environment
log.Debug().Msgf("Executing pending actions for environment %d", environmentID)
}
err := service.executePendingAction(endpointPendingAction, endpoint)
if err != nil {
log.Warn().Err(err).Msgf("failed to execute pending action")
return fmt.Errorf("failed to execute pending action: %w", err)
continue
}
err = service.dataStore.PendingActions().Delete(endpointPendingAction.ID)
if err != nil {
log.Error().Err(err).Msgf("failed to delete pending action")
return fmt.Errorf("failed to delete pending action: %w", err)
continue
}
}
}
return nil
}
func (service *PendingActionsService) executePendingAction(pendingAction portainer.PendingActions, endpoint *portainer.Endpoint) error {

View File

@@ -14,6 +14,7 @@ import (
"github.com/portainer/portainer/pkg/featureflags"
"golang.org/x/oauth2"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/version"
)
type (
@@ -1305,7 +1306,6 @@ type (
Status string
LastActivity time.Time
Port int
Jobs []EdgeJob
Credentials string
}
@@ -1496,6 +1496,8 @@ type (
// KubeClient represents a service used to query a Kubernetes environment(endpoint)
KubeClient interface {
ServerVersion() (*version.Info, error)
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
IsRBACEnabled() (bool, error)
GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error)
@@ -1562,13 +1564,13 @@ type (
ReverseTunnelService interface {
StartTunnelServer(addr, port string, snapshotService SnapshotService) error
StopTunnelServer() error
GenerateEdgeKey(url, host string, endpointIdentifier int) string
SetTunnelStatusToActive(endpointID EndpointID)
SetTunnelStatusToRequired(endpointID EndpointID) error
SetTunnelStatusToIdle(endpointID EndpointID)
GenerateEdgeKey(apiURL, tunnelAddr string, endpointIdentifier int) string
Open(endpoint *Endpoint) error
Config(endpointID EndpointID) TunnelDetails
TunnelAddr(endpoint *Endpoint) (string, error)
UpdateLastActivity(endpointID EndpointID)
KeepTunnelAlive(endpointID EndpointID, ctx context.Context, maxKeepAlive time.Duration)
GetTunnelDetails(endpointID EndpointID) TunnelDetails
GetActiveTunnel(endpoint *Endpoint) (TunnelDetails, error)
EdgeJobs(endpointId EndpointID) []EdgeJob
AddEdgeJob(endpoint *Endpoint, edgeJob *EdgeJob)
RemoveEdgeJob(edgeJobID EdgeJobID)
RemoveEdgeJobFromEndpoint(endpointID EndpointID, edgeJobID EdgeJobID)
@@ -1599,7 +1601,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.20.3"
APIVersion = "2.21.0-rc1"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1883,8 +1885,6 @@ const (
EdgeAgentIdle string = "IDLE"
// EdgeAgentManagementRequired represents a required state for a tunnel connected to an Edge environment(endpoint)
EdgeAgentManagementRequired string = "REQUIRED"
// EdgeAgentActive represents an active state for a tunnel connected to an Edge environment(endpoint)
EdgeAgentActive string = "ACTIVE"
)
// represents an authorization type

View File

@@ -31,6 +31,7 @@ const (
type unpackerCmdBuilderOptions struct {
pullImage bool
prune bool
forceRecreate bool
composeDestination string
registries []portainer.Registry
}
@@ -62,12 +63,13 @@ func (d *stackDeployer) buildUnpackerCmdForStack(stack *portainer.Stack, operati
return fn(stack, opts, registriesStrings, envStrings), nil
}
// deploy [-u username -p password] [--skip-tls-verify] [--env KEY1=VALUE1 --env KEY2=VALUE2] <git-repo-url> <ref> <project-name> <destination> <compose-file-path> [<more-file-paths>...]
// deploy [-u username -p password] [--skip-tls-verify] [--force-recreate] [-k] [--env KEY1=VALUE1 --env KEY2=VALUE2] <git-repo-url> <ref> <project-name> <destination> <compose-file-path> [<more-file-paths>...]
func buildDeployCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string {
cmd := []string{}
cmd = append(cmd, UnpackerCmdDeploy)
cmd = appendGitAuthIfNeeded(cmd, stack)
cmd = appendSkipTLSVerifyIfNeeded(cmd, stack)
cmd = appendForceRecreateIfNeeded(cmd, opts.forceRecreate)
cmd = append(cmd, env...)
cmd = append(cmd, registries...)
cmd = append(cmd, stack.GitConfig.URL)
@@ -124,12 +126,13 @@ func buildComposeStopCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions,
return cmd
}
// swarm-deploy [-u username -p password] [--skip-tls-verify] [-f] [-r] [--env KEY1=VALUE1 --env KEY2=VALUE2] <git-repo-url> <git-ref> <project-name> <destination> <compose-file-path> [<more-file-paths>...]
// swarm-deploy [-u username -p password] [--skip-tls-verify] [--force-recreate] [-f] [-r] [-k] [--env KEY1=VALUE1 --env KEY2=VALUE2] <git-repo-url> <git-ref> <project-name> <destination> <compose-file-path> [<more-file-paths>...]
func buildSwarmDeployCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string {
cmd := []string{}
cmd = append(cmd, UnpackerCmdSwarmDeploy)
cmd = appendGitAuthIfNeeded(cmd, stack)
cmd = appendSkipTLSVerifyIfNeeded(cmd, stack)
cmd = appendForceRecreateIfNeeded(cmd, opts.forceRecreate)
if opts.pullImage {
cmd = append(cmd, "-f")
}
@@ -203,6 +206,13 @@ func appendAdditionalFiles(cmd []string, files []string) []string {
return cmd
}
func appendForceRecreateIfNeeded(cmd []string, forceRecreate bool) []string {
if forceRecreate {
cmd = append(cmd, "--force-recreate")
}
return cmd
}
func getRegistry(registries []portainer.Registry, dataStore dataservices.DataStore) []string {
cmds := []string{}

View File

@@ -70,7 +70,8 @@ func (d *stackDeployer) DeployRemoteComposeStack(
endpoint,
OperationDeploy,
unpackerCmdBuilderOptions{
registries: registries,
forceRecreate: forceRecreate,
registries: registries,
},
)
}
@@ -119,10 +120,10 @@ func (d *stackDeployer) DeployRemoteSwarmStack(
defer d.swarmStackManager.Logout(endpoint)
return d.remoteStack(stack, endpoint, OperationSwarmDeploy, unpackerCmdBuilderOptions{
pullImage: pullImage,
prune: prune,
registries: registries,
pullImage: pullImage,
prune: prune,
forceRecreate: stack.AutoUpdate != nil && stack.AutoUpdate.ForceUpdate,
registries: registries,
})
}

View File

@@ -3,6 +3,7 @@ package stackutils
import (
"fmt"
"regexp"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
@@ -37,10 +38,11 @@ func ResourceControlID(endpointID portainer.EndpointID, name string) string {
return fmt.Sprintf("%d_%s", endpointID, name)
}
// convert string to valid kubernetes label by replacing invalid characters with periods
// convert string to valid kubernetes label by replacing invalid characters with periods and removing any periods at the beginning or end of the string
func SanitizeLabel(value string) string {
re := regexp.MustCompile(`[^A-Za-z0-9\.\-\_]+`)
return re.ReplaceAllString(value, ".")
onlyAllowedCharacterString := re.ReplaceAllString(value, ".")
return strings.Trim(onlyAllowedCharacterString, ".-_")
}
// IsGitStack checks if the stack is a git stack or not

View File

@@ -16,7 +16,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
parent: 'endpoint',
url: '/docker',
abstract: true,
onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, Notifications, StateManager, SystemService) {
onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, Notifications, StateManager, SystemService, EndpointProvider) {
return $async(async () => {
const dockerTypes = [PortainerEndpointTypes.DockerEnvironment, PortainerEndpointTypes.AgentOnDockerEnvironment, PortainerEndpointTypes.EdgeAgentOnDockerEnvironment];
@@ -44,9 +44,11 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnDockerEnvironment) {
params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'docker.dashboard' };
} else {
EndpointProvider.clean();
Notifications.error('Failed loading environment', e);
}
$state.go('portainer.home', params, { reload: true, inherit: false });
return false;
}
async function checkEndpointStatus(endpoint) {

View File

@@ -15,9 +15,12 @@
<td>{{ device.Name }}</td>
<td>{{ device.Vendor }}</td>
</tr>
<tr ng-if="!$ctrl.devices">
<tr ng-if="$ctrl.devices === undefined">
<td colspan="2" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.devices === null">
<td colspan="2" class="text-muted text-center"> Failed to load devices. </td>
</tr>
<tr ng-if="$ctrl.devices.length === 0">
<td colspan="2" class="text-muted text-center"> No device available. </td>
</tr>

View File

@@ -15,9 +15,12 @@
<td>{{ disk.Vendor }}</td>
<td>{{ disk.Size | humansize }}</td>
</tr>
<tr ng-if="!$ctrl.disks">
<tr ng-if="$ctrl.disks === undefined">
<td colspan="2" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.disks === null">
<td colspan="2" class="text-muted text-center"> Failed to load devices. </td>
</tr>
<tr ng-if="$ctrl.disks.length === 0">
<td colspan="2" class="text-muted text-center"> No disks available. </td>
</tr>

View File

@@ -41,7 +41,7 @@
class="form-control"
ng-model="$ctrl.data.mountPoint"
name="nfs_mountpoint"
placeholder="e.g. /export/share, :/export/share, /share or :/share"
placeholder="e.g. /export/share, :/export/share, address:/export/share, /share, :/share or address:/share"
required
/>
</div>

View File

@@ -70,22 +70,6 @@ angular.module('portainer.docker').factory('NetworkService', [
return Network.remove({ id: id }).$promise;
};
service.disconnectContainer = function (networkId, containerId, force) {
return Network.disconnect({ id: networkId }, { Container: containerId, Force: force }).$promise;
};
service.connectContainer = function (networkId, containerId, aliases) {
var payload = {
Container: containerId,
};
if (aliases) {
payload.EndpointConfig = {
Aliases: aliases,
};
}
return Network.connect({ id: networkId }, payload).$promise;
};
return service;
},
]);

View File

@@ -121,7 +121,8 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
.map((k) => k + '=' + params[k])
.join('&');
initTerm(url, ExecService.resizeTTY.bind(this, params.id));
const isLinuxCommand = execConfig.Cmd ? isLinuxTerminalCommand(execConfig.Cmd[0]) : false;
initTerm(url, ExecService.resizeTTY.bind(this, params.id), isLinuxCommand);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to exec into container');
@@ -165,7 +166,12 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
restcall(termWidth + add, termHeight + add, 1);
}
function initTerm(url, resizeRestCall) {
function isLinuxTerminalCommand(command) {
const validShellCommands = ['ash', 'bash', 'dash', 'sh'];
return validShellCommands.includes(command);
}
function initTerm(url, resizeRestCall, isLinuxTerm = false) {
let resizefun = resize.bind(this, resizeRestCall);
if ($transition$.params().nodeName) {
@@ -183,13 +189,20 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
socket.onopen = function () {
$scope.state = states.connected;
term = new Terminal();
socket.send('export LANG=C.UTF-8\n');
socket.send('export LC_ALL=C.UTF-8\n');
socket.send('clear\n');
if (isLinuxTerm) {
// linux terminals support xterm
socket.send('export LANG=C.UTF-8\n');
socket.send('export LC_ALL=C.UTF-8\n');
socket.send('export TERM="xterm-256color"\n');
socket.send('alias ls="ls --color=auto"\n');
socket.send('echo -e "\\033[2J\\033[H"\n');
}
term.onData(function (data) {
socket.send(data);
});
var terminal_container = document.getElementById('terminal-container');
term.open(terminal_container);
term.focus();
@@ -207,11 +220,13 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
socket.onmessage = function (e) {
term.write(e.data);
};
socket.onerror = function (err) {
$scope.disconnect();
$scope.$apply();
Notifications.error('Failure', err, 'Connection error');
$scope.$apply();
};
socket.onclose = function () {
$scope.disconnect();
$scope.$apply();

View File

@@ -29,6 +29,7 @@
<select class="form-control" ng-model="formValues.command" id="command">
<option value="ash" ng-if="imageOS == 'linux'">/bin/ash</option>
<option value="bash" ng-if="imageOS == 'linux'">/bin/bash</option>
<option value="dash" ng-if="imageOS == 'linux'">/bin/dash</option>
<option value="sh" ng-if="imageOS == 'linux'">/bin/sh</option>
<option value="powershell" ng-if="imageOS == 'windows'">powershell</option>
<option value="cmd.exe" ng-if="imageOS == 'windows'">cmd.exe</option>

View File

@@ -349,15 +349,5 @@
</div>
</div>
<docker-container-networks-datatable
ng-if="container.NetworkSettings.Networks"
dataset="container.NetworkSettings.Networks"
container="container"
available-networks="availableNetworks"
on-join="(containerJoinNetwork)"
join-in-progress="state.joinNetworkInProgress"
on-leave="(containerLeaveNetwork)"
leave-in-progress="state.leaveNetworkInProgress"
node-name="nodeName"
>
<docker-container-networks-datatable ng-if="container.NetworkSettings.Networks" dataset="container.NetworkSettings.Networks" container="container" node-name="nodeName">
</docker-container-networks-datatable>

View File

@@ -14,37 +14,13 @@ angular.module('portainer.docker').controller('ContainerController', [
'$filter',
'$async',
'Commit',
'ContainerHelper',
'ContainerService',
'ImageHelper',
'NetworkService',
'Notifications',
'ResourceControlService',
'RegistryService',
'ImageService',
'HttpRequestHelper',
'Authentication',
'endpoint',
function (
$q,
$scope,
$state,
$transition$,
$filter,
$async,
Commit,
ContainerHelper,
ContainerService,
ImageHelper,
NetworkService,
Notifications,
ResourceControlService,
RegistryService,
ImageService,
HttpRequestHelper,
Authentication,
endpoint
) {
function ($q, $scope, $state, $transition$, $filter, $async, Commit, ContainerService, ImageHelper, Notifications, HttpRequestHelper, Authentication, endpoint) {
$scope.resourceType = ResourceControlType.Container;
$scope.endpoint = endpoint;
$scope.isAdmin = Authentication.isAdmin();
@@ -61,8 +37,6 @@ angular.module('portainer.docker').controller('ContainerController', [
$scope.state = {
recreateContainerInProgress: false,
joinNetworkInProgress: false,
leaveNetworkInProgress: false,
pullImageValidity: false,
};
@@ -225,36 +199,6 @@ angular.module('portainer.docker').controller('ContainerController', [
});
};
$scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) {
$scope.state.leaveNetworkInProgress = true;
NetworkService.disconnectContainer(networkId, container.Id, false)
.then(function success() {
Notifications.success('Container left network', container.Id);
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to disconnect container from network');
})
.finally(function final() {
$scope.state.leaveNetworkInProgress = false;
});
};
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
$scope.state.joinNetworkInProgress = true;
NetworkService.connectContainer(networkId, container.Id)
.then(function success() {
Notifications.success('Container joined network', container.Id);
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect container to network');
})
.finally(function final() {
$scope.state.joinNetworkInProgress = false;
});
};
async function commitContainerAsync() {
$scope.config.commitInProgress = true;
const registryModel = $scope.config.RegistryModel;
@@ -349,17 +293,6 @@ angular.module('portainer.docker').controller('ContainerController', [
}
}
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
NetworkService.networks(provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE', false, provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25)
.then(function success(data) {
var networks = data;
$scope.availableNetworks = networks;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
update();
},
]);

View File

@@ -6,10 +6,14 @@
<rd-widget-body>
<form class="form-horizontal" name="$ctrl.form">
<div class="col-sm-12 form-section-title"> Host and filesystem </div>
<div ng-if="!$ctrl.isAgent" class="form-group">
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'info'" mode="'primary'" class-name="'space-right'"></pr-icon>
These features are only available for an Agent enabled environments.
<span class="text-muted"
>The environment must be <a href="https://docs.portainer.io/start/agent">running the Portainer Agent</a> to use this functionality, and the root of the host must be
bind-mounted to <b>/host</b> in the agent deployment. Check
<a href="https://docs.portainer.io/user/docker/host/setup#enable-host-management-features">our documentation</a> for more information.</span
>
</span>
</div>
<div class="form-group">

View File

@@ -17,7 +17,7 @@ angular.module('portainer.docker').controller('HostViewController', [
this.engineDetails = {};
this.hostDetails = {};
this.devices = null;
this.devices = undefined;
this.disks = null;
function initView() {

View File

@@ -3,6 +3,7 @@ import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDestructive } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
angular.module('portainer.docker').controller('ImagesController', [
'$scope',
@@ -157,24 +158,20 @@ angular.module('portainer.docker').controller('ImagesController', [
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
* @param {boolean} force
*/
function removeAction(selectedItems, force) {
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (image) {
async function removeAction(selectedItems, force) {
async function doRemove(image) {
HttpRequestHelper.setPortainerAgentTargetHeader(image.nodeName);
ImageService.deleteImage(image.id, force)
return ImageService.deleteImage(image.id, force)
.then(function success() {
Notifications.success('Image successfully removed', image.id);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image');
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
}
await processItemsInBatches(selectedItems, doRemove);
$state.reload();
}
$scope.setPullImageValidity = setPullImageValidity;

View File

@@ -1,6 +1,7 @@
import _ from 'lodash-es';
import DockerNetworkHelper from '@/docker/helpers/networkHelper';
import { confirmDelete } from '@@/modals/confirm';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
angular.module('portainer.docker').controller('NetworksController', [
'$q',
@@ -17,10 +18,10 @@ angular.module('portainer.docker').controller('NetworksController', [
if (!confirmed) {
return null;
}
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (network) {
async function doRemove(network) {
HttpRequestHelper.setPortainerAgentTargetHeader(network.NodeName);
NetworkService.remove(network.Id)
return NetworkService.remove(network.Id)
.then(function success() {
Notifications.success('Network successfully removed', network.Name);
var index = $scope.networks.indexOf(network);
@@ -28,14 +29,11 @@ angular.module('portainer.docker').controller('NetworksController', [
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove network');
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
}
await processItemsInBatches(selectedItems, doRemove);
$state.reload();
};
$scope.getNetworks = getNetworks;

View File

@@ -1,4 +1,6 @@
import { confirmDelete } from '@@/modals/confirm';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
angular.module('portainer.docker').controller('SecretsController', [
'$scope',
'$state',
@@ -10,9 +12,9 @@ angular.module('portainer.docker').controller('SecretsController', [
if (!confirmed) {
return null;
}
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (secret) {
SecretService.remove(secret.Id)
async function doRemove(secret) {
return SecretService.remove(secret.Id)
.then(function success() {
Notifications.success('Secret successfully removed', secret.Name);
var index = $scope.secrets.indexOf(secret);
@@ -20,14 +22,11 @@ angular.module('portainer.docker').controller('SecretsController', [
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove secret');
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
}
await processItemsInBatches(selectedItems, doRemove);
$state.reload();
};
$scope.getSecrets = getSecrets;

View File

@@ -99,7 +99,7 @@ angular.module('portainer.docker').controller('CreateVolumeController', [
}
driverOptions.push({ name: 'o', value: options });
var mountPoint = data.mountPoint[0] === ':' ? data.mountPoint : ':' + data.mountPoint;
var mountPoint = data.mountPoint.indexOf(':') === -1 ? ':' + data.mountPoint : data.mountPoint;
driverOptions.push({ name: 'device', value: mountPoint });
}

View File

@@ -1,5 +1,7 @@
import { confirmDelete } from '@@/modals/confirm';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
angular.module('portainer.docker').controller('VolumesController', [
'$q',
'$scope',
@@ -13,27 +15,23 @@ angular.module('portainer.docker').controller('VolumesController', [
'endpoint',
function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, Authentication, endpoint) {
$scope.removeAction = function (selectedItems) {
confirmDelete('Do you want to remove the selected volume(s)?').then((confirmed) => {
confirmDelete('Do you want to remove the selected volume(s)?').then(async (confirmed) => {
async function doRemove(volume) {
HttpRequestHelper.setPortainerAgentTargetHeader(volume.NodeName);
return VolumeService.remove(volume)
.then(function success() {
Notifications.success('Volume successfully removed', volume.Id);
var index = $scope.volumes.indexOf(volume);
$scope.volumes.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove volume');
});
}
if (confirmed) {
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (volume) {
HttpRequestHelper.setPortainerAgentTargetHeader(volume.NodeName);
VolumeService.remove(volume)
.then(function success() {
Notifications.success('Volume successfully removed', volume.Id);
var index = $scope.volumes.indexOf(volume);
$scope.volumes.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove volume');
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
await processItemsInBatches(selectedItems, doRemove);
$state.reload();
}
});
};

View File

@@ -63,12 +63,13 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
$state,
endpoint,
KubernetesHealthService,
KubernetesNamespaceService,
Notifications,
StateManager,
$http,
Authentication,
UserService
UserService,
EndpointService,
EndpointProvider
) {
return $async(async () => {
// if the user wants to use front end cache for performance, set the angular caching settings
@@ -93,39 +94,57 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
$state.go('portainer.home');
return;
}
try {
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
//edge
try {
await KubernetesHealthService.ping(endpoint.Id);
endpoint.Status = EnvironmentStatus.Up;
} catch (e) {
endpoint.Status = EnvironmentStatus.Down;
}
const status = await checkEndpointStatus(
endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
? KubernetesHealthService.ping(endpoint.Id)
: // use selfsubject access review to check if we can connect to the kubernetes environment
// because it gets a fast response, and is accessible to all users
getSelfSubjectAccessReview(endpoint.Id, 'default')
);
if (endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
await updateEndpointStatus(endpoint, status);
}
endpoint.Status = status;
if (endpoint.Status === EnvironmentStatus.Down) {
throw new Error(
endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment
? 'Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.'
: `The environment named ${endpoint.Name} is unreachable.`
);
}
await StateManager.updateEndpointState(endpoint);
if (endpoint.Type === PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment && endpoint.Status === EnvironmentStatus.Down) {
throw new Error('Unable to contact Edge agent, please ensure that the agent is properly running on the remote environment.');
}
// use selfsubject access review to check if we can connect to the kubernetes environment
// because it's gets a fast response, and is accessible to all users
try {
await getSelfSubjectAccessReview(endpoint.Id, 'default');
} catch (e) {
throw new Error(`The environment named ${endpoint.Name} is unreachable.`);
}
} catch (e) {
let params = {};
if (endpoint.Type == PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment) {
params = { redirect: true, environmentId: endpoint.Id, environmentName: endpoint.Name, route: 'kubernetes.dashboard' };
} else {
EndpointProvider.clean();
Notifications.error('Failed loading environment', e);
}
$state.go('portainer.home', params, { reload: true, inherit: false });
return false;
}
async function checkEndpointStatus(promise) {
try {
await promise;
return EnvironmentStatus.Up;
} catch (e) {
return EnvironmentStatus.Down;
}
}
async function updateEndpointStatus(endpoint, status) {
if (endpoint.Status === status) {
return;
}
await EndpointService.updateEndpoint(endpoint.Id, { Status: status });
}
});
},

View File

@@ -4,6 +4,8 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
import { getSchemeFromPort } from '@/react/common/network-utils';
angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatableController', [
'$scope',
'$controller',
@@ -105,7 +107,10 @@ angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatab
// Map all load balancer service ports to ip address
let loadBalancerURLs = [];
if (item.LoadBalancerIPAddress) {
loadBalancerURLs = item.PublishedPorts.map((pp) => `http://${item.LoadBalancerIPAddress}:${pp.Port}`);
loadBalancerURLs = item.PublishedPorts.map((pp) => {
const scheme = getSchemeFromPort(pp.Port);
return `${scheme}://${item.LoadBalancerIPAddress}:${pp.Port}`;
});
}
// combine ingress urls

View File

@@ -70,7 +70,7 @@
ng-click="$ctrl.changeOrderBy('Name')"
></table-column-header>
</th>
<th>
<th ng-if="!$ctrl.deploymentOptions.hideStacksFunctionality">
<table-column-header
col-title="'Stack'"
can-sort="true"
@@ -126,7 +126,7 @@
<span style="margin-left: 5px" class="label label-info image-tag" ng-if="$ctrl.isSystemNamespace(item)">system</span>
<span style="margin-left: 5px" class="label label-primary image-tag" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
</td>
<td>{{ item.StackName || '-' }}</td>
<td ng-if="!$ctrl.deploymentOptions.hideStacksFunctionality">{{ item.StackName || '-' }}</td>
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
</td>

View File

@@ -1,12 +1,14 @@
import { KubernetesApplicationDeploymentTypes } from 'Kubernetes/models/application/models/appConstants';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
angular.module('portainer.docker').controller('KubernetesNodeApplicationsDatatableController', [
'$scope',
'$controller',
'$async',
'DatatableService',
function ($scope, $controller, DatatableService) {
function ($scope, $controller, $async, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.isSystemNamespace = function (item) {
@@ -18,37 +20,41 @@ angular.module('portainer.docker').controller('KubernetesNodeApplicationsDatatab
};
this.$onInit = function () {
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
this.setDefaults();
this.prepareTableFromDataset();
return $async(async () => {
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
this.setDefaults();
this.prepareTableFromDataset();
this.state.orderBy = this.orderBy;
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
this.deploymentOptions = await getDeploymentOptions();
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
this.state.orderBy = this.orderBy;
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
});
};
},
]);

View File

@@ -68,9 +68,9 @@
ng-click="$ctrl.changeOrderBy('Name')"
></table-column-header>
</th>
<th>
<th ng-if="!$ctrl.deploymentOptions.hideStacksFunctionality">
<table-column-header
col-title="'StackName'"
col-title="'Stack'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'StackName'"
is-sorted-desc="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"
@@ -114,7 +114,7 @@
<a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a>
<span style="margin-left: 5px" class="label label-primary image-tag" ng-if="$ctrl.isExternalApplication(item)">external</span>
</td>
<td>{{ item.StackName || '-' }}</td>
<td ng-if="!$ctrl.deploymentOptions.hideStacksFunctionality">{{ item.StackName || '-' }}</td>
<td title="{{ item.Image }}"
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
>

View File

@@ -1,10 +1,12 @@
import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
angular.module('portainer.docker').controller('KubernetesResourcePoolApplicationsDatatableController', [
'$scope',
'$controller',
'$async',
'DatatableService',
function ($scope, $controller, DatatableService) {
function ($scope, $controller, $async, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.isExternalApplication = function (item) {
@@ -12,36 +14,40 @@ angular.module('portainer.docker').controller('KubernetesResourcePoolApplication
};
this.$onInit = function () {
this.setDefaults();
this.prepareTableFromDataset();
return $async(async () => {
this.setDefaults();
this.prepareTableFromDataset();
this.state.orderBy = this.orderBy;
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
this.deploymentOptions = await getDeploymentOptions();
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
this.state.orderBy = this.orderBy;
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
});
};
},
]);

View File

@@ -142,7 +142,14 @@ export const ngModule = angular
),
{ stackName: 'setStackName' }
),
['setStackName', 'stackName', 'stacks', 'inputClassName', 'textTip']
[
'setStackName',
'stackName',
'stacks',
'inputClassName',
'textTip',
'error',
]
)
)
.component(

View File

@@ -172,6 +172,7 @@
text-tip="'Enter or select a \'stack\' name to group multiple deployments together, or else leave empty to ignore.'"
stacks="ctrl.stacks"
input-class-name="'col-lg-10 col-sm-9'"
error="ctrl.state.stackNameError"
></kube-stack-name>
<!-- #endregion -->
@@ -234,6 +235,7 @@
text-tip="'Enter or select a \'stack\' name to group multiple deployments together, or else leave empty to ignore.'"
stacks="ctrl.stacks"
input-class-name="'col-lg-10 col-sm-9'"
error="ctrl.state.stackNameError"
></kube-stack-name>
<!-- #endregion -->

View File

@@ -28,6 +28,7 @@ import { confirmUpdateAppIngress } from '@/react/kubernetes/applications/CreateV
import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
class KubernetesCreateApplicationController {
/* #region CONSTRUCTOR */
@@ -127,6 +128,7 @@ class KubernetesCreateApplicationController {
// a validation message will be shown. isExistingCPUReservationUnchanged and isExistingMemoryReservationUnchanged (with available resources being exceeded) is used to decide whether to show the message or not.
isExistingCPUReservationUnchanged: false,
isExistingMemoryReservationUnchanged: false,
stackNameError: '',
};
this.isAdmin = this.Authentication.isAdmin();
@@ -186,9 +188,16 @@ class KubernetesCreateApplicationController {
}
/* #endregion */
onChangeStackName(stackName) {
onChangeStackName(name) {
return this.$async(async () => {
this.formValues.StackName = stackName;
if (KUBE_STACK_NAME_VALIDATION_REGEX.test(name) || name === '') {
this.state.stackNameError = '';
} else {
this.state.stackNameError =
"Stack must consist of alphanumeric characters, '-', '_' or '.', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123').";
}
this.formValues.StackName = name;
});
}
@@ -644,7 +653,8 @@ class KubernetesCreateApplicationController {
const invalid = !this.isValid();
const hasNoChanges = this.isEditAndNoChangesMade();
const nonScalable = this.isNonScalable();
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable;
const stackNameInvalid = this.state.stackNameError !== '';
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || stackNameInvalid;
}
isUpdateApplicationViaWebEditorButtonDisabled() {
@@ -1128,6 +1138,9 @@ class KubernetesCreateApplicationController {
}
this.oldFormValues = angular.copy(this.formValues);
this.savedFormValues = angular.copy(this.formValues);
this.updateNamespaceLimits(this.namespaceWithQuota);
this.updateSliders(this.namespaceWithQuota);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
} finally {

View File

@@ -90,7 +90,12 @@
<div class="w-fit mb-4">
<stack-name-label-insight></stack-name-label-insight>
</div>
<kube-stack-name stack-name="ctrl.formValues.StackName" set-stack-name="(ctrl.setStackName)" stacks="ctrl.stacks"></kube-stack-name>
<kube-stack-name
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.setStackName)"
stacks="ctrl.stacks"
error="ctrl.state.stackNameError"
></kube-stack-name>
</div>
<!-- !namespace -->

View File

@@ -12,6 +12,7 @@ import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/p
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
class KubernetesDeployController {
/* @ngInject */
@@ -57,6 +58,7 @@ class KubernetesDeployController {
templateLoadFailed: false,
isEditorReadOnly: false,
selectedHelmChart: '',
stackNameError: '',
};
this.currentUser = {
@@ -117,7 +119,16 @@ class KubernetesDeployController {
}
setStackName(name) {
this.formValues.StackName = name;
return this.$async(async () => {
if (KUBE_STACK_NAME_VALIDATION_REGEX.test(name) || name === '') {
this.state.stackNameError = '';
} else {
this.state.stackNameError =
"Stack must consist of alphanumeric characters, '-', '_' or '.', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123').";
}
this.formValues.StackName = name;
});
}
renderTemplate() {
@@ -197,9 +208,9 @@ class KubernetesDeployController {
const isWebEditorInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent);
const isURLFormInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.URL && _.isEmpty(this.formValues.ManifestURL);
const isCustomTemplateInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.CUSTOM_TEMPLATE && _.isEmpty(this.formValues.EditorContent);
const isStackNameInvalid = this.state.stackNameError !== '';
const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace);
return isWebEditorInvalid || isURLFormInvalid || isCustomTemplateInvalid || this.state.actionInProgress || isNamespaceInvalid;
return isWebEditorInvalid || isURLFormInvalid || isCustomTemplateInvalid || this.state.actionInProgress || isNamespaceInvalid || isStackNameInvalid;
}
onChangeFormValues(newValues) {

View File

@@ -20,6 +20,7 @@ angular
.module('portainer.app', [
'portainer.oauth',
'portainer.rbac',
'portainer.registrymanagement',
componentsModule,
settingsModule,
featureFlagModule,
@@ -352,47 +353,6 @@ angular
},
};
var registries = {
name: 'portainer.registries',
url: '/registries',
views: {
'content@': {
templateUrl: './views/registries/registries.html',
controller: 'RegistriesController',
},
},
data: {
docs: '/admin/registries',
access: AccessHeaders.Admin,
},
};
var registry = {
name: 'portainer.registries.registry',
url: '/:id',
views: {
'content@': {
component: 'editRegistry',
},
},
data: {
docs: '/admin/registries/edit',
},
};
const registryCreation = {
name: 'portainer.registries.new',
url: '/new',
views: {
'content@': {
component: 'createRegistry',
},
},
data: {
docs: '/admin/registries/add',
},
};
var settings = {
name: 'portainer.settings',
url: '/settings',
@@ -497,9 +457,6 @@ angular
$stateRegistryProvider.register(home);
$stateRegistryProvider.register(init);
$stateRegistryProvider.register(initAdmin);
$stateRegistryProvider.register(registries);
$stateRegistryProvider.register(registry);
$stateRegistryProvider.register(registryCreation);
$stateRegistryProvider.register(settings);
$stateRegistryProvider.register(settingsAuthentication);
$stateRegistryProvider.register(settingsEdgeCompute);

View File

@@ -1,86 +1,84 @@
<div class="col-sm-12">
<rd-widget>
<rd-widget-custom-header icon="$ctrl.template.Logo" title-text="$ctrl.template.Title"></rd-widget-custom-header>
<rd-widget-body classes="padding">
<form class="form-horizontal" name="stackTemplateForm">
<!-- description -->
<div ng-if="$ctrl.template.Note">
<div class="form-section-title"> Information </div>
<div class="col-sm-12 form-group">
<div class="template-note" ng-bind-html="$ctrl.template.Note"></div>
</div>
<rd-widget>
<rd-widget-custom-header icon="$ctrl.template.Logo" title-text="$ctrl.template.Title"></rd-widget-custom-header>
<rd-widget-body classes="padding">
<form class="form-horizontal" name="stackTemplateForm">
<!-- description -->
<div ng-if="$ctrl.template.Note">
<div class="form-section-title"> Information </div>
<div class="col-sm-12 form-group">
<div class="template-note" ng-bind-html="$ctrl.template.Note"></div>
</div>
<!-- !description -->
<div class="form-section-title"> Configuration </div>
<!-- name-input -->
<div class="form-group">
<label for="template_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-6">
<input type="text" name="template_name" class="form-control" ng-model="$ctrl.formValues.name" ng-pattern="$ctrl.nameRegex" placeholder="e.g. myStack" required />
<div class="form-group" ng-if="stackTemplateForm.template_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="stackTemplateForm.template_name.$error">
<p ng-message="pattern" class="vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
</p>
<p ng-message="required" class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required. </p>
</div>
</div>
<!-- !description -->
<div class="form-section-title"> Configuration </div>
<!-- name-input -->
<div class="form-group">
<label for="template_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-6">
<input type="text" name="template_name" class="form-control" ng-model="$ctrl.formValues.name" ng-pattern="$ctrl.nameRegex" placeholder="e.g. mystack" required />
<div class="form-group" ng-if="stackTemplateForm.template_name.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="stackTemplateForm.template_name.$error">
<p ng-message="pattern" class="vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
</p>
<p ng-message="required" class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required. </p>
</div>
</div>
</div>
</div>
</div>
<!-- !name-input -->
<!-- env -->
<div ng-repeat="var in $ctrl.template.Env" ng-if="!var.preset || var.select" class="form-group">
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
{{ var.label }}
<portainer-tooltip ng-if="var.description" message="var.description"></portainer-tooltip>
</label>
<div class="col-sm-6">
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" />
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
<option selected disabled hidden value="">Select value</option>
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
</select>
</div>
<!-- !name-input -->
<!-- env -->
<div ng-repeat="var in $ctrl.template.Env" ng-if="!var.preset || var.select" class="form-group">
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
{{ var.label }}
<portainer-tooltip ng-if="var.description" message="var.description"></portainer-tooltip>
</label>
<div class="col-sm-6">
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" />
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
<option selected disabled hidden value="">Select value</option>
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
</select>
</div>
<!-- !env -->
<ng-transclude ng-transclude-slot="advanced"></ng-transclude>
</div>
<!-- !env -->
<ng-transclude ng-transclude-slot="advanced"></ng-transclude>
<!-- access-control -->
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary"
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.name || !$ctrl.state.deployable || stackTemplateForm.$invalid"
ng-click="$ctrl.createTemplate()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
</button>
<button type="button" class="btn btn-default" ng-click="$ctrl.unselectTemplate($ctrl.template)">Hide</button>
<div class="form-group" ng-if="$ctrl.state.formValidationError">
<div class="col-sm-12 small text-danger" ng-if="$ctrl.state.formValidationError">
<p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>{{ $ctrl.state.formValidationError }} </p>
</div>
<!-- access-control -->
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary"
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.name || !$ctrl.state.deployable || stackTemplateForm.$invalid"
ng-click="$ctrl.createTemplate()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
</button>
<button type="button" class="btn btn-default" ng-click="$ctrl.unselectTemplate($ctrl.template)">Hide</button>
<div class="form-group" ng-if="$ctrl.state.formValidationError">
<div class="col-sm-12 small text-danger" ng-if="$ctrl.state.formValidationError">
<p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>{{ $ctrl.state.formValidationError }} </p>
</div>
<div class="form-group" ng-if="!$ctrl.state.deployable">
<div class="col-sm-12 small text-danger" ng-if="!$ctrl.state.deployable">
<p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This template type cannot be deployed on this environment. </p>
</div>
</div>
<div class="form-group" ng-if="!$ctrl.state.deployable">
<div class="col-sm-12 small text-danger" ng-if="!$ctrl.state.deployable">
<p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This template type cannot be deployed on this environment. </p>
</div>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>

View File

@@ -33,7 +33,7 @@ function validateYAML(yaml, containerNames, originalContainersNames = []) {
let yamlObject;
try {
yamlObject = YAML.parse(yaml, { mapAsMap: true });
yamlObject = YAML.parse(yaml, { mapAsMap: true, maxAliasCount: 10000 });
} catch (err) {
return 'There is an error in the yaml syntax: ' + err;
}
@@ -58,7 +58,7 @@ export function extractContainerNames(yaml = '') {
let yamlObject;
try {
yamlObject = YAML.parse(yaml);
yamlObject = YAML.parse(yaml, { maxAliasCount: 10000 });
} catch (err) {
return [];
}

View File

@@ -268,7 +268,11 @@ withFormValidation(
withFormValidation(
ngModule,
withUIRouter(
withControlledInput(StackEnvironmentVariablesPanel, { values: 'onChange' })
withReactQuery(
withControlledInput(StackEnvironmentVariablesPanel, {
values: 'onChange',
})
)
),
'stackEnvironmentVariablesPanel',
['showHelpMessage', 'isFoldable'],

View File

@@ -0,0 +1,51 @@
import { AccessHeaders } from '../authorization-guard';
angular.module('portainer.registrymanagement', []).config(config);
/* @ngInject */
function config($stateRegistryProvider) {
const registries = {
name: 'portainer.registries',
url: '/registries',
views: {
'content@': {
templateUrl: './views/list/registries.html',
controller: 'RegistriesController',
},
},
data: {
docs: '/admin/registries',
access: AccessHeaders.Admin,
},
};
const registryCreation = {
name: 'portainer.registries.new',
url: '/new',
views: {
'content@': {
component: 'createRegistry',
},
},
data: {
docs: '/admin/registries/add',
},
};
const registry = {
name: 'portainer.registries.registry',
url: '/:id',
views: {
'content@': {
component: 'editRegistry',
},
},
data: {
docs: '/admin/registries/edit',
},
};
$stateRegistryProvider.register(registries);
$stateRegistryProvider.register(registry);
$stateRegistryProvider.register(registryCreation);
}

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