Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 439714f93d | |||
| 2745e63527 | |||
| 24e0318280 | |||
| 9a079a83fa | |||
| 1df6087c8e | |||
| ae705bc245 | |||
| d725b5e3b6 | |||
| 1b33b1f5dd | |||
| b70f0fe3d2 | |||
| 55ef46edb6 | |||
| c2654d55b3 | |||
| 7fab352dbf | |||
| 0dcb5113f7 | |||
| a1b0634d86 | |||
| da134c3e3f | |||
| 5191fc9220 | |||
| af4e362c5c | |||
| eb5b9ef069 | |||
| a74c6dbd24 | |||
| 6451ccce94 | |||
| 6dd5150e23 | |||
| 441db15cfd | |||
| b44fabaefe | |||
| ddeddc723e | |||
| e980ce3d6a | |||
| 123a138278 | |||
| cc3ec3cebd | |||
| 5dab7a1df4 | |||
| ed0cf4d79c | |||
| aa4b8ad5e3 | |||
| 81811f669d | |||
| 3ae55d8c3e | |||
| 933c2a7002 | |||
| 1641642695 | |||
| f80b1ed53a | |||
| d04da7898d | |||
| ec83d02afa | |||
| 05265dda47 | |||
| 74e1ff5e2d | |||
| 795d812652 | |||
| 46b1d5b528 | |||
| cf7672d59e | |||
| 9c8a30693a | |||
| 023945cbd2 | |||
| 498ba46863 | |||
| 399ddaea3b | |||
| 13cee9975c | |||
| f8927851e4 |
@@ -22,7 +22,7 @@ on:
|
||||
env:
|
||||
DOCKER_HUB_REPO: portainerci/portainer-ce
|
||||
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -18,7 +18,7 @@ on:
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.9
|
||||
|
||||
jobs:
|
||||
client-dependencies:
|
||||
|
||||
@@ -14,7 +14,7 @@ on:
|
||||
- '.github/workflows/pr-security.yml'
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Test
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
on:
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
GO_VERSION: 1.21.6
|
||||
GO_VERSION: 1.21.9
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
initMSW(
|
||||
{
|
||||
onUnhandledRequest: ({ method, url }) => {
|
||||
console.log(method, url);
|
||||
if (url.startsWith('/api')) {
|
||||
console.error(`Unhandled ${method} request to ${url}.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
|
||||
if password != "" {
|
||||
archive, err = decrypt(archive, password)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to decrypt the archive")
|
||||
return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-10
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/datastore/migrator"
|
||||
"github.com/portainer/portainer/api/datastore/postinit"
|
||||
"github.com/portainer/portainer/api/demo"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
@@ -457,19 +458,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
authorizationService := authorization.NewService(dataStore)
|
||||
authorizationService.K8sClientFactory = kubernetesClientFactory
|
||||
|
||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, authorizationService, shutdownCtx)
|
||||
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing snapshot service")
|
||||
}
|
||||
snapshotService.Start()
|
||||
|
||||
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
|
||||
|
||||
kubeClusterAccessService := kubernetes.NewKubeClusterAccessService(*flags.BaseURL, *flags.AddrHTTPS, sslSettings.CertPath)
|
||||
|
||||
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService)
|
||||
proxyManager := proxy.NewManager(kubernetesClientFactory)
|
||||
|
||||
reverseTunnelService.ProxyManager = proxyManager
|
||||
|
||||
@@ -489,6 +482,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, proxyManager, *flags.Assets)
|
||||
|
||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory, dockerClientFactory, authorizationService, shutdownCtx, *flags.Assets, kubernetesDeployer)
|
||||
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx, pendingActionsService)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing snapshot service")
|
||||
}
|
||||
snapshotService.Start()
|
||||
|
||||
proxyManager.NewProxyFactory(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||
|
||||
helmPackageManager, err := initHelmPackageManager(*flags.Assets)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed initializing helm package manager")
|
||||
@@ -578,10 +581,12 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
// but some more complex migrations require access to a kubernetes or docker
|
||||
// client. Therefore we run a separate migration process just before
|
||||
// starting the server.
|
||||
postInitMigrator := datastore.NewPostInitMigrator(
|
||||
postInitMigrator := postinit.NewPostInitMigrator(
|
||||
kubernetesClientFactory,
|
||||
dockerClientFactory,
|
||||
dataStore,
|
||||
*flags.Assets,
|
||||
kubernetesDeployer,
|
||||
)
|
||||
if err := postInitMigrator.PostInitMigrate(); err != nil {
|
||||
log.Fatal().Err(err).Msg("failure during post init migrations")
|
||||
@@ -650,6 +655,7 @@ func main() {
|
||||
Msg("starting Portainer")
|
||||
|
||||
err := server.Start()
|
||||
|
||||
log.Info().Err(err).Msg("HTTP server exited")
|
||||
}
|
||||
}
|
||||
|
||||
+194
-32
@@ -1,52 +1,216 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
// NOTE: has to go with what is considered to be a simplistic in that it omits any
|
||||
// authentication of the encrypted data.
|
||||
// Person with better knowledge is welcomed to improve it.
|
||||
// sourced from https://golang.org/src/crypto/cipher/example_test.go
|
||||
const (
|
||||
// AES GCM settings
|
||||
aesGcmHeader = "AES256-GCM" // The encrypted file header
|
||||
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
|
||||
|
||||
var emptySalt []byte = make([]byte, 0)
|
||||
// Argon2 settings
|
||||
// Recommded settings lower memory hardware according to current OWASP recommendations
|
||||
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
|
||||
argon2MemoryCost = 12 * 1024
|
||||
argon2TimeCost = 3
|
||||
argon2Threads = 1
|
||||
argon2KeyLength = 32
|
||||
)
|
||||
|
||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
|
||||
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
// making a 32 bytes key that would correspond to AES-256
|
||||
// don't necessarily need a salt, so just kept in empty
|
||||
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
||||
err := aesEncryptGCM(input, output, passphrase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero
|
||||
// IV.
|
||||
var iv [aes.BlockSize]byte
|
||||
stream := cipher.NewOFB(block, iv[:])
|
||||
|
||||
writer := &cipher.StreamWriter{S: stream, W: output}
|
||||
// Copy the input to the output, encrypting as we go.
|
||||
if _, err := io.Copy(writer, input); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error encrypting file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
|
||||
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
// Read file header to determine how it was encrypted
|
||||
inputReader := bufio.NewReader(input)
|
||||
header, err := inputReader.Peek(len(aesGcmHeader))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
|
||||
}
|
||||
|
||||
if string(header) == aesGcmHeader {
|
||||
reader, err := aesDecryptGCM(inputReader, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decrypting file: %w", err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// Use the previous decryption routine which has no header (to support older archives)
|
||||
reader, err := aesDecryptOFB(inputReader, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decrypting legacy file backup: %w", err)
|
||||
}
|
||||
|
||||
return reader, nil
|
||||
}
|
||||
|
||||
// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key.
|
||||
func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
|
||||
// Derive key using argon2 with a random salt
|
||||
salt := make([]byte, 16) // 16 bytes salt
|
||||
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
nonce, err := NewRandomNonce(aesgcm.NonceSize())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// write the header
|
||||
if _, err := output.Write([]byte(aesGcmHeader)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write nonce and salt to the output file
|
||||
if _, err := output.Write(salt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(nonce.Value()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Buffer for reading plaintext blocks
|
||||
buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed
|
||||
ciphertext := make([]byte, len(buf)+aesgcm.Overhead())
|
||||
|
||||
// Encrypt plaintext in blocks
|
||||
for {
|
||||
n, err := io.ReadFull(input, buf)
|
||||
if n == 0 {
|
||||
break // end of plaintext input
|
||||
}
|
||||
|
||||
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Seal encrypts the plaintext using the nonce returning the updated slice.
|
||||
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
|
||||
|
||||
_, err = output.Write(ciphertext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nonce.Increment()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from.
|
||||
func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
// Reader & verify header
|
||||
header := make([]byte, len(aesGcmHeader))
|
||||
if _, err := io.ReadFull(input, header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if string(header) != aesGcmHeader {
|
||||
return nil, fmt.Errorf("invalid header")
|
||||
}
|
||||
|
||||
// Read salt
|
||||
salt := make([]byte, 16) // Salt size
|
||||
if _, err := io.ReadFull(input, salt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
|
||||
|
||||
// Initialize AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create GCM mode with the cipher block
|
||||
aesgcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read nonce from the input reader
|
||||
nonce := NewNonce(aesgcm.NonceSize())
|
||||
if err := nonce.Read(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize a buffer to store decrypted data
|
||||
buf := bytes.Buffer{}
|
||||
plaintext := make([]byte, aesGcmBlockSize)
|
||||
|
||||
// Decrypt the ciphertext in blocks
|
||||
for {
|
||||
// Read a block of ciphertext from the input reader
|
||||
ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed
|
||||
n, err := io.ReadFull(input, ciphertextBlock)
|
||||
if n == 0 {
|
||||
break // end of ciphertext
|
||||
}
|
||||
|
||||
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Decrypt the block of ciphertext
|
||||
plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = buf.Write(plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nonce.Increment()
|
||||
}
|
||||
|
||||
return &buf, nil
|
||||
}
|
||||
|
||||
// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
|
||||
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
var emptySalt []byte = make([]byte, 0)
|
||||
|
||||
// making a 32 bytes key that would correspond to AES-256
|
||||
// don't necessarily need a salt, so just kept in empty
|
||||
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
|
||||
@@ -59,11 +223,9 @@ func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero
|
||||
// IV.
|
||||
// If the key is unique for each ciphertext, then it's ok to use a zero IV.
|
||||
var iv [aes.BlockSize]byte
|
||||
stream := cipher.NewOFB(block, iv[:])
|
||||
|
||||
reader := &cipher.StreamReader{S: stream, R: input}
|
||||
|
||||
return reader, nil
|
||||
|
||||
+101
-11
@@ -2,6 +2,7 @@ package crypto
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -9,7 +10,19 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
func randBytes(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = letterBytes[rand.Intn(len(letterBytes))]
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
const passphrase = "passphrase"
|
||||
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
@@ -18,17 +31,99 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
content := randBytes(1024*1024*100 + 523)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
|
||||
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
|
||||
encryptedFileReader, _ := os.Open(encryptedFilePath)
|
||||
defer encryptedFileReader.Close()
|
||||
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
|
||||
assert.Nil(t, err, "Failed to decrypt file")
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
|
||||
}
|
||||
|
||||
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
|
||||
tmpdir := t.TempDir()
|
||||
|
||||
var (
|
||||
originFilePath = filepath.Join(tmpdir, "origin2")
|
||||
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
|
||||
)
|
||||
|
||||
content := randBytes(500)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
defer originFile.Close()
|
||||
|
||||
encryptedFileWriter, _ := os.Create(encryptedFilePath)
|
||||
defer encryptedFileWriter.Close()
|
||||
|
||||
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
|
||||
assert.Nil(t, err, "Failed to encrypt a file")
|
||||
encryptedFileWriter.Close()
|
||||
|
||||
encryptedContent, err := os.ReadFile(encryptedFilePath)
|
||||
assert.Nil(t, err, "Couldn't read encrypted file")
|
||||
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
|
||||
@@ -57,7 +152,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
content := randBytes(1024 * 50)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
@@ -96,7 +191,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
content := randBytes(1034)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
@@ -117,11 +212,6 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage"))
|
||||
assert.Nil(t, err, "Should allow to decrypt with wrong passphrase")
|
||||
|
||||
io.Copy(decryptedFileWriter, decryptedReader)
|
||||
|
||||
decryptedContent, _ := os.ReadFile(decryptedFilePath)
|
||||
assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match")
|
||||
_, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
|
||||
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Nonce struct {
|
||||
val []byte
|
||||
}
|
||||
|
||||
func NewNonce(size int) *Nonce {
|
||||
return &Nonce{val: make([]byte, size)}
|
||||
}
|
||||
|
||||
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
|
||||
// This ensures there are plenty of nonce values availble before rolling over
|
||||
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
|
||||
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
|
||||
func NewRandomNonce(size int) (*Nonce, error) {
|
||||
randomBytes := 1
|
||||
if size <= randomBytes {
|
||||
return nil, errors.New("nonce size must be greater than the number of random bytes")
|
||||
}
|
||||
|
||||
randomPart := make([]byte, randomBytes)
|
||||
if _, err := rand.Read(randomPart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zeroPart := make([]byte, size-randomBytes)
|
||||
nonceVal := append(randomPart, zeroPart...)
|
||||
return &Nonce{val: nonceVal}, nil
|
||||
}
|
||||
|
||||
func (n *Nonce) Read(stream io.Reader) error {
|
||||
_, err := io.ReadFull(stream, n.val)
|
||||
return err
|
||||
}
|
||||
|
||||
func (n *Nonce) Value() []byte {
|
||||
return n.val
|
||||
}
|
||||
|
||||
func (n *Nonce) Increment() error {
|
||||
// Start incrementing from the least significant byte
|
||||
for i := len(n.val) - 1; i >= 0; i-- {
|
||||
// Increment the current byte
|
||||
n.val[i]++
|
||||
|
||||
// Check for overflow
|
||||
if n.val[i] != 0 {
|
||||
// No overflow, nonce is successfully incremented
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, it means the nonce has overflowed
|
||||
return errors.New("nonce overflow")
|
||||
}
|
||||
@@ -73,6 +73,7 @@ type (
|
||||
PendingActionsService interface {
|
||||
BaseCRUD[portainer.PendingActions, portainer.PendingActionsID]
|
||||
GetNextIdentifier() int
|
||||
DeleteByEndpointID(ID portainer.EndpointID) error
|
||||
}
|
||||
|
||||
// EdgeStackService represents a service to manage Edge stacks
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package pendingactions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -45,6 +47,12 @@ func (s Service) Update(ID portainer.PendingActionsID, config *portainer.Pending
|
||||
})
|
||||
}
|
||||
|
||||
func (s Service) DeleteByEndpointID(ID portainer.EndpointID) error {
|
||||
return s.Connection.UpdateTx(func(tx portainer.Transaction) error {
|
||||
return s.Tx(tx).DeleteByEndpointID(ID)
|
||||
})
|
||||
}
|
||||
|
||||
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
|
||||
return ServiceTx{
|
||||
BaseDataServiceTx: dataservices.BaseDataServiceTx[portainer.PendingActions, portainer.PendingActionsID]{
|
||||
@@ -68,6 +76,29 @@ func (s ServiceTx) Update(ID portainer.PendingActionsID, config *portainer.Pendi
|
||||
return s.BaseDataServiceTx.Update(ID, config)
|
||||
}
|
||||
|
||||
func (s ServiceTx) DeleteByEndpointID(ID portainer.EndpointID) error {
|
||||
log.Debug().Int("endpointId", int(ID)).Msg("deleting pending actions for endpoint")
|
||||
pendingActions, err := s.BaseDataServiceTx.ReadAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve pending-actions for endpoint (%d): %w", ID, err)
|
||||
}
|
||||
|
||||
for _, pendingAction := range pendingActions {
|
||||
if pendingAction.EndpointID == ID {
|
||||
err := s.BaseDataServiceTx.Delete(pendingAction.ID)
|
||||
if err != nil {
|
||||
log.Debug().Int("endpointId", int(ID)).Msgf("failed to delete pending action: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNextIdentifier returns the next identifier for a custom template.
|
||||
func (service ServiceTx) GetNextIdentifier() int {
|
||||
return service.Tx.GetNextIdentifier(BucketName)
|
||||
}
|
||||
|
||||
// GetNextIdentifier returns the next identifier for a custom template.
|
||||
func (service *Service) GetNextIdentifier() int {
|
||||
return service.Connection.GetNextIdentifier(BucketName)
|
||||
|
||||
@@ -86,6 +86,7 @@ func (store *Store) newMigratorParameters(version *models.Version) *migrator.Mig
|
||||
EdgeStackService: store.EdgeStackService,
|
||||
EdgeJobService: store.EdgeJobService,
|
||||
TunnelServerService: store.TunnelServerService,
|
||||
PendingActionsService: store.PendingActionsService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type PostInitMigrator struct {
|
||||
kubeFactory *cli.ClientFactory
|
||||
dockerFactory *dockerclient.ClientFactory
|
||||
dataStore dataservices.DataStore
|
||||
}
|
||||
|
||||
func NewPostInitMigrator(kubeFactory *cli.ClientFactory, dockerFactory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *PostInitMigrator {
|
||||
return &PostInitMigrator{
|
||||
kubeFactory: kubeFactory,
|
||||
dockerFactory: dockerFactory,
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (migrator *PostInitMigrator) PostInitMigrate() error {
|
||||
if err := migrator.PostInitMigrateIngresses(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrator.PostInitMigrateGPUs()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migrator *PostInitMigrator) PostInitMigrateIngresses() error {
|
||||
endpoints, err := migrator.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
// Early exit if we do not need to migrate!
|
||||
if !endpoints[i].PostInitMigrations.MigrateIngresses {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := migrator.kubeFactory.MigrateEndpointIngresses(&endpoints[i])
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PostInitMigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
|
||||
// If there's an error getting the containers, we'll log it and move on
|
||||
func (migrator *PostInitMigrator) PostInitMigrateGPUs() {
|
||||
environments, err := migrator.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failure getting endpoints")
|
||||
return
|
||||
}
|
||||
|
||||
for i := range environments {
|
||||
if environments[i].Type == portainer.DockerEnvironment {
|
||||
// // Early exit if we do not need to migrate!
|
||||
if !environments[i].PostInitMigrations.MigrateGPUs {
|
||||
return
|
||||
}
|
||||
|
||||
// set the MigrateGPUs flag to false so we don't run this again
|
||||
environments[i].PostInitMigrations.MigrateGPUs = false
|
||||
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
|
||||
|
||||
// create a docker client
|
||||
dockerClient, err := migrator.dockerFactory.CreateClient(&environments[i], "", nil)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failure creating docker client for environment: " + environments[i].Name)
|
||||
return
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
|
||||
// get all containers
|
||||
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to list containers")
|
||||
return
|
||||
}
|
||||
|
||||
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole endpoint
|
||||
containersLoop:
|
||||
for _, container := range containers {
|
||||
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
|
||||
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to inspect container")
|
||||
return
|
||||
}
|
||||
|
||||
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
|
||||
for _, deviceRequest := range deviceRequests {
|
||||
if deviceRequest.Driver == "nvidia" {
|
||||
environments[i].EnableGPUManagement = true
|
||||
migrator.dataStore.Endpoint().UpdateEndpoint(environments[i].ID, &environments[i])
|
||||
|
||||
break containersLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (migrator *Migrator) cleanPendingActionsForDeletedEndpointsForDB111() error {
|
||||
log.Info().Msg("cleaning up pending actions for deleted endpoints")
|
||||
|
||||
pendingActions, err := migrator.pendingActionsService.ReadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoints := make(map[portainer.EndpointID]struct{})
|
||||
for _, action := range pendingActions {
|
||||
endpoints[action.EndpointID] = struct{}{}
|
||||
}
|
||||
|
||||
for endpointId := range endpoints {
|
||||
_, err := migrator.endpointService.Endpoint(endpointId)
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
err := migrator.pendingActionsService.DeleteByEndpointID(endpointId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices/endpointrelation"
|
||||
"github.com/portainer/portainer/api/dataservices/extension"
|
||||
"github.com/portainer/portainer/api/dataservices/fdoprofile"
|
||||
"github.com/portainer/portainer/api/dataservices/pendingactions"
|
||||
"github.com/portainer/portainer/api/dataservices/registry"
|
||||
"github.com/portainer/portainer/api/dataservices/resourcecontrol"
|
||||
"github.com/portainer/portainer/api/dataservices/role"
|
||||
@@ -58,6 +59,7 @@ type (
|
||||
edgeStackService *edgestack.Service
|
||||
edgeJobService *edgejob.Service
|
||||
TunnelServerService *tunnelserver.Service
|
||||
pendingActionsService *pendingactions.Service
|
||||
}
|
||||
|
||||
// MigratorParameters represents the required parameters to create a new Migrator instance.
|
||||
@@ -85,6 +87,7 @@ type (
|
||||
EdgeStackService *edgestack.Service
|
||||
EdgeJobService *edgejob.Service
|
||||
TunnelServerService *tunnelserver.Service
|
||||
PendingActionsService *pendingactions.Service
|
||||
}
|
||||
)
|
||||
|
||||
@@ -114,6 +117,7 @@ func NewMigrator(parameters *MigratorParameters) *Migrator {
|
||||
edgeStackService: parameters.EdgeStackService,
|
||||
edgeJobService: parameters.EdgeJobService,
|
||||
TunnelServerService: parameters.TunnelServerService,
|
||||
pendingActionsService: parameters.PendingActionsService,
|
||||
}
|
||||
|
||||
migrator.initMigrations()
|
||||
@@ -232,6 +236,9 @@ func (m *Migrator) initMigrations() {
|
||||
m.updateAppTemplatesVersionForDB110,
|
||||
m.updateResourceOverCommitToDB110,
|
||||
)
|
||||
m.addMigrations("2.20.2",
|
||||
m.cleanPendingActionsForDeletedEndpointsForDB111,
|
||||
)
|
||||
|
||||
// Add new migrations below...
|
||||
// One function per migration, each versions migration funcs in the same file.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
)
|
||||
|
||||
func Test_ConvertCleanNAPWithOverridePoliciesPayload(t *testing.T) {
|
||||
t.Run("test ConvertCleanNAPWithOverridePoliciesPayload", func(t *testing.T) {
|
||||
|
||||
_, store := MustNewTestStore(t, true, false)
|
||||
defer store.Close()
|
||||
|
||||
testData := []struct {
|
||||
Name string
|
||||
PendingAction portainer.PendingActions
|
||||
Expected *actions.CleanNAPWithOverridePoliciesPayload
|
||||
Err bool
|
||||
}{
|
||||
{
|
||||
Name: "test actiondata with EndpointGroupID 1",
|
||||
PendingAction: portainer.PendingActions{
|
||||
EndpointID: 1,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: &actions.CleanNAPWithOverridePoliciesPayload{
|
||||
EndpointGroupID: 1,
|
||||
},
|
||||
},
|
||||
Expected: &actions.CleanNAPWithOverridePoliciesPayload{
|
||||
EndpointGroupID: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "test actionData nil",
|
||||
PendingAction: portainer.PendingActions{
|
||||
EndpointID: 2,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: nil,
|
||||
},
|
||||
Expected: nil,
|
||||
},
|
||||
{
|
||||
Name: "test actionData empty and expected error",
|
||||
PendingAction: portainer.PendingActions{
|
||||
EndpointID: 2,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: "",
|
||||
},
|
||||
Expected: nil,
|
||||
Err: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, d := range testData {
|
||||
err := store.PendingActions().Create(&d.PendingAction)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
pendingActions, err := store.PendingActions().ReadAll()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, endpointPendingAction := range pendingActions {
|
||||
t.Run(d.Name, func(t *testing.T) {
|
||||
if endpointPendingAction.Action == "CleanNAPWithOverridePolicies" {
|
||||
actionData, err := actions.ConvertCleanNAPWithOverridePoliciesPayload(endpointPendingAction.ActionData)
|
||||
if d.Err && err == nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if d.Expected == nil && actionData != nil {
|
||||
t.Errorf("expected nil , got %d", actionData)
|
||||
}
|
||||
|
||||
if d.Expected != nil && actionData == nil {
|
||||
t.Errorf("expected not nil , got %d", actionData)
|
||||
}
|
||||
|
||||
if d.Expected != nil && actionData.EndpointGroupID != d.Expected.EndpointGroupID {
|
||||
t.Errorf("expected EndpointGroupID %d , got %d", d.Expected.EndpointGroupID, actionData.EndpointGroupID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
store.PendingActions().Delete(d.PendingAction.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package postinit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/client"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerClient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type PostInitMigrator struct {
|
||||
kubeFactory *cli.ClientFactory
|
||||
dockerFactory *dockerClient.ClientFactory
|
||||
dataStore dataservices.DataStore
|
||||
assetsPath string
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
}
|
||||
|
||||
func NewPostInitMigrator(
|
||||
kubeFactory *cli.ClientFactory,
|
||||
dockerFactory *dockerClient.ClientFactory,
|
||||
dataStore dataservices.DataStore,
|
||||
assetsPath string,
|
||||
kubernetesDeployer portainer.KubernetesDeployer,
|
||||
) *PostInitMigrator {
|
||||
return &PostInitMigrator{
|
||||
kubeFactory: kubeFactory,
|
||||
dockerFactory: dockerFactory,
|
||||
dataStore: dataStore,
|
||||
assetsPath: assetsPath,
|
||||
kubernetesDeployer: kubernetesDeployer,
|
||||
}
|
||||
}
|
||||
|
||||
// PostInitMigrate will run all post-init migrations, which require docker/kube clients for all edge or non-edge environments
|
||||
func (postInitMigrator *PostInitMigrator) PostInitMigrate() error {
|
||||
environments, err := postInitMigrator.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Error getting environments")
|
||||
return err
|
||||
}
|
||||
|
||||
for _, environment := range environments {
|
||||
// edge environments will run after the server starts, in pending actions
|
||||
if endpointutils.IsEdgeEndpoint(&environment) {
|
||||
log.Info().Msgf("Adding pending action 'PostInitMigrateEnvironment' for environment %d", environment.ID)
|
||||
err = postInitMigrator.createPostInitMigrationPendingAction(environment.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environment.ID)
|
||||
}
|
||||
} else {
|
||||
// non-edge environments will run before the server starts.
|
||||
err = postInitMigrator.MigrateEnvironment(&environment)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error running post-init migrations for non-edge environment %d", environment.ID)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// try to create a post init migration pending action. If it already exists, do nothing
|
||||
// this function exists for readability, not reusability
|
||||
// TODO: This should be moved into pending actions as part of the pending action migration
|
||||
func (postInitMigrator *PostInitMigrator) createPostInitMigrationPendingAction(environmentID portainer.EndpointID) error {
|
||||
migrateEnvPendingAction := portainer.PendingActions{
|
||||
EndpointID: environmentID,
|
||||
Action: actions.PostInitMigrateEnvironment,
|
||||
}
|
||||
|
||||
// Get all pending actions and filter them by endpoint, action and action args that are equal to the migrateEnvPendingAction
|
||||
pendingActions, err := postInitMigrator.dataStore.PendingActions().ReadAll()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error retrieving pending actions")
|
||||
return fmt.Errorf("failed to retrieve pending actions for environment %d: %w", environmentID, err)
|
||||
}
|
||||
for _, pendingAction := range pendingActions {
|
||||
if pendingAction.EndpointID == environmentID &&
|
||||
pendingAction.Action == migrateEnvPendingAction.Action &&
|
||||
reflect.DeepEqual(pendingAction.ActionData, migrateEnvPendingAction.ActionData) {
|
||||
log.Debug().Msgf("Migration pending action for environment %d already exists, skipping creating another", environmentID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no pending actions for the given endpoint, create one
|
||||
err = postInitMigrator.dataStore.PendingActions().Create(&migrateEnvPendingAction)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating pending action for environment %d", environmentID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateEnvironment runs migrations on a single environment
|
||||
func (migrator *PostInitMigrator) MigrateEnvironment(environment *portainer.Endpoint) error {
|
||||
log.Info().Msgf("Executing post init migration for environment %d", environment.ID)
|
||||
|
||||
switch {
|
||||
case endpointutils.IsKubernetesEndpoint(environment):
|
||||
// get the kubeclient for the environment, and skip all kube migrations if there's an error
|
||||
kubeclient, err := migrator.kubeFactory.GetKubeClient(environment)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating kubeclient for environment: %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
// if one environment fails, it is logged and the next migration runs. The error is returned at the end and handled by pending actions
|
||||
err = migrator.MigrateIngresses(*environment, kubeclient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case endpointutils.IsDockerEndpoint(environment):
|
||||
// get the docker client for the environment, and skip all docker migrations if there's an error
|
||||
dockerClient, err := migrator.dockerFactory.CreateClient(environment, "", nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error creating docker client for environment: %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
defer dockerClient.Close()
|
||||
migrator.MigrateGPUs(*environment, dockerClient)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migrator *PostInitMigrator) MigrateIngresses(environment portainer.Endpoint, kubeclient *cli.KubeClient) error {
|
||||
// Early exit if we do not need to migrate!
|
||||
if !environment.PostInitMigrations.MigrateIngresses {
|
||||
return nil
|
||||
}
|
||||
log.Debug().Msgf("Migrating ingresses for environment %d", environment.ID)
|
||||
|
||||
err := migrator.kubeFactory.MigrateEndpointIngresses(&environment, migrator.dataStore, kubeclient)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error migrating ingresses for environment %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateGPUs will check all docker endpoints for containers with GPUs and set EnableGPUManagement to true if any are found
|
||||
// If there's an error getting the containers, we'll log it and move on
|
||||
func (migrator *PostInitMigrator) MigrateGPUs(e portainer.Endpoint, dockerClient *client.Client) error {
|
||||
return migrator.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
environment, err := tx.Endpoint().Endpoint(e.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error getting environment %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
// Early exit if we do not need to migrate!
|
||||
if !environment.PostInitMigrations.MigrateGPUs {
|
||||
return nil
|
||||
}
|
||||
log.Debug().Msgf("Migrating GPUs for environment %d", e.ID)
|
||||
|
||||
// get all containers
|
||||
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("failed to list containers for environment %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// check for a gpu on each container. If even one GPU is found, set EnableGPUManagement to true for the whole environment
|
||||
containersLoop:
|
||||
for _, container := range containers {
|
||||
// https://www.sobyte.net/post/2022-10/go-docker/ has nice documentation on the docker client with GPUs
|
||||
containerDetails, err := dockerClient.ContainerInspect(context.Background(), container.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to inspect container")
|
||||
continue
|
||||
}
|
||||
|
||||
deviceRequests := containerDetails.HostConfig.Resources.DeviceRequests
|
||||
for _, deviceRequest := range deviceRequests {
|
||||
if deviceRequest.Driver == "nvidia" {
|
||||
environment.EnableGPUManagement = true
|
||||
break containersLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// set the MigrateGPUs flag to false so we don't run this again
|
||||
environment.PostInitMigrations.MigrateGPUs = false
|
||||
err = tx.Endpoint().UpdateEndpoint(environment.ID, environment)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error updating EnableGPUManagement flag for environment %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -16,7 +16,9 @@ func (tx *StoreTx) IsErrObjectNotFound(err error) bool {
|
||||
|
||||
func (tx *StoreTx) CustomTemplate() dataservices.CustomTemplateService { return nil }
|
||||
|
||||
func (tx *StoreTx) PendingActions() dataservices.PendingActionsService { return nil }
|
||||
func (tx *StoreTx) PendingActions() dataservices.PendingActionsService {
|
||||
return tx.store.PendingActionsService.Tx(tx.tx)
|
||||
}
|
||||
|
||||
func (tx *StoreTx) EdgeGroup() dataservices.EdgeGroupService {
|
||||
return tx.store.EdgeGroupService.Tx(tx.tx)
|
||||
|
||||
@@ -631,6 +631,7 @@
|
||||
"LogoURL": "",
|
||||
"OAuthSettings": {
|
||||
"AccessTokenURI": "",
|
||||
"AuthStyle": 0,
|
||||
"AuthorizationURI": "",
|
||||
"ClientID": "",
|
||||
"DefaultTeamID": 0,
|
||||
@@ -677,6 +678,7 @@
|
||||
"Architecture": "",
|
||||
"BridgeNfIp6tables": false,
|
||||
"BridgeNfIptables": false,
|
||||
"CDISpecDirs": null,
|
||||
"CPUSet": false,
|
||||
"CPUShares": false,
|
||||
"CgroupDriver": "",
|
||||
@@ -939,6 +941,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.3\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -93,11 +93,17 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return client.NewClientWithOpts(
|
||||
opts := []client.Opt{
|
||||
client.WithHost(endpoint.URL),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithHTTPClient(httpCli),
|
||||
)
|
||||
}
|
||||
|
||||
if nnTransport, ok := httpCli.Transport.(*NodeNameTransport); ok && nnTransport.TLSClientConfig != nil {
|
||||
opts = append(opts, client.WithScheme("https"))
|
||||
}
|
||||
|
||||
return client.NewClientWithOpts(opts...)
|
||||
}
|
||||
|
||||
func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
||||
@@ -159,7 +165,7 @@ func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
var rs []struct {
|
||||
types.ImageSummary
|
||||
image.Summary
|
||||
Portainer struct {
|
||||
Agent struct {
|
||||
NodeName string
|
||||
|
||||
@@ -119,7 +119,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
for _, network := range container.NetworkSettings.Networks {
|
||||
cli.NetworkConnect(ctx, network.NetworkID, containerId, network)
|
||||
}
|
||||
cli.ContainerStart(ctx, containerId, types.ContainerStartOptions{})
|
||||
cli.ContainerStart(ctx, containerId, dockercontainer.StartOptions{})
|
||||
})
|
||||
|
||||
log.Debug().Str("container", strings.Split(container.Name, "/")[1]).Msg("starting to create a new container")
|
||||
@@ -135,7 +135,7 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
c.sr.push(func() {
|
||||
log.Debug().Str("container_id", create.ID).Msg("removing the new container")
|
||||
cli.ContainerStop(ctx, create.ID, dockercontainer.StopOptions{})
|
||||
cli.ContainerRemove(ctx, create.ID, types.ContainerRemoveOptions{})
|
||||
cli.ContainerRemove(ctx, create.ID, dockercontainer.RemoveOptions{})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
@@ -164,14 +164,14 @@ func (c *ContainerService) Recreate(ctx context.Context, endpoint *portainer.End
|
||||
|
||||
// 8. start the new container
|
||||
log.Debug().Str("container_id", newContainerId).Msg("starting the new container")
|
||||
err = cli.ContainerStart(ctx, newContainerId, types.ContainerStartOptions{})
|
||||
err = cli.ContainerStart(ctx, newContainerId, dockercontainer.StartOptions{})
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "start container error")
|
||||
}
|
||||
|
||||
// 9. delete the old container
|
||||
log.Debug().Str("container_id", containerId).Msg("starting to remove the old container")
|
||||
_ = cli.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{})
|
||||
_ = cli.ContainerRemove(ctx, containerId, dockercontainer.RemoveOptions{})
|
||||
|
||||
c.sr.disable()
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
consts "github.com/portainer/portainer/api/docker/consts"
|
||||
@@ -157,7 +158,7 @@ func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string,
|
||||
return Error, nil
|
||||
}
|
||||
|
||||
containers, err := cli.ContainerList(ctx, types.ContainerListOptions{
|
||||
containers, err := cli.ContainerList(ctx, container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+serviceID)),
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
_container "github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
@@ -147,7 +148,7 @@ func snapshotSwarmServices(snapshot *portainer.DockerSnapshot, cli *client.Clien
|
||||
}
|
||||
|
||||
func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client) error {
|
||||
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: true})
|
||||
containers, err := cli.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -934,7 +934,7 @@ func FileExists(filePath string) (bool, error) {
|
||||
func (service *Service) SafeMoveDirectory(originalPath, newPath string) error {
|
||||
// 1. Backup the source directory to a different folder
|
||||
backupDir := fmt.Sprintf("%s-%s", filepath.Dir(originalPath), "backup")
|
||||
err := MoveDirectory(originalPath, backupDir)
|
||||
err := MoveDirectory(originalPath, backupDir, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to backup source directory: %w", err)
|
||||
}
|
||||
@@ -973,14 +973,14 @@ func restoreBackup(src, backupDir string) error {
|
||||
return fmt.Errorf("failed to delete destination directory: %w", err)
|
||||
}
|
||||
|
||||
err = MoveDirectory(backupDir, src)
|
||||
err = MoveDirectory(backupDir, src, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restore backup directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MoveDirectory(originalPath, newPath string) error {
|
||||
func MoveDirectory(originalPath, newPath string, overwriteTargetPath bool) error {
|
||||
if _, err := os.Stat(originalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -991,7 +991,13 @@ func MoveDirectory(originalPath, newPath string) error {
|
||||
}
|
||||
|
||||
if alreadyExists {
|
||||
return errors.New("Target path already exists")
|
||||
if !overwriteTargetPath {
|
||||
return fmt.Errorf("Target path already exists")
|
||||
}
|
||||
|
||||
if err = os.RemoveAll(newPath); err != nil {
|
||||
return fmt.Errorf("failed to overwrite path %s: %s", newPath, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
return os.Rename(originalPath, newPath)
|
||||
|
||||
@@ -16,7 +16,7 @@ func Test_movePath_shouldFailIfSourceDirDoesNotExist(t *testing.T) {
|
||||
file1 := addFile(destinationDir, "dir", "file")
|
||||
file2 := addFile(destinationDir, "file")
|
||||
|
||||
err := MoveDirectory(sourceDir, destinationDir)
|
||||
err := MoveDirectory(sourceDir, destinationDir, false)
|
||||
assert.Error(t, err, "move directory should fail when source path is missing")
|
||||
assert.FileExists(t, file1, "destination dir contents should remain")
|
||||
assert.FileExists(t, file2, "destination dir contents should remain")
|
||||
@@ -30,7 +30,7 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
|
||||
file3 := addFile(destinationDir, "dir", "file")
|
||||
file4 := addFile(destinationDir, "file")
|
||||
|
||||
err := MoveDirectory(sourceDir, destinationDir)
|
||||
err := MoveDirectory(sourceDir, destinationDir, false)
|
||||
assert.Error(t, err, "move directory should fail when destination directory already exists")
|
||||
assert.FileExists(t, file1, "source dir contents should remain")
|
||||
assert.FileExists(t, file2, "source dir contents should remain")
|
||||
@@ -38,6 +38,22 @@ func Test_movePath_shouldFailIfDestinationDirExists(t *testing.T) {
|
||||
assert.FileExists(t, file4, "destination dir contents should remain")
|
||||
}
|
||||
|
||||
func Test_movePath_succesIfOverwriteSetWhenDestinationDirExists(t *testing.T) {
|
||||
sourceDir := t.TempDir()
|
||||
file1 := addFile(sourceDir, "dir", "file")
|
||||
file2 := addFile(sourceDir, "file")
|
||||
destinationDir := t.TempDir()
|
||||
file3 := addFile(destinationDir, "dir", "file")
|
||||
file4 := addFile(destinationDir, "file")
|
||||
|
||||
err := MoveDirectory(sourceDir, destinationDir, true)
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, file1, "source dir contents should be moved")
|
||||
assert.NoFileExists(t, file2, "source dir contents should be moved")
|
||||
assert.FileExists(t, file3, "destination dir contents should remain")
|
||||
assert.FileExists(t, file4, "destination dir contents should remain")
|
||||
}
|
||||
|
||||
func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
sourceDir := path.Join(tmp, "source")
|
||||
@@ -46,7 +62,7 @@ func Test_movePath_successWhenSourceExistsAndDestinationIsMissing(t *testing.T)
|
||||
file2 := addFile(sourceDir, "file")
|
||||
destinationDir := path.Join(tmp, "destination")
|
||||
|
||||
err := MoveDirectory(sourceDir, destinationDir)
|
||||
err := MoveDirectory(sourceDir, destinationDir, false)
|
||||
assert.NoError(t, err)
|
||||
assert.NoFileExists(t, file1, "source dir contents should be moved")
|
||||
assert.NoFileExists(t, file2, "source dir contents should be moved")
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
|
||||
}
|
||||
}
|
||||
|
||||
err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath)
|
||||
err = filesystem.MoveDirectory(options.ProjectPath, backupProjectPath, true)
|
||||
if err != nil {
|
||||
return cleanFn, errors.WithMessage(err, "Unable to move git repository directory")
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func CloneWithBackup(gitService portainer.GitService, fileService portainer.File
|
||||
err = gitService.CloneRepository(options.ProjectPath, options.URL, options.ReferenceName, options.Username, options.Password, options.TLSSkipVerify)
|
||||
if err != nil {
|
||||
cleanUp = false
|
||||
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath)
|
||||
restoreError := filesystem.MoveDirectory(backupProjectPath, options.ProjectPath, false)
|
||||
if restoreError != nil {
|
||||
log.Warn().Err(restoreError).Msg("failed restoring backup folder")
|
||||
}
|
||||
|
||||
@@ -75,7 +75,12 @@ func (handler *Handler) authenticate(rw http.ResponseWriter, r *http.Request) *h
|
||||
if settings.AuthenticationMethod == portainer.AuthenticationInternal ||
|
||||
settings.AuthenticationMethod == portainer.AuthenticationOAuth ||
|
||||
(settings.AuthenticationMethod == portainer.AuthenticationLDAP && !settings.LDAPSettings.AutoCreateUsers) {
|
||||
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
|
||||
// avoid username enumeration timing attack by creating a fake user
|
||||
// https://en.wikipedia.org/wiki/Timing_attack
|
||||
user = &portainer.User{
|
||||
Username: "portainer-fake-username",
|
||||
Password: "$2a$10$abcdefghijklmnopqrstuvwx..ABCDEFGHIJKLMNOPQRSTUVWXYZ12", // fake but valid format bcrypt hash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +117,11 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
|
||||
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
|
||||
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Only initial admin is allowed to login without oauth", err)
|
||||
if errors.Is(err, httperrors.ErrUnauthorized) {
|
||||
return httperror.NewError(http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized)
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unable to authenticate user against LDAP", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
)
|
||||
|
||||
type ImageResponse struct {
|
||||
@@ -63,7 +64,9 @@ func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *http
|
||||
|
||||
imageUsageSet := set.Set[string]{}
|
||||
if withUsage {
|
||||
containers, err := cli.ContainerList(r.Context(), types.ContainerListOptions{})
|
||||
containers, err := cli.ContainerList(r.Context(), container.ListOptions{
|
||||
All: true,
|
||||
})
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve Docker containers", err)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/tag"
|
||||
pendingActionActions "github.com/portainer/portainer/api/pendingactions/actions"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -159,7 +160,9 @@ func (handler *Handler) updateEndpointGroup(tx dataservices.DataStoreTx, endpoin
|
||||
err := handler.PendingActionsService.Create(portainer.PendingActions{
|
||||
EndpointID: endpointID,
|
||||
Action: "CleanNAPWithOverridePolicies",
|
||||
ActionData: endpointGroupID,
|
||||
ActionData: &pendingActionActions.CleanNAPWithOverridePoliciesPayload{
|
||||
EndpointGroupID: endpointGroupID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Unable to create pending action to clean NAP with override policies for endpoint (%d) and endpoint group (%d).", endpointID, endpointGroupID)
|
||||
|
||||
@@ -179,6 +179,12 @@ func (handler *Handler) deleteEndpoint(tx dataservices.DataStoreTx, endpointID p
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
err = tx.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to delete the environment from the database", err)
|
||||
|
||||
@@ -21,7 +21,8 @@ func TestEndpointDeleteEdgeGroupsConcurrently(t *testing.T) {
|
||||
|
||||
handler := NewHandler(testhelpers.NewTestRequestBouncer(), demo.NewService())
|
||||
handler.DataStore = store
|
||||
handler.ProxyManager = proxy.NewManager(nil, nil, nil, nil, nil, nil, nil)
|
||||
handler.ProxyManager = proxy.NewManager(nil)
|
||||
handler.ProxyManager.NewProxyFactory(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// Create all the environments and add them to the same edge group
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
dockertypes "github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ func (payload *forceUpdateServicePayload) Validate(r *http.Request) error {
|
||||
// @produce json
|
||||
// @param id path int true "endpoint identifier"
|
||||
// @param body body forceUpdateServicePayload true "details"
|
||||
// @success 200 {object} dockertypes.ServiceUpdateResponse "Success"
|
||||
// @success 200 {object} swarm.ServiceUpdateResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "endpoint not found"
|
||||
@@ -94,7 +94,7 @@ func (handler *Handler) endpointForceUpdateService(w http.ResponseWriter, r *htt
|
||||
go func() {
|
||||
images.EvictImageStatus(payload.ServiceID)
|
||||
images.EvictImageStatus(service.Spec.Labels[consts.SwarmStackNameLabel])
|
||||
containers, _ := dockerClient.ContainerList(context.TODO(), types.ContainerListOptions{
|
||||
containers, _ := dockerClient.ContainerList(context.TODO(), container.ListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(filters.Arg("label", consts.SwarmServiceIdLabel+"="+payload.ServiceID)),
|
||||
})
|
||||
|
||||
@@ -622,6 +622,7 @@ func getEdgeStackStatusParam(r *http.Request) (*portainer.EdgeStackStatusType, e
|
||||
portainer.EdgeStackStatusRunning,
|
||||
portainer.EdgeStackStatusDeploying,
|
||||
portainer.EdgeStackStatusRemoving,
|
||||
portainer.EdgeStackStatusCompleted,
|
||||
}, edgeStackStatus) {
|
||||
return nil, errors.New("invalid edgeStackStatus parameter")
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.20.1
|
||||
// @version 2.20.3
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -61,8 +61,7 @@ func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *htt
|
||||
return httperror.InternalServerError("Unable to install a chart", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return response.JSON(w, release)
|
||||
return response.JSONWithStatus(w, release, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (p *installChartPayload) Validate(_ *http.Request) error {
|
||||
|
||||
@@ -155,7 +155,7 @@ func pullImage(ctx context.Context, docker *client.Client, imageName string) err
|
||||
// runContainer should be used to run a short command that returns information to stdout
|
||||
// TODO: add k8s support
|
||||
func runContainer(ctx context.Context, docker *client.Client, imageName, containerName string, cmdLine []string) (output string, err error) {
|
||||
opts := types.ContainerListOptions{All: true}
|
||||
opts := container.ListOptions{All: true}
|
||||
opts.Filters = filters.NewArgs()
|
||||
opts.Filters.Add("name", containerName)
|
||||
existingContainers, err := docker.ContainerList(ctx, opts)
|
||||
@@ -170,7 +170,7 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
|
||||
}
|
||||
|
||||
if len(existingContainers) > 0 {
|
||||
err = docker.ContainerRemove(ctx, existingContainers[0].ID, types.ContainerRemoveOptions{Force: true})
|
||||
err = docker.ContainerRemove(ctx, existingContainers[0].ID, container.RemoveOptions{Force: true})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("image_name", imageName).
|
||||
@@ -211,7 +211,7 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = docker.ContainerStart(ctx, created.ID, types.ContainerStartOptions{})
|
||||
err = docker.ContainerStart(ctx, created.ID, container.StartOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("image_name", imageName).
|
||||
@@ -243,14 +243,14 @@ func runContainer(ctx context.Context, docker *client.Client, imageName, contain
|
||||
|
||||
log.Debug().Int64("status", statusCode).Msg("container wait status")
|
||||
|
||||
out, err := docker.ContainerLogs(ctx, created.ID, types.ContainerLogsOptions{ShowStdout: true})
|
||||
out, err := docker.ContainerLogs(ctx, created.ID, container.LogsOptions{ShowStdout: true})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("image_name", imageName).Str("container_name", containerName).Msg("getting container log")
|
||||
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = docker.ContainerRemove(ctx, created.ID, types.ContainerRemoveOptions{})
|
||||
err = docker.ContainerRemove(ctx, created.ID, container.RemoveOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("image_name", imageName).
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/pendingactions"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
@@ -91,7 +92,7 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) er
|
||||
if len(failedNamespaces) > 0 {
|
||||
handler.PendingActionsService.Create(portainer.PendingActions{
|
||||
EndpointID: endpointId,
|
||||
Action: pendingactions.DeletePortainerK8sRegistrySecrets,
|
||||
Action: actions.DeletePortainerK8sRegistrySecrets,
|
||||
|
||||
// When extracting the data, this is the type we need to pull out
|
||||
// i.e. pendingactions.DeletePortainerK8sRegistrySecretsData
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
@@ -95,6 +96,11 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
if payload.OAuthSettings != nil {
|
||||
if payload.OAuthSettings.AuthStyle < oauth2.AuthStyleAutoDetect || payload.OAuthSettings.AuthStyle > oauth2.AuthStyleInHeader {
|
||||
return errors.New("Invalid OAuth AuthStyle")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -225,6 +231,7 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
|
||||
settings.OAuthSettings = *payload.OAuthSettings
|
||||
settings.OAuthSettings.ClientSecret = clientSecret
|
||||
settings.OAuthSettings.KubeSecretKey = kubeSecret
|
||||
settings.OAuthSettings.AuthStyle = payload.OAuthSettings.AuthStyle
|
||||
}
|
||||
|
||||
if payload.EnableEdgeComputeFeatures != nil {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -190,7 +191,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
|
||||
}
|
||||
}
|
||||
|
||||
containers, err := dockerClient.ContainerList(context.Background(), types.ContainerListOptions{All: true})
|
||||
containers, err := dockerClient.ContainerList(context.Background(), container.ListOptions{All: true})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -20,9 +21,6 @@ type userAccessTokenCreatePayload struct {
|
||||
}
|
||||
|
||||
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.Password) {
|
||||
return errors.New("invalid password: cannot be empty")
|
||||
}
|
||||
if govalidator.IsNull(payload.Description) {
|
||||
return errors.New("invalid description: cannot be empty")
|
||||
}
|
||||
@@ -44,6 +42,7 @@ type accessTokenResponse struct {
|
||||
// @summary Generate an API key for a user
|
||||
// @description Generates an API key for a user.
|
||||
// @description Only the calling user can generate a token for themselves.
|
||||
// @description Password is required only for internal authentication.
|
||||
// @description **Access policy**: restricted
|
||||
// @tags users
|
||||
// @security jwt
|
||||
@@ -51,7 +50,7 @@ type accessTokenResponse struct {
|
||||
// @produce json
|
||||
// @param id path int true "User identifier"
|
||||
// @param body body userAccessTokenCreatePayload true "details"
|
||||
// @success 201 {object} accessTokenResponse "Created"
|
||||
// @success 200 {object} accessTokenResponse "Created"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 403 "Permission denied"
|
||||
@@ -94,9 +93,21 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
|
||||
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
|
||||
return httperror.InternalServerError("Unable to determine the authentication method", err)
|
||||
}
|
||||
|
||||
if internalAuth {
|
||||
// Internal auth requires the password field and must not be empty
|
||||
if govalidator.IsNull(payload.Password) {
|
||||
return httperror.BadRequest("Invalid request payload", errors.New("invalid password: cannot be empty"))
|
||||
}
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
|
||||
}
|
||||
}
|
||||
|
||||
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
|
||||
@@ -104,6 +115,20 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
|
||||
return httperror.InternalServerError("Internal Server Error", err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
return response.JSON(w, accessTokenResponse{rawAPIKey, *apiKey})
|
||||
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 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// otherwise determine the auth method from the settings
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unable to retrieve the settings from the database: %w", err)
|
||||
}
|
||||
|
||||
return settings.AuthenticationMethod == portainer.AuthenticationInternal, nil
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
|
||||
DockerClientFactory: factory.dockerClientFactory,
|
||||
}
|
||||
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService)
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, httpTransport, factory.gitService, factory.snapshotService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ type (
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
dockerClientFactory *dockerclient.ClientFactory
|
||||
gitService portainer.GitService
|
||||
snapshotService portainer.SnapshotService
|
||||
}
|
||||
|
||||
// TransportParameters is used to create a new Transport
|
||||
@@ -63,7 +64,7 @@ type (
|
||||
)
|
||||
|
||||
// NewTransport returns a pointer to a new Transport instance.
|
||||
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService) (*Transport, error) {
|
||||
func NewTransport(parameters *TransportParameters, httpTransport *http.Transport, gitService portainer.GitService, snapshotService portainer.SnapshotService) (*Transport, error) {
|
||||
transport := &Transport{
|
||||
endpoint: parameters.Endpoint,
|
||||
dataStore: parameters.DataStore,
|
||||
@@ -72,6 +73,7 @@ func NewTransport(parameters *TransportParameters, httpTransport *http.Transport
|
||||
dockerClientFactory: parameters.DockerClientFactory,
|
||||
HTTPTransport: httpTransport,
|
||||
gitService: gitService,
|
||||
snapshotService: snapshotService,
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
@@ -48,6 +49,14 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
if responseObject["Volumes"] != nil {
|
||||
volumeData := responseObject["Volumes"].([]interface{})
|
||||
|
||||
if transport.snapshotService != nil {
|
||||
// Filling snapshot data can improve the performance of getVolumeResourceID
|
||||
if err = transport.snapshotService.FillSnapshotData(transport.endpoint); err != nil {
|
||||
log.Info().Err(err).
|
||||
Int("endpoint id", int(transport.endpoint.ID)).
|
||||
Msg("snapshot is not filled into the endpoint.")
|
||||
}
|
||||
}
|
||||
for _, volumeObject := range volumeData {
|
||||
volume := volumeObject.(map[string]interface{})
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
|
||||
|
||||
proxy := &dockerLocalProxy{}
|
||||
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService)
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newSocketTransport(path), factory.gitService, factory.snapshotService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func (factory ProxyFactory) newOSBasedLocalProxy(path string, endpoint *portaine
|
||||
|
||||
proxy := &dockerLocalProxy{}
|
||||
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService)
|
||||
dockerTransport, err := docker.NewTransport(transportParameters, newNamedPipeTransport(path), factory.gitService, factory.snapshotService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -23,11 +23,12 @@ type (
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
gitService portainer.GitService
|
||||
snapshotService portainer.SnapshotService
|
||||
}
|
||||
)
|
||||
|
||||
// NewProxyFactory returns a pointer to a new instance of a ProxyFactory
|
||||
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *ProxyFactory {
|
||||
func NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) *ProxyFactory {
|
||||
return &ProxyFactory{
|
||||
dataStore: dataStore,
|
||||
signatureService: signatureService,
|
||||
@@ -36,6 +37,7 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine
|
||||
kubernetesClientFactory: kubernetesClientFactory,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
gitService: gitService,
|
||||
snapshotService: snapshotService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,17 +25,24 @@ type (
|
||||
)
|
||||
|
||||
// NewManager initializes a new proxy Service
|
||||
func NewManager(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService) *Manager {
|
||||
func NewManager(kubernetesClientFactory *cli.ClientFactory) *Manager {
|
||||
return &Manager{
|
||||
endpointProxies: cmap.New(),
|
||||
k8sClientFactory: kubernetesClientFactory,
|
||||
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService),
|
||||
}
|
||||
}
|
||||
|
||||
func (manager *Manager) NewProxyFactory(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *dockerclient.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager, gitService portainer.GitService, snapshotService portainer.SnapshotService) {
|
||||
manager.proxyFactory = factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager, gitService, snapshotService)
|
||||
}
|
||||
|
||||
// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
|
||||
proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -48,6 +55,9 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo
|
||||
// CreateAgentProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAgentProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
return manager.proxyFactory.NewAgentProxy(endpoint)
|
||||
}
|
||||
|
||||
@@ -74,5 +84,8 @@ func (manager *Manager) DeleteEndpointProxy(endpointID portainer.EndpointID) {
|
||||
|
||||
// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API
|
||||
func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) {
|
||||
if manager.proxyFactory == nil {
|
||||
return nil, fmt.Errorf("proxy factory not init")
|
||||
}
|
||||
return manager.proxyFactory.NewGitlabProxy(url)
|
||||
}
|
||||
|
||||
+2
-2
@@ -61,7 +61,6 @@ 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"
|
||||
@@ -382,7 +381,8 @@ func (server *Server) Start() error {
|
||||
|
||||
go shutdown(server.ShutdownCtx, httpsServer)
|
||||
|
||||
go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
|
||||
// Temporarily disable for EE-6905 until we have a solution for the snapshotter
|
||||
// go snapshot.NewBackgroundSnapshotter(server.DataStore, server.ReverseTunnelService)
|
||||
|
||||
return httpsServer.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
"github.com/pkg/errors"
|
||||
@@ -286,106 +287,111 @@ func buildLocalConfig() (*rest.Config, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {
|
||||
// classes is a list of controllers which have been manually added to the
|
||||
// cluster setup view. These need to all be allowed globally, but then
|
||||
// blocked in specific namespaces which they were not previously allowed in.
|
||||
classes := e.Kubernetes.Configuration.IngressClasses
|
||||
|
||||
// We need a kube client to gather namespace level permissions. In pre-2.16
|
||||
// versions of portainer, the namespace level permissions were stored by
|
||||
// creating an actual ingress rule in the cluster with a particular
|
||||
// annotation indicating that it's name (the class name) should be allowed.
|
||||
cli, err := factory.GetKubeClient(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
detected, err := cli.GetIngressControllers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// newControllers is a set of all currently detected controllers.
|
||||
newControllers := make(map[string]struct{})
|
||||
for _, controller := range detected {
|
||||
newControllers[controller.ClassName] = struct{}{}
|
||||
}
|
||||
|
||||
namespaces, err := cli.GetNamespaces()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set of namespaces, if any, in which "allow none" should be true.
|
||||
allow := make(map[string]map[string]struct{})
|
||||
for _, c := range classes {
|
||||
allow[c.Name] = make(map[string]struct{})
|
||||
}
|
||||
allow["none"] = make(map[string]struct{})
|
||||
|
||||
for namespace := range namespaces {
|
||||
// Compare old annotations with currently detected controllers.
|
||||
ingresses, err := cli.GetIngresses(namespace)
|
||||
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint, datastore dataservices.DataStore, cli *KubeClient) error {
|
||||
return datastore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
environment, err := tx.Endpoint().Endpoint(e.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failure getting ingresses during migration")
|
||||
log.Error().Err(err).Msgf("Error retrieving environment %d", e.ID)
|
||||
return err
|
||||
}
|
||||
for _, ingress := range ingresses {
|
||||
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
|
||||
if !ok {
|
||||
// Skip rules without our old annotation.
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := newControllers[oldController]; ok {
|
||||
// Skip rules which match a detected controller.
|
||||
// TODO: Allow this particular controller.
|
||||
allow[oldController][ingress.Namespace] = struct{}{}
|
||||
continue
|
||||
}
|
||||
// classes is a list of controllers which have been manually added to the
|
||||
// cluster setup view. These need to all be allowed globally, but then
|
||||
// blocked in specific namespaces which they were not previously allowed in.
|
||||
classes := environment.Kubernetes.Configuration.IngressClasses
|
||||
|
||||
allow["none"][ingress.Namespace] = struct{}{}
|
||||
// In pre-2.16 versions of portainer, the namespace level permissions were stored by
|
||||
// creating an actual ingress rule in the cluster with a particular
|
||||
// annotation indicating that it's name (the class name) should be allowed.
|
||||
detected, err := cli.GetIngressControllers()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error getting ingress controllers in environment %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Locally, disable "allow none" for namespaces not inside shouldAllowNone.
|
||||
var newClasses []portainer.KubernetesIngressClassConfig
|
||||
for _, c := range classes {
|
||||
var blocked []string
|
||||
// newControllers is a set of all currently detected controllers.
|
||||
newControllers := make(map[string]struct{})
|
||||
for _, controller := range detected {
|
||||
newControllers[controller.ClassName] = struct{}{}
|
||||
}
|
||||
|
||||
namespaces, err := cli.GetNamespaces()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error getting namespaces in environment %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Set of namespaces, if any, in which "allow none" should be true.
|
||||
allow := make(map[string]map[string]struct{})
|
||||
for _, c := range classes {
|
||||
allow[c.Name] = make(map[string]struct{})
|
||||
}
|
||||
allow["none"] = make(map[string]struct{})
|
||||
|
||||
for namespace := range namespaces {
|
||||
if _, ok := allow[c.Name][namespace]; ok {
|
||||
continue
|
||||
// Compare old annotations with currently detected controllers.
|
||||
ingresses, err := cli.GetIngresses(namespace)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error getting ingresses in environment %d", environment.ID)
|
||||
return err
|
||||
}
|
||||
for _, ingress := range ingresses {
|
||||
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
|
||||
if !ok {
|
||||
// Skip rules without our old annotation.
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := newControllers[oldController]; ok {
|
||||
// Skip rules which match a detected controller.
|
||||
// TODO: Allow this particular controller.
|
||||
allow[oldController][ingress.Namespace] = struct{}{}
|
||||
continue
|
||||
}
|
||||
|
||||
allow["none"][ingress.Namespace] = struct{}{}
|
||||
}
|
||||
blocked = append(blocked, namespace)
|
||||
}
|
||||
|
||||
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
|
||||
Name: c.Name,
|
||||
Type: c.Type,
|
||||
GloballyBlocked: false,
|
||||
BlockedNamespaces: blocked,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle "none".
|
||||
if len(allow["none"]) != 0 {
|
||||
e.Kubernetes.Configuration.AllowNoneIngressClass = true
|
||||
var disallowNone []string
|
||||
for namespace := range namespaces {
|
||||
if _, ok := allow["none"][namespace]; ok {
|
||||
continue
|
||||
// Locally, disable "allow none" for namespaces not inside shouldAllowNone.
|
||||
var newClasses []portainer.KubernetesIngressClassConfig
|
||||
for _, c := range classes {
|
||||
var blocked []string
|
||||
for namespace := range namespaces {
|
||||
if _, ok := allow[c.Name][namespace]; ok {
|
||||
continue
|
||||
}
|
||||
blocked = append(blocked, namespace)
|
||||
}
|
||||
disallowNone = append(disallowNone, namespace)
|
||||
}
|
||||
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
|
||||
Name: "none",
|
||||
Type: "custom",
|
||||
GloballyBlocked: false,
|
||||
BlockedNamespaces: disallowNone,
|
||||
})
|
||||
}
|
||||
|
||||
e.Kubernetes.Configuration.IngressClasses = newClasses
|
||||
e.PostInitMigrations.MigrateIngresses = false
|
||||
return factory.dataStore.Endpoint().UpdateEndpoint(e.ID, e)
|
||||
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
|
||||
Name: c.Name,
|
||||
Type: c.Type,
|
||||
GloballyBlocked: false,
|
||||
BlockedNamespaces: blocked,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle "none".
|
||||
if len(allow["none"]) != 0 {
|
||||
environment.Kubernetes.Configuration.AllowNoneIngressClass = true
|
||||
var disallowNone []string
|
||||
for namespace := range namespaces {
|
||||
if _, ok := allow["none"][namespace]; ok {
|
||||
continue
|
||||
}
|
||||
disallowNone = append(disallowNone, namespace)
|
||||
}
|
||||
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
|
||||
Name: "none",
|
||||
Type: "custom",
|
||||
GloballyBlocked: false,
|
||||
BlockedNamespaces: disallowNone,
|
||||
})
|
||||
}
|
||||
|
||||
environment.Kubernetes.Configuration.IngressClasses = newClasses
|
||||
environment.PostInitMigrations.MigrateIngresses = false
|
||||
return tx.Endpoint().UpdateEndpoint(environment.ID, environment)
|
||||
})
|
||||
}
|
||||
|
||||
+19
-4
@@ -125,12 +125,27 @@ func GetNamespace(manifestYaml []byte) (string, error) {
|
||||
return "", errors.Wrap(err, "failed to unmarshal yaml manifest when obtaining namespace")
|
||||
}
|
||||
|
||||
if _, ok := m["metadata"]; ok {
|
||||
if namespace, ok := m["metadata"].(map[string]interface{})["namespace"]; ok {
|
||||
return namespace.(string), nil
|
||||
}
|
||||
kind, ok := m["kind"].(string)
|
||||
if !ok {
|
||||
return "", errors.New("invalid kubernetes manifest, missing 'kind' field")
|
||||
}
|
||||
|
||||
if _, ok := m["metadata"]; ok {
|
||||
var namespace interface{}
|
||||
var ok bool
|
||||
if strings.EqualFold(kind, "namespace") {
|
||||
namespace, ok = m["metadata"].(map[string]interface{})["name"]
|
||||
} else {
|
||||
namespace, ok = m["metadata"].(map[string]interface{})["namespace"]
|
||||
}
|
||||
|
||||
if ok {
|
||||
if v, ok := namespace.(string); ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", errors.New("invalid kubernetes manifest, 'namespace' field is not a string")
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
|
||||
@@ -648,7 +648,7 @@ func Test_GetNamespace(t *testing.T) {
|
||||
input: `apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
namespace: test-namespace
|
||||
name: test-namespace
|
||||
`,
|
||||
want: "test-namespace",
|
||||
},
|
||||
|
||||
+8
-1
@@ -75,7 +75,14 @@ func (*Service) AuthenticateUser(username, password string, settings *portainer.
|
||||
|
||||
userDN, err := searchUser(username, connection, settings.SearchSettings)
|
||||
if err != nil {
|
||||
return err
|
||||
if errors.Is(err, errUserNotFound) {
|
||||
// prevent user enumeration timing attack by attempting the bind with a fake user
|
||||
// and whatever password was provided should definately fail
|
||||
// https://en.wikipedia.org/wiki/Timing_attack
|
||||
userDN = "portainer-fake-ldap-username"
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = connection.Bind(userDN, password)
|
||||
|
||||
+3
-2
@@ -172,8 +172,9 @@ func getResource(token string, configuration *portainer.OAuthSettings) (map[stri
|
||||
|
||||
func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config {
|
||||
endpoint := oauth2.Endpoint{
|
||||
AuthURL: configuration.AuthorizationURI,
|
||||
TokenURL: configuration.AccessTokenURI,
|
||||
AuthURL: configuration.AuthorizationURI,
|
||||
TokenURL: configuration.AccessTokenURI,
|
||||
AuthStyle: configuration.AuthStyle,
|
||||
}
|
||||
|
||||
return &oauth2.Config{
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package actions
|
||||
|
||||
const (
|
||||
CleanNAPWithOverridePolicies = "CleanNAPWithOverridePolicies"
|
||||
DeletePortainerK8sRegistrySecrets = "DeletePortainerK8sRegistrySecrets"
|
||||
PostInitMigrateEnvironment = "PostInitMigrateEnvironment"
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type (
|
||||
CleanNAPWithOverridePoliciesPayload struct {
|
||||
EndpointGroupID portainer.EndpointGroupID
|
||||
}
|
||||
)
|
||||
|
||||
func ConvertCleanNAPWithOverridePoliciesPayload(actionData interface{}) (*CleanNAPWithOverridePoliciesPayload, error) {
|
||||
var payload CleanNAPWithOverridePoliciesPayload
|
||||
|
||||
if actionData == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// backward compatible with old data format
|
||||
if endpointGroupId, ok := actionData.(float64); ok {
|
||||
payload.EndpointGroupID = portainer.EndpointGroupID(endpointGroupId)
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
data, ok := actionData.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to convert actionData to map[string]interface{}")
|
||||
|
||||
}
|
||||
|
||||
for key, value := range data {
|
||||
switch key {
|
||||
case "EndpointGroupID":
|
||||
if endpointGroupID, ok := value.(float64); ok {
|
||||
payload.EndpointGroupID = portainer.EndpointGroupID(endpointGroupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &payload, nil
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func (service *PendingActionsService) DeleteKubernetesRegistrySecrets(endpoint *
|
||||
return nil
|
||||
}
|
||||
|
||||
kubeClient, err := service.clientFactory.GetKubeClient(endpoint)
|
||||
kubeClient, err := service.kubeFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -7,22 +7,24 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore/postinit"
|
||||
dockerClient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
CleanNAPWithOverridePolicies = "CleanNAPWithOverridePolicies"
|
||||
DeletePortainerK8sRegistrySecrets = "DeletePortainerK8sRegistrySecrets"
|
||||
)
|
||||
|
||||
type (
|
||||
PendingActionsService struct {
|
||||
authorizationService *authorization.Service
|
||||
clientFactory *kubecli.ClientFactory
|
||||
kubeFactory *kubecli.ClientFactory
|
||||
dockerFactory *dockerClient.ClientFactory
|
||||
dataStore dataservices.DataStore
|
||||
shutdownCtx context.Context
|
||||
assetsPath string
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
@@ -30,15 +32,21 @@ type (
|
||||
|
||||
func NewService(
|
||||
dataStore dataservices.DataStore,
|
||||
clientFactory *kubecli.ClientFactory,
|
||||
kubeFactory *kubecli.ClientFactory,
|
||||
dockerFactory *dockerClient.ClientFactory,
|
||||
authorizationService *authorization.Service,
|
||||
shutdownCtx context.Context,
|
||||
assetsPath string,
|
||||
kubernetesDeployer portainer.KubernetesDeployer,
|
||||
) *PendingActionsService {
|
||||
return &PendingActionsService{
|
||||
dataStore: dataStore,
|
||||
shutdownCtx: shutdownCtx,
|
||||
authorizationService: authorizationService,
|
||||
clientFactory: clientFactory,
|
||||
kubeFactory: kubeFactory,
|
||||
dockerFactory: dockerFactory,
|
||||
assetsPath: assetsPath,
|
||||
kubernetesDeployer: kubernetesDeployer,
|
||||
mu: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
@@ -57,9 +65,22 @@ func (service *PendingActionsService) Execute(id portainer.EndpointID) error {
|
||||
return fmt.Errorf("failed to retrieve environment %d: %w", id, err)
|
||||
}
|
||||
|
||||
if endpoint.Status != portainer.EndpointStatusUp {
|
||||
isKubernetesEndpoint := endpointutils.IsKubernetesEndpoint(endpoint) && !endpointutils.IsEdgeEndpoint(endpoint)
|
||||
|
||||
// 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: %w", endpoint.Name, id, err)
|
||||
return fmt.Errorf("environment %q (id: %d) is not up", endpoint.Name, id)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
pendingActions, err := service.dataStore.PendingActions().ReadAll()
|
||||
@@ -95,13 +116,19 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
}()
|
||||
|
||||
switch pendingAction.Action {
|
||||
case CleanNAPWithOverridePolicies:
|
||||
if (pendingAction.ActionData == nil) || (pendingAction.ActionData.(portainer.EndpointGroupID) == 0) {
|
||||
case actions.CleanNAPWithOverridePolicies:
|
||||
pendingActionData, err := actions.ConvertCleanNAPWithOverridePoliciesPayload(pendingAction.ActionData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse pendingActionData for CleanNAPWithOverridePoliciesPayload")
|
||||
}
|
||||
|
||||
if pendingActionData == nil || pendingActionData.EndpointGroupID == 0 {
|
||||
service.authorizationService.CleanNAPWithOverridePolicies(service.dataStore, endpoint, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
endpointGroupID := pendingAction.ActionData.(portainer.EndpointGroupID)
|
||||
endpointGroupID := pendingActionData.EndpointGroupID
|
||||
|
||||
endpointGroup, err := service.dataStore.EndpointGroup().Read(portainer.EndpointGroupID(endpointGroupID))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error reading environment group to clean NAP with override policies for environment %d and environment group %d", endpoint.ID, endpointGroup.ID)
|
||||
@@ -114,7 +141,7 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
}
|
||||
|
||||
return nil
|
||||
case DeletePortainerK8sRegistrySecrets:
|
||||
case actions.DeletePortainerK8sRegistrySecrets:
|
||||
if pendingAction.ActionData == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -130,6 +157,22 @@ func (service *PendingActionsService) executePendingAction(pendingAction portain
|
||||
return fmt.Errorf("failed to delete kubernetes registry secrets for environment %d: %w", endpoint.ID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case actions.PostInitMigrateEnvironment:
|
||||
postInitMigrator := postinit.NewPostInitMigrator(
|
||||
service.kubeFactory,
|
||||
service.dockerFactory,
|
||||
service.dataStore,
|
||||
service.assetsPath,
|
||||
service.kubernetesDeployer,
|
||||
)
|
||||
err := postInitMigrator.MigrateEnvironment(endpoint)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Error running post-init migrations for edge environment %d", endpoint.ID)
|
||||
return fmt.Errorf("failed running post-init migrations for edge environment %d: %w", endpoint.ID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+22
-16
@@ -6,10 +6,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/system"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/portainer/portainer/pkg/featureflags"
|
||||
"golang.org/x/oauth2"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
@@ -242,8 +245,8 @@ type (
|
||||
Containers []DockerContainerSnapshot `json:"Containers" swaggerignore:"true"`
|
||||
Volumes volume.ListResponse `json:"Volumes" swaggerignore:"true"`
|
||||
Networks []types.NetworkResource `json:"Networks" swaggerignore:"true"`
|
||||
Images []types.ImageSummary `json:"Images" swaggerignore:"true"`
|
||||
Info types.Info `json:"Info" swaggerignore:"true"`
|
||||
Images []image.Summary `json:"Images" swaggerignore:"true"`
|
||||
Info system.Info `json:"Info" swaggerignore:"true"`
|
||||
Version types.Version `json:"Version" swaggerignore:"true"`
|
||||
}
|
||||
|
||||
@@ -756,19 +759,20 @@ type (
|
||||
|
||||
// OAuthSettings represents the settings used to authorize with an authorization server
|
||||
OAuthSettings struct {
|
||||
ClientID string `json:"ClientID"`
|
||||
ClientSecret string `json:"ClientSecret,omitempty"`
|
||||
AccessTokenURI string `json:"AccessTokenURI"`
|
||||
AuthorizationURI string `json:"AuthorizationURI"`
|
||||
ResourceURI string `json:"ResourceURI"`
|
||||
RedirectURI string `json:"RedirectURI"`
|
||||
UserIdentifier string `json:"UserIdentifier"`
|
||||
Scopes string `json:"Scopes"`
|
||||
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
|
||||
DefaultTeamID TeamID `json:"DefaultTeamID"`
|
||||
SSO bool `json:"SSO"`
|
||||
LogoutURI string `json:"LogoutURI"`
|
||||
KubeSecretKey []byte `json:"KubeSecretKey"`
|
||||
ClientID string `json:"ClientID"`
|
||||
ClientSecret string `json:"ClientSecret,omitempty"`
|
||||
AccessTokenURI string `json:"AccessTokenURI"`
|
||||
AuthorizationURI string `json:"AuthorizationURI"`
|
||||
ResourceURI string `json:"ResourceURI"`
|
||||
RedirectURI string `json:"RedirectURI"`
|
||||
UserIdentifier string `json:"UserIdentifier"`
|
||||
Scopes string `json:"Scopes"`
|
||||
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
|
||||
DefaultTeamID TeamID `json:"DefaultTeamID"`
|
||||
SSO bool `json:"SSO"`
|
||||
LogoutURI string `json:"LogoutURI"`
|
||||
KubeSecretKey []byte `json:"KubeSecretKey"`
|
||||
AuthStyle oauth2.AuthStyle `json:"AuthStyle"`
|
||||
}
|
||||
|
||||
// Pair defines a key/value string pair
|
||||
@@ -1595,7 +1599,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.20.1"
|
||||
APIVersion = "2.20.3"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -1724,6 +1728,8 @@ const (
|
||||
EdgeStackStatusRollingBack
|
||||
// EdgeStackStatusRolledBack represents an Edge stack which has rolled back
|
||||
EdgeStackStatusRolledBack
|
||||
// EdgeStackStatusCompleted represents a completed Edge stack
|
||||
EdgeStackStatusCompleted
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/api/types/system"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/pkg/errors"
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUnpackerImage = "portainer/compose-unpacker:latest"
|
||||
defaultUnpackerImage = "portainer/compose-unpacker:" + portainer.APIVersion
|
||||
composeUnpackerImageEnvVar = "COMPOSE_UNPACKER_IMAGE"
|
||||
composePathPrefix = "portainer-compose-unpacker"
|
||||
)
|
||||
@@ -211,9 +212,9 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "unable to create unpacker container")
|
||||
}
|
||||
defer cli.ContainerRemove(ctx, unpackerContainer.ID, types.ContainerRemoveOptions{})
|
||||
defer cli.ContainerRemove(ctx, unpackerContainer.ID, container.RemoveOptions{})
|
||||
|
||||
if err := cli.ContainerStart(ctx, unpackerContainer.ID, types.ContainerStartOptions{}); err != nil {
|
||||
if err := cli.ContainerStart(ctx, unpackerContainer.ID, container.StartOptions{}); err != nil {
|
||||
return errors.Wrap(err, "start unpacker container error")
|
||||
}
|
||||
|
||||
@@ -228,7 +229,7 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
|
||||
|
||||
stdErr := &bytes.Buffer{}
|
||||
|
||||
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
|
||||
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, container.LogsOptions{ShowStdout: true, ShowStderr: true})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("unable to get logs from unpacker container")
|
||||
} else {
|
||||
@@ -335,6 +336,6 @@ func getTargetSocketBind(osType string) string {
|
||||
|
||||
// Per https://stackoverflow.com/a/50590287 and Docker's LocalNodeState possible values
|
||||
// `LocalNodeStateInactive` means the node is not in a swarm cluster
|
||||
func isNotInASwarm(info *types.Info) bool {
|
||||
func isNotInASwarm(info *system.Info) bool {
|
||||
return info.Swarm.LocalNodeState == swarm.LocalNodeStateInactive
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class KubernetesApplicationsController {
|
||||
} else {
|
||||
await this.KubernetesApplicationService.delete(application);
|
||||
|
||||
if (application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) {
|
||||
if (application.Metadata.labels && application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) {
|
||||
// Update applications in stack
|
||||
const stack = this.state.stacks.find((x) => x.Name === application.StackName);
|
||||
const index = stack.Applications.indexOf(application);
|
||||
|
||||
@@ -65,13 +65,12 @@
|
||||
<relative-path-fieldset value="$ctrl.stack" git-model="$ctrl.stack" is-editing="true" hide-edge-configs="true"></relative-path-fieldset>
|
||||
</div>
|
||||
|
||||
<environment-variables-panel
|
||||
<stack-environment-variables-panel
|
||||
values="$ctrl.formValues.Env"
|
||||
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’.'"
|
||||
on-change="($ctrl.onChangeEnvVar)"
|
||||
show-help-message="true"
|
||||
is-foldable="true"
|
||||
></environment-variables-panel>
|
||||
></stack-environment-variables-panel>
|
||||
|
||||
<option-panel ng-if="$ctrl.stack.Type === 1 && $ctrl.endpoint.apiVersion >= 1.27" ng-model="$ctrl.formValues.Option" on-change="($ctrl.onChangeOption)"></option-panel>
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ export function OAuthSettingsViewModel(data) {
|
||||
this.DefaultTeamID = data.DefaultTeamID;
|
||||
this.SSO = data.SSO;
|
||||
this.LogoutURI = data.LogoutURI;
|
||||
this.AuthStyle = data.AuthStyle;
|
||||
}
|
||||
|
||||
export function EdgeSettingsViewModel(data = {}) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
|
||||
import { ModalType } from '@@/modals';
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
import providers, { getProviderByUrl } from './providers';
|
||||
|
||||
const MS_TENANT_ID_PLACEHOLDER = 'TENANT_ID';
|
||||
@@ -31,6 +30,7 @@ export default class OAuthSettingsController {
|
||||
this.addTeamMembershipMapping = this.addTeamMembershipMapping.bind(this);
|
||||
this.removeTeamMembership = this.removeTeamMembership.bind(this);
|
||||
this.onToggleAutoTeamMembership = this.onToggleAutoTeamMembership.bind(this);
|
||||
this.onChangeAuthStyle = this.onChangeAuthStyle.bind(this);
|
||||
}
|
||||
|
||||
onMicrosoftTenantIDChange() {
|
||||
@@ -54,6 +54,7 @@ export default class OAuthSettingsController {
|
||||
this.settings.LogoutURI = provider.logoutUrl;
|
||||
this.settings.UserIdentifier = provider.userIdentifier;
|
||||
this.settings.Scopes = provider.scopes;
|
||||
this.settings.AuthStyle = provider.authStyle;
|
||||
|
||||
if (providerId === 'microsoft' && this.state.microsoftTenantID !== '') {
|
||||
this.onMicrosoftTenantIDChange();
|
||||
@@ -77,6 +78,12 @@ export default class OAuthSettingsController {
|
||||
});
|
||||
}
|
||||
|
||||
onChangeAuthStyle(val) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.settings.AuthStyle = val;
|
||||
});
|
||||
}
|
||||
|
||||
async onChangeHideInternalAuth(checked) {
|
||||
this.$async(async () => {
|
||||
if (this.isLimitedToBE) {
|
||||
|
||||
@@ -341,6 +341,8 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<oauth-auth-style value="$ctrl.settings.AuthStyle" on-change="($ctrl.onChangeAuthStyle)"></oauth-auth-style>
|
||||
<save-auth-settings-button
|
||||
on-save-settings="($ctrl.onSaveSettings)"
|
||||
save-button-state="($ctrl.saveButtonState)"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { baseHref } from '@/portainer/helpers/pathHelper';
|
||||
import { OAuthStyle } from '@/react/portainer/settings/types';
|
||||
|
||||
export default {
|
||||
microsoft: {
|
||||
@@ -8,6 +9,7 @@ export default {
|
||||
logoutUrl: `https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/logout`,
|
||||
userIdentifier: 'userPrincipalName',
|
||||
scopes: 'profile openid',
|
||||
authStyle: OAuthStyle.InParams,
|
||||
},
|
||||
google: {
|
||||
authUrl: 'https://accounts.google.com/o/oauth2/auth',
|
||||
@@ -16,6 +18,7 @@ export default {
|
||||
logoutUrl: `https://www.google.com/accounts/Logout?continue=https://appengine.google.com/_ah/logout?continue=${window.location.origin}${baseHref()}#!/auth`,
|
||||
userIdentifier: 'email',
|
||||
scopes: 'profile email',
|
||||
authStyle: OAuthStyle.InParams,
|
||||
},
|
||||
github: {
|
||||
authUrl: 'https://github.com/login/oauth/authorize',
|
||||
@@ -24,8 +27,9 @@ export default {
|
||||
logoutUrl: `https://github.com/logout`,
|
||||
userIdentifier: 'login',
|
||||
scopes: 'id email name',
|
||||
authStyle: OAuthStyle.AutoDetect,
|
||||
},
|
||||
custom: { authUrl: '', accessTokenUrl: '', resourceUrl: '', logoutUrl: '', userIdentifier: '', scopes: '' },
|
||||
custom: { authUrl: '', accessTokenUrl: '', resourceUrl: '', logoutUrl: '', userIdentifier: '', scopes: '', authStyle: OAuthStyle.AutoDetect },
|
||||
};
|
||||
|
||||
export function getProviderByUrl(providerAuthURL = '') {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { withControlledInput } from '@/react-tools/withControlledInput';
|
||||
import {
|
||||
EnvironmentVariablesFieldset,
|
||||
EnvironmentVariablesPanel,
|
||||
StackEnvironmentVariablesPanel,
|
||||
envVarValidation,
|
||||
} from '@@/form-components/EnvironmentVariablesFieldset';
|
||||
import { Icon } from '@@/Icon';
|
||||
@@ -263,3 +264,13 @@ withFormValidation(
|
||||
['explanation', 'showHelpMessage', 'isFoldable'],
|
||||
envVarValidation
|
||||
);
|
||||
|
||||
withFormValidation(
|
||||
ngModule,
|
||||
withUIRouter(
|
||||
withControlledInput(StackEnvironmentVariablesPanel, { values: 'onChange' })
|
||||
),
|
||||
'stackEnvironmentVariablesPanel',
|
||||
['showHelpMessage', 'isFoldable'],
|
||||
envVarValidation
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { KubeSettingsPanel } from '@/react/portainer/settings/SettingsView/KubeS
|
||||
import { HelmCertPanel } from '@/react/portainer/settings/SettingsView/HelmCertPanel';
|
||||
import { HiddenContainersPanel } from '@/react/portainer/settings/SettingsView/HiddenContainersPanel/HiddenContainersPanel';
|
||||
import { SSLSettingsPanelWrapper } from '@/react/portainer/settings/SettingsView/SSLSettingsPanel/SSLSettingsPanel';
|
||||
import { AuthStyleField } from '@/react/portainer/settings/AuthenticationView/OAuth';
|
||||
|
||||
export const settingsModule = angular
|
||||
.module('portainer.app.react.components.settings', [])
|
||||
@@ -39,4 +40,15 @@ export const settingsModule = angular
|
||||
.component(
|
||||
'kubeSettingsPanel',
|
||||
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), ['settings'])
|
||||
)
|
||||
.component(
|
||||
'oauthAuthStyle',
|
||||
r2a(AuthStyleField, [
|
||||
'value',
|
||||
'onChange',
|
||||
'label',
|
||||
'tooltip',
|
||||
'readonly',
|
||||
'size',
|
||||
])
|
||||
).name;
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'history'"></pr-icon>
|
||||
</div>
|
||||
Activity Logs
|
||||
Activity logs
|
||||
</div>
|
||||
<div class="vertical-center">
|
||||
<datatable-searchbar on-change="($ctrl.onChangeKeyword)" value="$ctrl.keyword"></datatable-searchbar>
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
<page-header title="'User Activity'" breadcrumbs="['Activity Logs']" reload="true"> </page-header>
|
||||
<page-header title="'User activity logs'" breadcrumbs="['User activity logs']" reload="true"> </page-header>
|
||||
|
||||
<div class="be-indicator-container limited-be mx-4">
|
||||
<div>
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date Range</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" class="form-control" disabled />
|
||||
<div class="mx-4">
|
||||
<div class="be-indicator-container limited-be">
|
||||
<div class="limited-be-link vertical-center m-4"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content !p-0 !pt-[15px]">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date range</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" class="form-control" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
|
||||
Portainer user activity logs have a maximum retention of 7 days.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled>
|
||||
<pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>
|
||||
Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<div class="row mt-5">
|
||||
<activity-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
sort="$ctrl.state.sort"
|
||||
limit="$ctrl.state.limit"
|
||||
context-filter="$ctrl.state.contextFilter"
|
||||
total-items="$ctrl.state.totalItems"
|
||||
current-page="$ctrl.state.currentPage"
|
||||
feature="{{:: $ctrl.limitedFeature}}"
|
||||
on-change-keyword="($ctrl.onChangeKeyword)"
|
||||
on-change-sort="($ctrl.onChangeSort)"
|
||||
on-change-limit="($ctrl.onChangeLimit)"
|
||||
on-change-page="($ctrl.onChangePage)"
|
||||
></activity-logs-datatable>
|
||||
<p class="text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
|
||||
Portainer user activity logs have a maximum retention of 7 days.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled>
|
||||
<pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>
|
||||
Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<activity-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
sort="$ctrl.state.sort"
|
||||
limit="$ctrl.state.limit"
|
||||
context-filter="$ctrl.state.contextFilter"
|
||||
total-items="$ctrl.state.totalItems"
|
||||
current-page="$ctrl.state.currentPage"
|
||||
feature="{{:: $ctrl.limitedFeature}}"
|
||||
on-change-keyword="($ctrl.onChangeKeyword)"
|
||||
on-change-sort="($ctrl.onChangeSort)"
|
||||
on-change-limit="($ctrl.onChangeLimit)"
|
||||
on-change-page="($ctrl.onChangePage)"
|
||||
></activity-logs-datatable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'history'"></pr-icon>
|
||||
</div>
|
||||
Authentication Events
|
||||
Authentication events
|
||||
</div>
|
||||
<div class="vertical-center">
|
||||
<datatable-searchbar on-change="($ctrl.onChangeKeyword)"></datatable-searchbar>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default class AuthLogsViewController {
|
||||
|
||||
this.limitedFeature = FeatureId.ACTIVITY_AUDIT;
|
||||
this.state = {
|
||||
keyword: 'f',
|
||||
keyword: '',
|
||||
date: {
|
||||
from: 0,
|
||||
to: 0,
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
<page-header title="'User Activity'" breadcrumbs="['User authentication activity']" reload="true"> </page-header>
|
||||
<page-header title="'User authentication logs'" breadcrumbs="['User authentication logs']" reload="true"> </page-header>
|
||||
|
||||
<div class="be-indicator-container limited-be mx-4">
|
||||
<div>
|
||||
<div class="limited-be-link vertical-center"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<div class="limited-be-content">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date Range</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" class="form-control" disabled />
|
||||
<div class="mx-4">
|
||||
<div class="be-indicator-container limited-be">
|
||||
<div class="limited-be-link vertical-center m-4"><be-feature-indicator feature="$ctrl.limitedFeature"></be-feature-indicator></div>
|
||||
<!-- 15px matches the padding for col-sm-12 for the widget and table -->
|
||||
<div class="limited-be-content !p-0 !pt-[15px]">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="dateRangeInput" class="col-sm-2 control-label text-left">Date range</label>
|
||||
<div class="col-sm-6">
|
||||
<input type="text" class="form-control" disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
|
||||
Portainer user authentication activity logs have a maximum retention of 7 days.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled
|
||||
><pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<div class="row mt-5">
|
||||
<auth-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
sort="$ctrl.state.sort"
|
||||
limit="$ctrl.state.limit"
|
||||
context-filter="$ctrl.state.contextFilter"
|
||||
type-filter="$ctrl.state.typeFilter"
|
||||
total-items="$ctrl.state.totalItems"
|
||||
current-page="$ctrl.state.currentPage"
|
||||
feature="{{:: $ctrl.limitedFeature}}"
|
||||
on-change-context-filter="($ctrl.onChangeContextFilter)"
|
||||
on-change-type-filter="($ctrl.onChangeTypeFilter)"
|
||||
on-change-keyword="($ctrl.onChangeKeyword)"
|
||||
on-change-sort="($ctrl.onChangeSort)"
|
||||
on-change-limit="($ctrl.onChangeLimit)"
|
||||
on-change-page="($ctrl.onChangePage)"
|
||||
></auth-logs-datatable>
|
||||
<p class="text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" class-name="'icon icon-sm icon-primary'"></pr-icon>
|
||||
Portainer user authentication logs have a maximum retention of 7 days.
|
||||
</p>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-primary" limited-feature-dir="{{::$ctrl.limitedFeature}}" limited-feature-class="limited-be" limited-feature-disabled
|
||||
><pr-icon icon="'download'" class-name="'icon icon-sm'"></pr-icon>Export as CSV
|
||||
</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<auth-logs-datatable
|
||||
logs="$ctrl.state.logs"
|
||||
keyword="$ctrl.state.keyword"
|
||||
sort="$ctrl.state.sort"
|
||||
limit="$ctrl.state.limit"
|
||||
context-filter="$ctrl.state.contextFilter"
|
||||
type-filter="$ctrl.state.typeFilter"
|
||||
total-items="$ctrl.state.totalItems"
|
||||
current-page="$ctrl.state.currentPage"
|
||||
feature="{{:: $ctrl.limitedFeature}}"
|
||||
on-change-context-filter="($ctrl.onChangeContextFilter)"
|
||||
on-change-type-filter="($ctrl.onChangeTypeFilter)"
|
||||
on-change-keyword="($ctrl.onChangeKeyword)"
|
||||
on-change-sort="($ctrl.onChangeSort)"
|
||||
on-change-limit="($ctrl.onChangeLimit)"
|
||||
on-change-page="($ctrl.onChangePage)"
|
||||
></auth-logs-datatable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,12 +152,7 @@
|
||||
</div>
|
||||
|
||||
<!-- environment-variables -->
|
||||
<environment-variables-panel
|
||||
values="formValues.Env"
|
||||
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’'"
|
||||
on-change="(handleEnvVarChange)"
|
||||
>
|
||||
</environment-variables-panel>
|
||||
<stack-environment-variables-panel values="formValues.Env" on-change="(handleEnvVarChange)" show-alert="true"> </stack-environment-variables-panel>
|
||||
<!-- !environment-variables -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData"></por-access-control-form>
|
||||
<!-- actions -->
|
||||
|
||||
@@ -169,13 +169,12 @@
|
||||
|
||||
<!-- environment-variables -->
|
||||
<div ng-if="stack">
|
||||
<environment-variables-panel
|
||||
<stack-environment-variables-panel
|
||||
values="formValues.Env"
|
||||
explanation="'These values will be used as substitutions in the stack file. To reference the .env file in your compose file, use ‘stack.env’.'"
|
||||
on-change="(handleEnvVarChange)"
|
||||
show-help-message="true"
|
||||
is-foldable="true"
|
||||
></environment-variables-panel>
|
||||
></stack-environment-variables-panel>
|
||||
</div>
|
||||
<!-- !environment-variables -->
|
||||
|
||||
|
||||
@@ -5,14 +5,38 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
|
||||
|
||||
import { BEFeatureIndicator } from './BEFeatureIndicator';
|
||||
|
||||
type Variants = 'form-section' | 'widget' | 'multi-widget';
|
||||
|
||||
type OverlayClasses = {
|
||||
beLinkContainerClassName: string;
|
||||
contentClassName: string;
|
||||
};
|
||||
|
||||
const variantClassNames: Record<Variants, OverlayClasses> = {
|
||||
'form-section': {
|
||||
beLinkContainerClassName: '',
|
||||
contentClassName: '',
|
||||
},
|
||||
widget: {
|
||||
beLinkContainerClassName: '',
|
||||
// no padding so that the border overlaps the widget border
|
||||
contentClassName: '!p-0',
|
||||
},
|
||||
'multi-widget': {
|
||||
beLinkContainerClassName: 'm-4',
|
||||
// widgets have a mx of 15px and mb of 15px - match this at the top with padding
|
||||
contentClassName: '!p-0 !pt-[15px]',
|
||||
},
|
||||
};
|
||||
|
||||
export function BEOverlay({
|
||||
featureId,
|
||||
children,
|
||||
className,
|
||||
variant = 'form-section',
|
||||
}: {
|
||||
featureId: FeatureId;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: 'form-section' | 'widget' | 'multi-widget';
|
||||
}) {
|
||||
const isLimited = isLimitedToBE(featureId);
|
||||
if (!isLimited) {
|
||||
@@ -21,10 +45,22 @@ export function BEOverlay({
|
||||
|
||||
return (
|
||||
<div className="be-indicator-container limited-be">
|
||||
<div className="limited-be-link vertical-center">
|
||||
<div
|
||||
className={clsx(
|
||||
'limited-be-link vertical-center',
|
||||
variantClassNames[variant].beLinkContainerClassName
|
||||
)}
|
||||
>
|
||||
<BEFeatureIndicator featureId={featureId} />
|
||||
</div>
|
||||
<div className={clsx('limited-be-content', className)}>{children}</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'limited-be-content',
|
||||
variantClassNames[variant].contentClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-2
@@ -1,4 +1,4 @@
|
||||
import { ComponentProps } from 'react';
|
||||
import React, { ComponentProps } from 'react';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
@@ -14,16 +14,19 @@ export function EnvironmentVariablesPanel({
|
||||
showHelpMessage,
|
||||
errors,
|
||||
isFoldable = false,
|
||||
alertMessage,
|
||||
}: {
|
||||
explanation?: string;
|
||||
explanation?: React.ReactNode;
|
||||
showHelpMessage?: boolean;
|
||||
isFoldable?: boolean;
|
||||
alertMessage?: React.ReactNode;
|
||||
} & FieldsetProps) {
|
||||
return (
|
||||
<FormSection
|
||||
title="Environment variables"
|
||||
isFoldable={isFoldable}
|
||||
defaultFolded={isFoldable}
|
||||
className="flex flex-col w-full"
|
||||
>
|
||||
<div className="form-group">
|
||||
{!!explanation && (
|
||||
@@ -32,6 +35,8 @@ export function EnvironmentVariablesPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alertMessage}
|
||||
|
||||
<div className="col-sm-12">
|
||||
<EnvironmentVariablesFieldset
|
||||
values={values}
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { ComponentProps } from 'react';
|
||||
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { EnvironmentVariablesFieldset } from './EnvironmentVariablesFieldset';
|
||||
import { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
|
||||
|
||||
type FieldsetProps = ComponentProps<typeof EnvironmentVariablesFieldset>;
|
||||
|
||||
export function StackEnvironmentVariablesPanel({
|
||||
onChange,
|
||||
values,
|
||||
errors,
|
||||
isFoldable = false,
|
||||
showHelpMessage,
|
||||
}: {
|
||||
isFoldable?: boolean;
|
||||
showHelpMessage?: boolean;
|
||||
} & FieldsetProps) {
|
||||
return (
|
||||
<EnvironmentVariablesPanel
|
||||
explanation={
|
||||
<div>
|
||||
You may use{' '}
|
||||
<a
|
||||
href="https://docs.portainer.io/v/2.20/user/docker/stacks/add#environment-variables"
|
||||
target="_blank"
|
||||
data-cy="stack-env-vars-help-link"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
environment variables in your compose file
|
||||
</a>
|
||||
. The environment variable values set below will be used as
|
||||
substitutions in the compose file. Note that you may also reference a
|
||||
stack.env file in your compose file. A stack.env file contains the
|
||||
environment variables and their values (e.g. TAG=v1.5).
|
||||
</div>
|
||||
}
|
||||
onChange={onChange}
|
||||
values={values}
|
||||
errors={errors}
|
||||
isFoldable={isFoldable}
|
||||
showHelpMessage={showHelpMessage}
|
||||
alertMessage={
|
||||
<div className="flex p-4">
|
||||
<Alert color="info" className="col-sm-12">
|
||||
<div>
|
||||
<p>
|
||||
<strong>stack.env file operation</strong>
|
||||
</p>
|
||||
<div>
|
||||
When deploying via <strong>Repository</strong>, the stack.env
|
||||
file must already reside in the Git repo.
|
||||
</div>
|
||||
<div>
|
||||
When deploying via <strong>Web editor</strong>,{' '}
|
||||
<strong>Upload</strong> or{' '}
|
||||
<strong>Custom template deployment</strong>, the stack.env file
|
||||
is auto created from what you set below.
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,5 +4,6 @@ export {
|
||||
} from './EnvironmentVariablesFieldset';
|
||||
|
||||
export { EnvironmentVariablesPanel } from './EnvironmentVariablesPanel';
|
||||
export { StackEnvironmentVariablesPanel } from './StackEnvironmentVariablesPanel';
|
||||
|
||||
export { type Values as EnvVarValues } from './types';
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
isFoldable?: boolean;
|
||||
defaultFolded?: boolean;
|
||||
titleClassName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FormSection({
|
||||
@@ -20,11 +21,12 @@ export function FormSection({
|
||||
isFoldable = false,
|
||||
defaultFolded = isFoldable,
|
||||
titleClassName,
|
||||
className,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const [isExpanded, setIsExpanded] = useState(!defaultFolded);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={className}>
|
||||
<FormSectionTitle
|
||||
htmlFor={isFoldable ? `foldingButton${title}` : ''}
|
||||
titleSize={titleSize}
|
||||
@@ -52,6 +54,6 @@ export function FormSection({
|
||||
</FormSectionTitle>
|
||||
|
||||
{isExpanded && children}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { TextTip } from '@@/Tip/TextTip';
|
||||
import { HelpLink } from '@@/HelpLink';
|
||||
|
||||
import { useContainers } from '../queries/containers';
|
||||
import { useSystemLimits } from '../../proxy/queries/useInfo';
|
||||
import { useSystemLimits, useIsWindows } from '../../proxy/queries/useInfo';
|
||||
|
||||
import { useCreateOrReplaceMutation } from './useCreateMutation';
|
||||
import { useValidation } from './validation';
|
||||
@@ -48,6 +48,7 @@ export function CreateView() {
|
||||
function CreateForm() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const router = useRouter();
|
||||
const isWindows = useIsWindows(environmentId);
|
||||
const { trackEvent } = useAnalytics();
|
||||
const isAdminQuery = useIsEdgeAdmin();
|
||||
const { authorized: isEnvironmentAdmin } = useIsEnvironmentAdmin({
|
||||
@@ -57,7 +58,8 @@ function CreateForm() {
|
||||
|
||||
const mutation = useCreateOrReplaceMutation();
|
||||
const initialValuesQuery = useInitialValues(
|
||||
mutation.isLoading || mutation.isSuccess
|
||||
mutation.isLoading || mutation.isSuccess,
|
||||
isWindows
|
||||
);
|
||||
const registriesQuery = useEnvironmentRegistries(environmentId);
|
||||
|
||||
@@ -84,9 +86,11 @@ function CreateForm() {
|
||||
|
||||
const environment = envQuery.data;
|
||||
|
||||
// if windows, hide capabilities. this is because capadd and capdel are not supported on windows
|
||||
const hideCapabilities =
|
||||
!environment.SecuritySettings.allowContainerCapabilitiesForRegularUsers &&
|
||||
!isEnvironmentAdmin;
|
||||
(!environment.SecuritySettings.allowContainerCapabilitiesForRegularUsers &&
|
||||
!isEnvironmentAdmin) ||
|
||||
isWindows;
|
||||
|
||||
const {
|
||||
isDuplicating = false,
|
||||
|
||||
@@ -5,9 +5,10 @@ import { DockerContainer } from '../../types';
|
||||
|
||||
import { CONTAINER_MODE, Values } from './types';
|
||||
|
||||
export function getDefaultViewModel() {
|
||||
export function getDefaultViewModel(isWindows: boolean) {
|
||||
const networkMode = isWindows ? 'nat' : 'bridge';
|
||||
return {
|
||||
networkMode: 'bridge',
|
||||
networkMode,
|
||||
hostname: '',
|
||||
domain: '',
|
||||
macAddress: '',
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface Values extends BaseFormValues {
|
||||
env: EnvVarValues;
|
||||
}
|
||||
|
||||
export function useInitialValues(submitting: boolean) {
|
||||
export function useInitialValues(submitting: boolean, isWindows: boolean) {
|
||||
const {
|
||||
params: { nodeName, from },
|
||||
} = useCurrentStateAndParams();
|
||||
@@ -66,9 +66,10 @@ export function useInitialValues(submitting: boolean) {
|
||||
|
||||
const networksQuery = useNetworksForSelector();
|
||||
|
||||
const fromContainerQuery = useContainer(environmentId, from, {
|
||||
const fromContainerQuery = useContainer(environmentId, from, nodeName, {
|
||||
enabled: !submitting,
|
||||
});
|
||||
|
||||
const runningContainersQuery = useContainers(environmentId, {
|
||||
enabled: !!from,
|
||||
});
|
||||
@@ -86,7 +87,7 @@ export function useInitialValues(submitting: boolean) {
|
||||
|
||||
if (!from) {
|
||||
return {
|
||||
initialValues: defaultValues(isPureAdmin, user.Id, nodeName),
|
||||
initialValues: defaultValues(isPureAdmin, user.Id, nodeName, isWindows),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,12 +152,13 @@ export function useInitialValues(submitting: boolean) {
|
||||
function defaultValues(
|
||||
isPureAdmin: boolean,
|
||||
currentUserId: UserId,
|
||||
nodeName: string
|
||||
nodeName: string,
|
||||
isWindows: boolean
|
||||
): Values {
|
||||
return {
|
||||
commands: commandsTabUtils.getDefaultViewModel(),
|
||||
volumes: volumesTabUtils.getDefaultViewModel(),
|
||||
network: networkTabUtils.getDefaultViewModel(),
|
||||
network: networkTabUtils.getDefaultViewModel(isWindows), // windows containers should default to the nat network, not the bridge
|
||||
labels: labelsTabUtils.getDefaultViewModel(),
|
||||
restartPolicy: restartPolicyTabUtils.getDefaultViewModel(),
|
||||
resources: resourcesTabUtils.getDefaultViewModel(),
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Link } from '@@/Link';
|
||||
|
||||
export function LogView() {
|
||||
const {
|
||||
params: { endpointId: environmentId, id: containerId },
|
||||
params: { endpointId: environmentId, id: containerId, nodeName },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const containerQuery = useContainer(environmentId, containerId);
|
||||
const containerQuery = useContainer(environmentId, containerId, nodeName);
|
||||
if (!containerQuery.data || containerQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
MountPoint,
|
||||
NetworkSettings,
|
||||
} from 'docker-types/generated/1.41';
|
||||
import { RawAxiosRequestHeaders } from 'axios';
|
||||
|
||||
import { PortainerResponse } from '@/react/docker/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
@@ -75,11 +76,15 @@ export interface ContainerJSON {
|
||||
export function useContainer(
|
||||
environmentId: EnvironmentId,
|
||||
containerId?: ContainerId,
|
||||
nodeName?: string,
|
||||
{ enabled }: { enabled?: boolean } = {}
|
||||
) {
|
||||
return useQuery(
|
||||
containerId ? queryKeys.container(environmentId, containerId) : [],
|
||||
() => (containerId ? getContainer(environmentId, containerId) : undefined),
|
||||
() =>
|
||||
containerId
|
||||
? getContainer(environmentId, containerId, nodeName)
|
||||
: undefined,
|
||||
{
|
||||
meta: {
|
||||
title: 'Failure',
|
||||
@@ -103,11 +108,19 @@ export type ContainerResponse = PortainerResponse<ContainerJSON>;
|
||||
|
||||
async function getContainer(
|
||||
environmentId: EnvironmentId,
|
||||
containerId: ContainerId
|
||||
containerId: ContainerId,
|
||||
nodeName?: string
|
||||
) {
|
||||
try {
|
||||
const headers: RawAxiosRequestHeaders = {};
|
||||
|
||||
if (nodeName) {
|
||||
headers['X-PortainerAgent-Target'] = nodeName;
|
||||
}
|
||||
|
||||
const { data } = await axios.get<ContainerResponse>(
|
||||
urlBuilder(environmentId, containerId, 'json')
|
||||
urlBuilder(environmentId, containerId, 'json'),
|
||||
{ headers }
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -81,7 +81,7 @@ function buildImageFullURIWithRegistry(image: string, registry: Registry) {
|
||||
}
|
||||
|
||||
function buildImageURIForGithub(image: string, registry: Registry) {
|
||||
const imageName = image.split('/').pop();
|
||||
const imageName = image.startsWith('/') ? image.slice(1) : image;
|
||||
|
||||
const namespace = registry.Github.UseOrganisation
|
||||
? registry.Github.OrganisationName
|
||||
|
||||
@@ -30,6 +30,12 @@ export function useInfo<TSelect = SystemInfo>(
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsWindows(environmentId: EnvironmentId) {
|
||||
const query = useInfo(environmentId, (info) => info.OSType === 'windows');
|
||||
|
||||
return !!query.data;
|
||||
}
|
||||
|
||||
export function useIsStandAlone(environmentId: EnvironmentId) {
|
||||
const query = useInfo(environmentId, (info) => !info.Swarm?.NodeID);
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ function getTooltip(count: number, total: number, type?: StatusType) {
|
||||
switch (type) {
|
||||
case StatusType.Running:
|
||||
return 'deployments running';
|
||||
case StatusType.Completed:
|
||||
return 'deployments completed';
|
||||
case StatusType.DeploymentReceived:
|
||||
return 'deployments received';
|
||||
case StatusType.Error:
|
||||
|
||||
@@ -84,6 +84,16 @@ function getStatus(
|
||||
};
|
||||
}
|
||||
|
||||
const allCompleted = envStatus.every((s) => s.Type === StatusType.Completed);
|
||||
|
||||
if (allCompleted) {
|
||||
return {
|
||||
label: 'Completed',
|
||||
icon: CheckCircle,
|
||||
mode: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
const allRunning = envStatus.every(
|
||||
(s) =>
|
||||
s.Type === StatusType.Running ||
|
||||
|
||||
@@ -44,6 +44,8 @@ export enum StatusType {
|
||||
RollingBack,
|
||||
/** PausedRemoving represents an Edge stack which has been rolled back */
|
||||
RolledBack,
|
||||
/** Completed represents a completed Edge stack */
|
||||
Completed,
|
||||
}
|
||||
|
||||
export interface DeploymentStatus {
|
||||
|
||||
+11
-7
@@ -12,6 +12,7 @@ import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
|
||||
import {
|
||||
useApplicationRevisionList,
|
||||
@@ -86,13 +87,16 @@ export function RollbackApplicationButton({
|
||||
|
||||
return (
|
||||
<Authorized authorizations="K8sApplicationDetailsW">
|
||||
{isRollbackNotAvailable ? (
|
||||
<TooltipWithChildren message="Cannot roll back to previous configuration as none currently exists">
|
||||
<span>{rollbackButton}</span>
|
||||
</TooltipWithChildren>
|
||||
) : (
|
||||
rollbackButton
|
||||
)}
|
||||
<div className="flex gap-x-2">
|
||||
{isRollbackNotAvailable ? (
|
||||
<TooltipWithChildren message="Cannot roll back to previous configuration as none currently exists">
|
||||
<span>{rollbackButton}</span>
|
||||
</TooltipWithChildren>
|
||||
) : (
|
||||
rollbackButton
|
||||
)}
|
||||
<Tooltip message="Only one level of rollback is available, i.e. if you roll back from v2 to v1, and then roll back again, you will end up back at v2. Note that service changes and autoscaler rule changes are not included in rollback functionality. This is how Kubernetes works natively." />
|
||||
</div>
|
||||
</Authorized>
|
||||
);
|
||||
|
||||
|
||||
+1
@@ -170,6 +170,7 @@ function UnmatchedAffinitiesInfo({
|
||||
'datatable-highlighted': isHighlighted,
|
||||
'datatable-unhighlighted': !isHighlighted,
|
||||
})}
|
||||
key={aff.map((term) => term.key).join('')}
|
||||
>
|
||||
<td />
|
||||
<td colSpan={cellCount - 1}>
|
||||
|
||||
+7
-5
@@ -11,11 +11,13 @@ export const status = columnHelper.accessor('acceptsApplication', {
|
||||
cell: ({ getValue }) => {
|
||||
const acceptsApplication = getValue();
|
||||
return (
|
||||
<Icon
|
||||
icon={acceptsApplication ? Check : X}
|
||||
mode={acceptsApplication ? 'success' : 'danger'}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex items-center h-full">
|
||||
<Icon
|
||||
icon={acceptsApplication ? Check : X}
|
||||
mode={acceptsApplication ? 'success' : 'danger'}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
|
||||
+85
-67
@@ -2,9 +2,9 @@ import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { useMemo } from 'react';
|
||||
import { Pod, Taint, Node } from 'kubernetes-types/core/v1';
|
||||
import _ from 'lodash';
|
||||
import * as JsonPatch from 'fast-json-patch';
|
||||
|
||||
import { useNodesQuery } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||
import { KubernetesPodNodeAffinityNodeSelectorRequirementOperators } from '@/kubernetes/pod/models';
|
||||
|
||||
import {
|
||||
BasicTableSettings,
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
|
||||
import { useApplication, useApplicationPods } from '../../application.queries';
|
||||
import { NodePlacementRowData } from '../types';
|
||||
import { Affinity, Label, NodePlacementRowData } from '../types';
|
||||
|
||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||
|
||||
@@ -162,6 +162,68 @@ function computeTolerations(nodes: Node[], pod: Pod): NodePlacementRowData[] {
|
||||
return nodePlacements;
|
||||
}
|
||||
|
||||
function getUnmatchedNodeSelectorLabels(node: Node, pod: Pod): Label[] {
|
||||
const nodeLabels = node.metadata?.labels || {};
|
||||
const podNodeSelectorLabels = pod.spec?.nodeSelector || {};
|
||||
|
||||
return Object.entries(podNodeSelectorLabels)
|
||||
.filter(([key, value]) => nodeLabels[key] !== value)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
// Function to get unmatched required node affinities
|
||||
function getUnmatchedRequiredNodeAffinities(node: Node, pod: Pod): Affinity[] {
|
||||
const basicNodeAffinity =
|
||||
pod.spec?.affinity?.nodeAffinity
|
||||
?.requiredDuringSchedulingIgnoredDuringExecution;
|
||||
|
||||
const unmatchedRequiredNodeAffinities: Affinity[] =
|
||||
basicNodeAffinity?.nodeSelectorTerms.map(
|
||||
(selectorTerm) =>
|
||||
selectorTerm.matchExpressions?.flatMap((matchExpression) => {
|
||||
const exists = !!node.metadata?.labels?.[matchExpression.key];
|
||||
const isIn =
|
||||
exists &&
|
||||
_.includes(
|
||||
matchExpression.values,
|
||||
node.metadata?.labels?.[matchExpression.key]
|
||||
);
|
||||
|
||||
// Check if the match expression is satisfied
|
||||
if (
|
||||
(matchExpression.operator === 'Exists' && exists) ||
|
||||
(matchExpression.operator === 'DoesNotExist' && !exists) ||
|
||||
(matchExpression.operator === 'In' && isIn) ||
|
||||
(matchExpression.operator === 'NotIn' && !isIn) ||
|
||||
(matchExpression.operator === 'Gt' &&
|
||||
exists &&
|
||||
parseInt(node.metadata?.labels?.[matchExpression.key] || '', 10) >
|
||||
parseInt(matchExpression.values?.[0] || '', 10)) ||
|
||||
(matchExpression.operator === 'Lt' &&
|
||||
exists &&
|
||||
parseInt(node.metadata?.labels?.[matchExpression.key] || '', 10) <
|
||||
parseInt(matchExpression.values?.[0] || '', 10))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return the unmatched affinity
|
||||
return [
|
||||
{
|
||||
key: matchExpression.key,
|
||||
operator:
|
||||
matchExpression.operator as KubernetesPodNodeAffinityNodeSelectorRequirementOperators,
|
||||
values: matchExpression.values?.join(', ') || '',
|
||||
},
|
||||
];
|
||||
}) || []
|
||||
) || [];
|
||||
return unmatchedRequiredNodeAffinities;
|
||||
}
|
||||
|
||||
// Node requirement depending on the operator value
|
||||
// https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#node-affinity
|
||||
function computeAffinities(
|
||||
@@ -173,76 +235,32 @@ function computeAffinities(
|
||||
(node, nodeIndex) => {
|
||||
let { acceptsApplication } = nodePlacements[nodeIndex];
|
||||
|
||||
if (pod.spec?.nodeSelector) {
|
||||
const patch = JsonPatch.compare(
|
||||
node.metadata?.labels || {},
|
||||
pod.spec.nodeSelector
|
||||
);
|
||||
_.remove(patch, { op: 'remove' });
|
||||
const unmatchedNodeSelectorLabels = patch.map((operation) => ({
|
||||
key: _.trimStart(operation.path, '/'),
|
||||
value: operation.op,
|
||||
}));
|
||||
if (unmatchedNodeSelectorLabels.length) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
// check node selectors for unmatched labels
|
||||
const unmatchedNodeSelectorLabels = getUnmatchedNodeSelectorLabels(
|
||||
node,
|
||||
pod
|
||||
);
|
||||
|
||||
// check node affinities that are required during scheduling
|
||||
const unmatchedRequiredNodeAffinities =
|
||||
getUnmatchedRequiredNodeAffinities(node, pod);
|
||||
|
||||
// If there are any unmatched affinities or node labels, the node does not accept the application
|
||||
if (
|
||||
unmatchedRequiredNodeAffinities.length ||
|
||||
unmatchedNodeSelectorLabels.length
|
||||
) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
|
||||
const basicNodeAffinity =
|
||||
pod.spec?.affinity?.nodeAffinity
|
||||
?.requiredDuringSchedulingIgnoredDuringExecution;
|
||||
if (basicNodeAffinity) {
|
||||
const unmatchedTerms = basicNodeAffinity.nodeSelectorTerms.map(
|
||||
(selectorTerm) => {
|
||||
const unmatchedExpressions = selectorTerm.matchExpressions?.flatMap(
|
||||
(matchExpression) => {
|
||||
const exists = {}.hasOwnProperty.call(
|
||||
node.metadata?.labels,
|
||||
matchExpression.key
|
||||
);
|
||||
const isIn =
|
||||
exists &&
|
||||
_.includes(
|
||||
matchExpression.values,
|
||||
node.metadata?.labels?.[matchExpression.key]
|
||||
);
|
||||
if (
|
||||
(matchExpression.operator === 'Exists' && exists) ||
|
||||
(matchExpression.operator === 'DoesNotExist' && !exists) ||
|
||||
(matchExpression.operator === 'In' && isIn) ||
|
||||
(matchExpression.operator === 'NotIn' && !isIn) ||
|
||||
(matchExpression.operator === 'Gt' &&
|
||||
exists &&
|
||||
parseInt(
|
||||
node.metadata?.labels?.[matchExpression.key] || '',
|
||||
10
|
||||
) > parseInt(matchExpression.values?.[0] || '', 10)) ||
|
||||
(matchExpression.operator === 'Lt' &&
|
||||
exists &&
|
||||
parseInt(
|
||||
node.metadata?.labels?.[matchExpression.key] || '',
|
||||
10
|
||||
) < parseInt(matchExpression.values?.[0] || '', 10))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [true];
|
||||
}
|
||||
);
|
||||
|
||||
return unmatchedExpressions;
|
||||
}
|
||||
);
|
||||
|
||||
_.remove(unmatchedTerms, (i) => !i);
|
||||
if (unmatchedTerms.length) {
|
||||
acceptsApplication = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
const nodePlacementRowData: NodePlacementRowData = {
|
||||
...nodePlacements[nodeIndex],
|
||||
acceptsApplication,
|
||||
unmatchedNodeSelectorLabels,
|
||||
unmatchedNodeAffinities: unmatchedRequiredNodeAffinities,
|
||||
};
|
||||
|
||||
return nodePlacementRowData;
|
||||
}
|
||||
);
|
||||
return nodePlacementsFromAffinities;
|
||||
|
||||
+2
-4
@@ -77,14 +77,12 @@ export function ApplicationsStacksDatatable({
|
||||
namespaces={namespaces}
|
||||
value={namespace}
|
||||
onChange={onNamespaceChange}
|
||||
showSystem={tableState.showSystemResources}
|
||||
showSystem={showSystem}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<SystemResourceDescription
|
||||
showSystemResources={tableState.showSystemResources}
|
||||
/>
|
||||
<SystemResourceDescription showSystemResources={showSystem} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+20
-13
@@ -7,13 +7,17 @@ import { InputGroup } from '@@/form-components/InputGroup';
|
||||
|
||||
import { Namespace } from './types';
|
||||
|
||||
function transformNamespaces(namespaces: Namespace[], showSystem: boolean) {
|
||||
return namespaces
|
||||
.filter((ns) => showSystem || !ns.IsSystem)
|
||||
.map(({ Name, IsSystem }) => ({
|
||||
label: IsSystem ? `${Name} - system` : Name,
|
||||
value: Name,
|
||||
}));
|
||||
function transformNamespaces(namespaces: Namespace[], showSystem?: boolean) {
|
||||
const transformedNamespaces = namespaces.map(({ Name, IsSystem }) => ({
|
||||
label: IsSystem ? `${Name} - system` : Name,
|
||||
value: Name,
|
||||
isSystem: IsSystem,
|
||||
}));
|
||||
if (showSystem === undefined) {
|
||||
return transformedNamespaces;
|
||||
}
|
||||
// only filter when showSystem is set
|
||||
return transformedNamespaces.filter((ns) => showSystem || !ns.isSystem);
|
||||
}
|
||||
|
||||
export function NamespaceFilter({
|
||||
@@ -25,19 +29,22 @@ export function NamespaceFilter({
|
||||
namespaces: Namespace[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
showSystem: boolean;
|
||||
showSystem?: boolean;
|
||||
}) {
|
||||
const transformedNamespaces = transformNamespaces(namespaces, showSystem);
|
||||
|
||||
// sync value with displayed namespaces
|
||||
useEffect(() => {
|
||||
const names = transformedNamespaces.map((ns) => ns.value);
|
||||
if (value && !names.find((ns) => ns === value)) {
|
||||
onChange(
|
||||
names.length > 0 ? names.find((ns) => ns === 'default') || names[0] : ''
|
||||
);
|
||||
const isSelectedNamespaceFound = names.some((ns) => ns === value);
|
||||
if (value && !isSelectedNamespaceFound) {
|
||||
const newNamespaceValue =
|
||||
names.length > 0
|
||||
? names.find((ns) => ns === 'default') || names[0]
|
||||
: '';
|
||||
onChange(newNamespaceValue);
|
||||
}
|
||||
}, [value, onChange, transformedNamespaces]);
|
||||
}, [value, onChange, transformedNamespaces, showSystem]);
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
|
||||
@@ -34,7 +34,8 @@ export function DashboardView() {
|
||||
);
|
||||
const { data: services, ...servicesQuery } = useServicesForCluster(
|
||||
environmentId,
|
||||
namespaceNames
|
||||
namespaceNames,
|
||||
{ lookupApplications: false }
|
||||
);
|
||||
const { data: ingresses, ...ingressesQuery } = useIngresses(
|
||||
environmentId,
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Authorized } from '@/react/hooks/useUser';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
interface Props {
|
||||
showSystemResources: boolean;
|
||||
showSystemResources?: boolean;
|
||||
}
|
||||
|
||||
export function SystemResourceDescription({ showSystemResources }: Props) {
|
||||
return !showSystemResources ? (
|
||||
return showSystemResources === false ? (
|
||||
<Authorized authorizations="K8sAccessSystemNamespaces" adminOnlyCE>
|
||||
<TextTip color="blue" className="!mb-0">
|
||||
System resources are hidden, this can be changed in the table settings
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user