Compare commits
107 Commits
fix/EE-715
...
2.21.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3a74c4d72 | ||
|
|
9fd5669a23 | ||
|
|
efc2bf9292 | ||
|
|
4d74a00492 | ||
|
|
68011fb293 | ||
|
|
25f84c0b3e | ||
|
|
13766cc465 | ||
|
|
5e6e3048d5 | ||
|
|
18e755e30e | ||
|
|
a63bd2cea4 | ||
|
|
ef8e611e0a | ||
|
|
cc60836bb8 | ||
|
|
e2830019d7 | ||
|
|
20de243299 | ||
|
|
e8af981746 | ||
|
|
fa4711946d | ||
|
|
566e37535f | ||
|
|
3529a36f92 | ||
|
|
9c775396fd | ||
|
|
65871207f0 | ||
|
|
dc3e20acac | ||
|
|
91a477d9fb | ||
|
|
9339d10233 | ||
|
|
e7af3296fc | ||
|
|
5182220d0a | ||
|
|
9d7173fb5f | ||
|
|
69f9a509c8 | ||
|
|
93ce33fac8 | ||
|
|
55706cbf35 | ||
|
|
e0934bb7fa | ||
|
|
c4235c84a7 | ||
|
|
4fd4aa8138 | ||
|
|
8aec5adb66 | ||
|
|
d490061c1f | ||
|
|
b23b0f7c8d | ||
|
|
c94cfb1308 | ||
|
|
4be2c061f5 | ||
|
|
0356288fd9 | ||
|
|
3b95c333fc | ||
|
|
11404aaecb | ||
|
|
ccb6dd7f1a | ||
|
|
6e0dd34cc8 | ||
|
|
61ef133bb8 | ||
|
|
f5d896bce1 | ||
|
|
6c98271e43 | ||
|
|
4700e38e5d | ||
|
|
2f1b5ec979 | ||
|
|
39088b16b3 | ||
|
|
680cb3b36a | ||
|
|
1fce2b83d7 | ||
|
|
92c8692bbe | ||
|
|
65060725df | ||
|
|
b920c542dd | ||
|
|
be99f646f8 | ||
|
|
6d2775a42e | ||
|
|
4bcefb4866 | ||
|
|
f16d2e5d28 | ||
|
|
9960137a7c | ||
|
|
62c625c446 | ||
|
|
acaa564557 | ||
|
|
d727cbc373 | ||
|
|
31403bfc7e | ||
|
|
cf91a8352d | ||
|
|
549579d935 | ||
|
|
0be1888f11 | ||
|
|
2856d0ed64 | ||
|
|
d70812be0d | ||
|
|
efc88c0073 | ||
|
|
00d8391a02 | ||
|
|
4403503d82 | ||
|
|
8ae64523b3 | ||
|
|
ccfd5e4500 | ||
|
|
a755e6be15 | ||
|
|
17561c1c0c | ||
|
|
d73b162d16 | ||
|
|
d5c4671320 | ||
|
|
f9065367b9 | ||
|
|
218b8bf300 | ||
|
|
aa9e73002f | ||
|
|
b86b721b0f | ||
|
|
95292d20f4 | ||
|
|
fb7c23a241 | ||
|
|
a3146fff36 | ||
|
|
483aa80e40 | ||
|
|
fb4ffaec35 | ||
|
|
1a801d86f0 | ||
|
|
fe41d23244 | ||
|
|
0e163adf8d | ||
|
|
eb4a06d422 | ||
|
|
1fee55ddd2 | ||
|
|
2cbc5027aa | ||
|
|
305a7a354a | ||
|
|
948e2bb715 | ||
|
|
02b6829819 | ||
|
|
3d3d65d44e | ||
|
|
6714cba2f8 | ||
|
|
582b1a16fc | ||
|
|
2be897c4a1 | ||
|
|
8616f9b742 | ||
|
|
5d7d68bc44 | ||
|
|
cea9969463 | ||
|
|
5fb5ea7ae0 | ||
|
|
5f89255d9c | ||
|
|
e5f6363244 | ||
|
|
5d9423b93c | ||
|
|
2443a0f568 | ||
|
|
08643ed872 |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -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:
|
||||
|
||||
30
.github/workflows/test.yaml
vendored
30
.github/workflows/test.yaml
vendored
@@ -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
|
||||
|
||||
3
Makefile
3
Makefile
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ type (
|
||||
}
|
||||
|
||||
DataStore interface {
|
||||
Connection() portainer.Connection
|
||||
Open() (newStore bool, err error)
|
||||
Init() error
|
||||
Close() error
|
||||
|
||||
@@ -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\"}"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := ®istries[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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("", "")
|
||||
}
|
||||
|
||||
@@ -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, ®istry); 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, ®istry); 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
7
api/kubernetes/cli/server_version.go
Normal file
7
api/kubernetes/cli/server_version.go
Normal 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()
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -142,7 +142,14 @@ export const ngModule = angular
|
||||
),
|
||||
{ stackName: 'setStackName' }
|
||||
),
|
||||
['setStackName', 'stackName', 'stacks', 'inputClassName', 'textTip']
|
||||
[
|
||||
'setStackName',
|
||||
'stackName',
|
||||
'stacks',
|
||||
'inputClassName',
|
||||
'textTip',
|
||||
'error',
|
||||
]
|
||||
)
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -268,7 +268,11 @@ withFormValidation(
|
||||
withFormValidation(
|
||||
ngModule,
|
||||
withUIRouter(
|
||||
withControlledInput(StackEnvironmentVariablesPanel, { values: 'onChange' })
|
||||
withReactQuery(
|
||||
withControlledInput(StackEnvironmentVariablesPanel, {
|
||||
values: 'onChange',
|
||||
})
|
||||
)
|
||||
),
|
||||
'stackEnvironmentVariablesPanel',
|
||||
['showHelpMessage', 'isFoldable'],
|
||||
|
||||
51
app/portainer/registry-management/index.js
Normal file
51
app/portainer/registry-management/index.js
Normal 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
Reference in New Issue
Block a user