Compare commits
56 Commits
feat/EE-82
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb5d31f7e | ||
|
|
9c616ffb07 | ||
|
|
3254051647 | ||
|
|
a0b52fc3d7 | ||
|
|
31fdef1e60 | ||
|
|
be30e1c453 | ||
|
|
5b55b890e7 | ||
|
|
a5eac07b0c | ||
|
|
fa80a7b7e5 | ||
|
|
278667825a | ||
|
|
65ded647b6 | ||
|
|
084cdcd8dc | ||
|
|
5b68c4365e | ||
|
|
9cd64664cc | ||
|
|
e831fa4a03 | ||
|
|
2a3c807978 | ||
|
|
a8265a44d0 | ||
|
|
71ad21598b | ||
|
|
1ddf76dbda | ||
|
|
a13ad8927f | ||
|
|
8e3751d0b7 | ||
|
|
89f53458c6 | ||
|
|
5466e68f50 | ||
|
|
60ef6d0270 | ||
|
|
caa6c15032 | ||
|
|
6b759438b8 | ||
|
|
2170ad49ef | ||
|
|
6a88c2ae36 | ||
|
|
7f96220a09 | ||
|
|
0b93714de4 | ||
|
|
296ecc5960 | ||
|
|
d7bc4f9b96 | ||
|
|
a5e8cf62d2 | ||
|
|
6e9f472723 | ||
|
|
49bd139466 | ||
|
|
dc180d85c5 | ||
|
|
45ceece1a9 | ||
|
|
0b85684168 | ||
|
|
f674573cdf | ||
|
|
14ac005627 | ||
|
|
26ead28d7b | ||
|
|
eae2f5c9fc | ||
|
|
1f2a90a722 | ||
|
|
267968e099 | ||
|
|
defd929366 | ||
|
|
2fb17c9cf9 | ||
|
|
c8d78ad15f | ||
|
|
96a6129d8a | ||
|
|
b8660ed2a0 | ||
|
|
9ec1f2ed6d | ||
|
|
8bfa5132cd | ||
|
|
cafcebe27e | ||
|
|
1d46f2bb35 | ||
|
|
872a8262f1 | ||
|
|
c339afb562 | ||
|
|
78661b50ca |
73
api/bolt/bolttest/datastore.go
Normal file
73
api/bolt/bolttest/datastore.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package bolttest
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
var errTempDir = errors.New("can't create a temp dir")
|
||||
|
||||
func MustNewTestStore(init bool) (*bolt.Store, func()) {
|
||||
store, teardown, err := NewTestStore(init)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errTempDir) {
|
||||
teardown()
|
||||
}
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return store, teardown
|
||||
}
|
||||
|
||||
func NewTestStore(init bool) (*bolt.Store, func(), error) {
|
||||
// Creates unique temp directory in a concurrency friendly manner.
|
||||
dataStorePath, err := ioutil.TempDir("", "boltdb")
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errTempDir, err.Error())
|
||||
}
|
||||
|
||||
fileService, err := filesystem.NewService(dataStorePath, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
store, err := bolt.NewStore(dataStorePath, fileService)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = store.Open()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if init {
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
teardown := func() {
|
||||
teardown(store, dataStorePath)
|
||||
}
|
||||
|
||||
return store, teardown, nil
|
||||
}
|
||||
|
||||
func teardown(store *bolt.Store, dataStorePath string) {
|
||||
err := store.Close()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(dataStorePath)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
18
api/bolt/migrator/migrate_dbversion30.go
Normal file
18
api/bolt/migrator/migrate_dbversion30.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package migrator
|
||||
|
||||
func (m *Migrator) migrateDBVersionTo30() error {
|
||||
if err := m.migrateSettings(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateSettings() error {
|
||||
legacySettings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacySettings.OAuthSettings.SSO = false
|
||||
legacySettings.OAuthSettings.LogoutURI = ""
|
||||
return m.settingsService.UpdateSettings(legacySettings)
|
||||
}
|
||||
95
api/bolt/migrator/migrate_dbversion30_test.go
Normal file
95
api/bolt/migrator/migrate_dbversion30_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
"github.com/portainer/portainer/api/bolt/settings"
|
||||
)
|
||||
|
||||
var (
|
||||
testingDBStorePath string
|
||||
testingDBFileName string
|
||||
dummyLogoURL string
|
||||
dbConn *bolt.DB
|
||||
settingsService *settings.Service
|
||||
)
|
||||
|
||||
// initTestingDBConn creates a raw bolt DB connection
|
||||
// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg
|
||||
func initTestingDBConn(storePath, fileName string) (*bolt.DB, error) {
|
||||
databasePath := path.Join(storePath, fileName)
|
||||
dbConn, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbConn, nil
|
||||
}
|
||||
|
||||
// initTestingDBConn creates a settings service with raw bolt DB connection
|
||||
// for unit testing usage only since using NewStore will cause cycle import inside migrator pkg
|
||||
func initTestingSettingsService(dbConn *bolt.DB, preSetObj map[string]interface{}) (*settings.Service, error) {
|
||||
internalDBConn := &internal.DbConnection{
|
||||
DB: dbConn,
|
||||
}
|
||||
settingsService, err := settings.NewService(internalDBConn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//insert a obj
|
||||
if err := internal.UpdateObject(internalDBConn, "settings", []byte("SETTINGS"), preSetObj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settingsService, nil
|
||||
}
|
||||
|
||||
func setup() error {
|
||||
testingDBStorePath, _ = os.Getwd()
|
||||
testingDBFileName = "portainer-ee-mig-30.db"
|
||||
dummyLogoURL = "example.com"
|
||||
var err error
|
||||
dbConn, err = initTestingDBConn(testingDBStorePath, testingDBFileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dummySettingsObj := map[string]interface{}{
|
||||
"LogoURL": dummyLogoURL,
|
||||
}
|
||||
settingsService, err = initTestingSettingsService(dbConn, dummySettingsObj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMigrateSettings(t *testing.T) {
|
||||
if err := setup(); err != nil {
|
||||
t.Errorf("failed to complete testing setups, err: %v", err)
|
||||
}
|
||||
defer dbConn.Close()
|
||||
defer os.Remove(testingDBFileName)
|
||||
m := &Migrator{
|
||||
db: dbConn,
|
||||
settingsService: settingsService,
|
||||
}
|
||||
if err := m.migrateSettings(); err != nil {
|
||||
t.Errorf("failed to update settings: %v", err)
|
||||
}
|
||||
updatedSettings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
t.Errorf("failed to retrieve the updated settings: %v", err)
|
||||
}
|
||||
if updatedSettings.LogoURL != dummyLogoURL {
|
||||
t.Errorf("unexpected value changes in the updated settings, want LogoURL value: %s, got LogoURL value: %s", dummyLogoURL, updatedSettings.LogoURL)
|
||||
}
|
||||
if updatedSettings.OAuthSettings.SSO != false {
|
||||
t.Errorf("unexpected default OAuth SSO setting, want: false, got: %t", updatedSettings.OAuthSettings.SSO)
|
||||
}
|
||||
if updatedSettings.OAuthSettings.LogoutURI != "" {
|
||||
t.Errorf("unexpected default OAuth HideInternalAuth setting, want:, got: %s", updatedSettings.OAuthSettings.LogoutURI)
|
||||
}
|
||||
}
|
||||
@@ -358,5 +358,13 @@ func (m *Migrator) Migrate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.6.0
|
||||
if m.currentDBVersion < 30 {
|
||||
err := m.migrateDBVersionTo30()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
@@ -88,8 +89,8 @@ func initSwarmStackManager(assetsPath string, dataStorePath string, signatureSer
|
||||
return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService)
|
||||
}
|
||||
|
||||
func initKubernetesDeployer(assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(assetsPath)
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, assetsPath)
|
||||
}
|
||||
|
||||
func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
|
||||
@@ -165,6 +166,7 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI
|
||||
settings.SnapshotInterval = *flags.SnapshotInterval
|
||||
settings.EnableEdgeComputeFeatures = *flags.EnableEdgeComputeFeatures
|
||||
settings.EnableTelemetry = true
|
||||
settings.OAuthSettings.SSO = true
|
||||
|
||||
if *flags.Templates != "" {
|
||||
settings.TemplatesURL = *flags.Templates
|
||||
@@ -240,6 +242,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore portainer.Dat
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
@@ -301,6 +304,7 @@ func createUnsecuredEndpoint(endpointURL string, dataStore portainer.DataStore,
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
@@ -386,6 +390,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
snapshotService.Start()
|
||||
|
||||
authorizationService := authorization.NewService(dataStore)
|
||||
authorizationService.K8sClientFactory = kubernetesClientFactory
|
||||
|
||||
swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed initializing swarm stack manager: %v", err)
|
||||
@@ -395,7 +402,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(*flags.Assets)
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
|
||||
|
||||
if dataStore.IsNew() {
|
||||
err = updateSettingsFromFlags(dataStore, flags)
|
||||
@@ -458,6 +465,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
AuthorizationService: authorizationService,
|
||||
ReverseTunnelService: reverseTunnelService,
|
||||
Status: applicationStatus,
|
||||
BindAddress: *flags.Addr,
|
||||
|
||||
@@ -2,71 +2,234 @@ package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
)
|
||||
|
||||
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment.
|
||||
type KubernetesDeployer struct {
|
||||
binaryPath string
|
||||
binaryPath string
|
||||
dataStore portainer.DataStore
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
signatureService portainer.DigitalSignatureService
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
}
|
||||
|
||||
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
|
||||
func NewKubernetesDeployer(binaryPath string) *KubernetesDeployer {
|
||||
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
|
||||
return &KubernetesDeployer{
|
||||
binaryPath: binaryPath,
|
||||
binaryPath: binaryPath,
|
||||
dataStore: datastore,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
signatureService: signatureService,
|
||||
kubernetesClientFactory: kubernetesClientFactory,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
kubecli, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tokenCache := deployer.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID))
|
||||
|
||||
tokenManager, err := kubernetes.NewTokenManager(kubecli, deployer.dataStore, tokenCache, setLocalAdminToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return tokenManager.GetAdminServiceAccountToken(), nil
|
||||
}
|
||||
|
||||
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("can not get a valid user service account token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint.
|
||||
// If composeFormat is set to true, it will leverage the kompose binary to deploy a compose compliant manifest.
|
||||
// Otherwise it will use kubectl to deploy the manifest.
|
||||
func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) {
|
||||
if composeFormat {
|
||||
convertedData, err := deployer.convertComposeData(data)
|
||||
func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment {
|
||||
token, err := deployer.getToken(request, endpoint, true);
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
data = string(convertedData)
|
||||
|
||||
command := path.Join(deployer.binaryPath, "kubectl")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kubectl.exe")
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--server", endpoint.URL)
|
||||
args = append(args, "--insecure-skip-tls-verify")
|
||||
args = append(args, "--token", token)
|
||||
args = append(args, "--namespace", namespace)
|
||||
args = append(args, "apply", "-f", "-")
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = strings.NewReader(stackConfig)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", errors.New(stderr.String())
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
// agent
|
||||
|
||||
endpointURL := endpoint.URL
|
||||
if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
tunnel := deployer.reverseTunnelService.GetTunnelDetails(endpoint.ID)
|
||||
if tunnel.Status == portainer.EdgeAgentIdle {
|
||||
|
||||
err := deployer.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
settings, err := deployer.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
waitForAgentToConnect := time.Duration(settings.EdgeAgentCheckinInterval) * time.Second
|
||||
time.Sleep(waitForAgentToConnect * 2)
|
||||
}
|
||||
|
||||
endpointURL = fmt.Sprintf("http://127.0.0.1:%d", tunnel.Port)
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
transport.TLSClientConfig = tlsConfig
|
||||
}
|
||||
|
||||
httpCli := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(endpointURL, "http") {
|
||||
endpointURL = fmt.Sprintf("https://%s", endpointURL)
|
||||
}
|
||||
|
||||
url, err := url.Parse(fmt.Sprintf("%s/v2/kubernetes/stack", endpointURL))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
command := path.Join(deployer.binaryPath, "kubectl")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kubectl.exe")
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--server", endpoint.URL)
|
||||
args = append(args, "--insecure-skip-tls-verify")
|
||||
args = append(args, "--token", string(token))
|
||||
args = append(args, "--namespace", namespace)
|
||||
args = append(args, "apply", "-f", "-")
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = strings.NewReader(data)
|
||||
|
||||
output, err := cmd.Output()
|
||||
reqPayload, err := json.Marshal(
|
||||
struct {
|
||||
StackConfig string
|
||||
Namespace string
|
||||
}{
|
||||
StackConfig: stackConfig,
|
||||
Namespace: namespace,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.New(stderr.String())
|
||||
return "", err
|
||||
}
|
||||
|
||||
return output, nil
|
||||
req, err := http.NewRequest(http.MethodPost, url.String(), bytes.NewReader(reqPayload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
signature, err := deployer.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := deployer.getToken(request, endpoint, false);
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey())
|
||||
req.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
req.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
resp, err := httpCli.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errorResponseData struct {
|
||||
Message string
|
||||
Details string
|
||||
}
|
||||
err = json.NewDecoder(resp.Body).Decode(&errorResponseData)
|
||||
if err != nil {
|
||||
output, parseStringErr := ioutil.ReadAll(resp.Body)
|
||||
if parseStringErr != nil {
|
||||
return "", parseStringErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Failed parsing, body: %s, error: %w", output, err)
|
||||
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Deployment to agent failed: %s", errorResponseData.Details)
|
||||
}
|
||||
|
||||
var responseData struct{ Output string }
|
||||
err = json.NewDecoder(resp.Body).Decode(&responseData)
|
||||
if err != nil {
|
||||
parsedOutput, parseStringErr := ioutil.ReadAll(resp.Body)
|
||||
if parseStringErr != nil {
|
||||
return "", parseStringErr
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("Failed decoding, body: %s, err: %w", parsedOutput, err)
|
||||
}
|
||||
|
||||
return responseData.Output, nil
|
||||
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) convertComposeData(data string) ([]byte, error) {
|
||||
// ConvertCompose leverages the kompose binary to deploy a compose compliant manifest.
|
||||
func (deployer *KubernetesDeployer) ConvertCompose(data string) ([]byte, error) {
|
||||
command := path.Join(deployer.binaryPath, "kompose")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kompose.exe")
|
||||
|
||||
@@ -31,6 +31,8 @@ const (
|
||||
ComposeStorePath = "compose"
|
||||
// ComposeFileDefaultName represents the default name of a compose file.
|
||||
ComposeFileDefaultName = "docker-compose.yml"
|
||||
// ManifestFileDefaultName represents the default name of a k8s manifest file.
|
||||
ManifestFileDefaultName = "k8s-deployment.yml"
|
||||
// EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder.
|
||||
EdgeStackStorePath = "edge_stacks"
|
||||
// PrivateKeyFile represents the name on disk of the file containing the private key.
|
||||
@@ -279,13 +281,7 @@ func (service *Service) WriteJSONToFile(path string, content interface{}) error
|
||||
|
||||
// FileExists checks for the existence of the specified file.
|
||||
func (service *Service) FileExists(filePath string) (bool, error) {
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
return FileExists(filePath)
|
||||
}
|
||||
|
||||
// KeyPairFilesExist checks for the existence of the key files.
|
||||
@@ -510,3 +506,31 @@ func (service *Service) GetTemporaryPath() (string, error) {
|
||||
func (service *Service) GetDatastorePath() string {
|
||||
return service.dataStorePath
|
||||
}
|
||||
|
||||
// FileExists checks for the existence of the specified file.
|
||||
func FileExists(filePath string) (bool, error) {
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func MoveDirectory(originalPath, newPath string) error {
|
||||
if _, err := os.Stat(originalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alreadyExists, err := FileExists(newPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if alreadyExists {
|
||||
return errors.New("Target path already exists")
|
||||
}
|
||||
|
||||
return os.Rename(originalPath, newPath)
|
||||
}
|
||||
|
||||
55
api/filesystem/filesystem_fileexists_test.go
Normal file
55
api/filesystem/filesystem_fileexists_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
|
||||
service := createService(t)
|
||||
testHelperFileExists_fileExists(t, service.FileExists)
|
||||
}
|
||||
|
||||
func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
|
||||
service := createService(t)
|
||||
testHelperFileExists_fileNotExists(t, service.FileExists)
|
||||
}
|
||||
|
||||
func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
|
||||
testHelperFileExists_fileExists(t, FileExists)
|
||||
}
|
||||
|
||||
func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
|
||||
testHelperFileExists_fileNotExists(t, FileExists)
|
||||
}
|
||||
|
||||
func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bool, error)) {
|
||||
file, err := os.CreateTemp("", t.Name())
|
||||
assert.NoError(t, err, "CreateTemp should not fail")
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(file.Name())
|
||||
})
|
||||
|
||||
exists, err := checker(file.Name())
|
||||
assert.NoError(t, err, "FileExists should not fail")
|
||||
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) {
|
||||
filePath := path.Join(os.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
|
||||
|
||||
err := os.RemoveAll(filePath)
|
||||
assert.NoError(t, err, "RemoveAll should not fail")
|
||||
|
||||
exists, err := checker(filePath)
|
||||
assert.NoError(t, err, "FileExists should not fail")
|
||||
|
||||
assert.False(t, exists)
|
||||
}
|
||||
49
api/filesystem/filesystem_move_test.go
Normal file
49
api/filesystem/filesystem_move_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// temporary function until upgrade to 1.16
|
||||
func tempDir(t *testing.T) string {
|
||||
tmpDir, err := os.MkdirTemp("", "dir")
|
||||
assert.NoError(t, err, "MkdirTemp should not fail")
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
func Test_movePath_shouldFailIfOriginalPathDoesntExist(t *testing.T) {
|
||||
tmpDir := tempDir(t)
|
||||
missingPath := path.Join(tmpDir, "missing")
|
||||
targetPath := path.Join(tmpDir, "target")
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
err := MoveDirectory(missingPath, targetPath)
|
||||
assert.Error(t, err, "move directory should fail when target path exists")
|
||||
}
|
||||
|
||||
func Test_movePath_shouldFailIfTargetPathDoesExist(t *testing.T) {
|
||||
originalPath := tempDir(t)
|
||||
missingPath := tempDir(t)
|
||||
|
||||
defer os.RemoveAll(originalPath)
|
||||
defer os.RemoveAll(missingPath)
|
||||
|
||||
err := MoveDirectory(originalPath, missingPath)
|
||||
assert.Error(t, err, "move directory should fail when target path exists")
|
||||
}
|
||||
|
||||
func Test_movePath_success(t *testing.T) {
|
||||
originalPath := tempDir(t)
|
||||
|
||||
defer os.RemoveAll(originalPath)
|
||||
|
||||
err := MoveDirectory(originalPath, fmt.Sprintf("%s-old", originalPath))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
22
api/filesystem/filesystem_test.go
Normal file
22
api/filesystem/filesystem_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func createService(t *testing.T) *Service {
|
||||
dataStorePath := path.Join(os.TempDir(), t.Name())
|
||||
|
||||
service, err := NewService(dataStorePath, "")
|
||||
assert.NoError(t, err, "NewService should not fail")
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(dataStorePath)
|
||||
})
|
||||
|
||||
return service
|
||||
}
|
||||
@@ -2,12 +2,13 @@ package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||
@@ -54,7 +55,7 @@ func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dst)
|
||||
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
|
||||
err = service.ClonePublicRepository(repositoryUrl, tt.args.referenceName, dst)
|
||||
err = service.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
})
|
||||
@@ -72,7 +73,7 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
|
||||
defer os.RemoveAll(dst)
|
||||
|
||||
repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration"
|
||||
err = service.ClonePrivateRepositoryWithBasicAuth(repositoryUrl, "refs/heads/main", dst, "", pat)
|
||||
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", "", pat)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ package git
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"github.com/pkg/errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
@@ -27,7 +28,7 @@ type downloader interface {
|
||||
download(ctx context.Context, dst string, opt cloneOptions) error
|
||||
}
|
||||
|
||||
type gitClient struct{
|
||||
type gitClient struct {
|
||||
preserveGitDirectory bool
|
||||
}
|
||||
|
||||
@@ -86,26 +87,18 @@ func NewService() *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// ClonePublicRepository clones a public git repository using the specified URL in the specified
|
||||
// CloneRepository clones a git repository using the specified URL in the specified
|
||||
// destination folder.
|
||||
func (service *Service) ClonePublicRepository(repositoryURL, referenceName, destination string) error {
|
||||
return service.cloneRepository(destination, cloneOptions{
|
||||
repositoryUrl: repositoryURL,
|
||||
referenceName: referenceName,
|
||||
depth: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
|
||||
// destination folder. It will use the specified Username and Password for basic HTTP authentication.
|
||||
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName, destination, username, password string) error {
|
||||
return service.cloneRepository(destination, cloneOptions{
|
||||
func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string) error {
|
||||
options := cloneOptions{
|
||||
repositoryUrl: repositoryURL,
|
||||
username: username,
|
||||
password: password,
|
||||
referenceName: referenceName,
|
||||
depth: 1,
|
||||
})
|
||||
}
|
||||
|
||||
return service.cloneRepository(destination, options)
|
||||
}
|
||||
|
||||
func (service *Service) cloneRepository(destination string, options cloneOptions) error {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
@@ -20,7 +21,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
defer os.RemoveAll(dst)
|
||||
|
||||
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
|
||||
err = service.ClonePrivateRepositoryWithBasicAuth(repositoryUrl, "refs/heads/main", dst, username, pat)
|
||||
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, pat)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func Test_ClonePublicRepository_Shallow(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err = service.ClonePublicRepository(repositoryURL, referenceName, dir)
|
||||
err = service.CloneRepository(dir, repositoryURL, referenceName, "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
@@ -75,9 +75,11 @@ func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create a temp dir")
|
||||
}
|
||||
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err = service.ClonePublicRepository(repositoryURL, referenceName, dir)
|
||||
err = service.CloneRepository(dir, repositoryURL, referenceName, "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package gittypes
|
||||
|
||||
// RepoConfig represents a configuration for a repo
|
||||
type RepoConfig struct {
|
||||
// The repo url
|
||||
URL string `example:"https://github.com/portainer/portainer-ee.git"`
|
||||
@@ -8,12 +7,4 @@ type RepoConfig struct {
|
||||
ReferenceName string `example:"refs/heads/branch_name"`
|
||||
// Path to where the config file is in this url/refName
|
||||
ConfigFilePath string `example:"docker-compose.yml"`
|
||||
// Git credentials
|
||||
Authentication *GitAuthentication
|
||||
ConfigHash []byte
|
||||
}
|
||||
|
||||
type GitAuthentication struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkg
|
||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
|
||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible h1:fUDGZCv/7iAN7u0puUVhvKCcsR6vRfwrJatElLBEf0I=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
@@ -153,6 +154,7 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
|
||||
@@ -219,8 +221,10 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420 h1:Yu3681ykYHDfLoI6XVjL4JWmkE+3TX9yfIWwRCh1kFM=
|
||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
|
||||
@@ -366,9 +370,11 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
@@ -395,6 +401,7 @@ k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUc
|
||||
k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
|
||||
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
|
||||
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a h1:UcxjrRMyNx/i/y8G7kPvLyy7rfbeuf1PYyBf973pgyU=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f h1:GiPwtSzdP43eI1hpPCbROQCCIgCuiMMNF8YUVLF3vJo=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
|
||||
|
||||
@@ -130,19 +130,13 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use
|
||||
}
|
||||
|
||||
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
|
||||
tokenData := &portainer.TokenData{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
|
||||
return handler.persistAndWriteToken(w, tokenData)
|
||||
return handler.persistAndWriteToken(w, composeTokenData(user))
|
||||
}
|
||||
|
||||
func (handler *Handler) persistAndWriteToken(w http.ResponseWriter, tokenData *portainer.TokenData) *httperror.HandlerError {
|
||||
token, err := handler.JWTService.GenerateToken(tokenData)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to generate JWT token", Err: err}
|
||||
}
|
||||
|
||||
return response.JSON(w, &authenticateResponse{JWT: token})
|
||||
@@ -204,3 +198,11 @@ func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamM
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func composeTokenData(user *portainer.User) *portainer.TokenData {
|
||||
return &portainer.TokenData{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,17 +25,6 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id AuthenticateOauth
|
||||
// @summary Authenticate with OAuth
|
||||
// @tags auth
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body oauthPayload true "OAuth Credentials used for authentication"
|
||||
// @success 200 {object} authenticateResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 422 "Invalid Credentials"
|
||||
// @failure 500 "Server error"
|
||||
// @router /auth/oauth/validate [post]
|
||||
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) {
|
||||
if code == "" {
|
||||
return "", errors.New("Invalid OAuth authorization code")
|
||||
@@ -53,35 +42,46 @@ func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuth
|
||||
return username, nil
|
||||
}
|
||||
|
||||
// @id ValidateOAuth
|
||||
// @summary Authenticate with OAuth
|
||||
// @tags auth
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body oauthPayload true "OAuth Credentials used for authentication"
|
||||
// @success 200 {object} authenticateResponse "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 422 "Invalid Credentials"
|
||||
// @failure 500 "Server error"
|
||||
// @router /auth/oauth/validate [post]
|
||||
func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload oauthPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve settings from the database", Err: err}
|
||||
}
|
||||
|
||||
if settings.AuthenticationMethod != 3 {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")}
|
||||
if settings.AuthenticationMethod != portainer.AuthenticationOAuth {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "OAuth authentication is not enabled", Err: errors.New("OAuth authentication is not enabled")}
|
||||
}
|
||||
|
||||
username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] - OAuth authentication error: %s", err)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", httperrors.ErrUnauthorized}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to authenticate through OAuth", Err: httperrors.ErrUnauthorized}
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().UserByUsername(username)
|
||||
if err != nil && err != bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a user with the specified username from the database", Err: err}
|
||||
}
|
||||
|
||||
if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", httperrors.ErrUnauthorized}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Account not created beforehand in Portainer and automatic user provisioning not enabled", Err: httperrors.ErrUnauthorized}
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
@@ -92,7 +92,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
|
||||
|
||||
err = handler.DataStore.User().CreateUser(user)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist user inside the database", Err: err}
|
||||
}
|
||||
|
||||
if settings.OAuthSettings.DefaultTeamID != 0 {
|
||||
@@ -104,7 +104,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h
|
||||
|
||||
err = handler.DataStore.TeamMembership().CreateTeamMembership(membership)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team membership inside the database", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist team membership inside the database", Err: err}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -236,16 +236,14 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
|
||||
projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID))
|
||||
customTemplate.ProjectPath = projectPath
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
repositoryUsername := payload.RepositoryUsername
|
||||
repositoryPassword := payload.RepositoryPassword
|
||||
if !payload.RepositoryAuthentication {
|
||||
repositoryUsername = ""
|
||||
repositoryPassword = ""
|
||||
}
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package customtemplates
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
if parameters.authentication {
|
||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||
}
|
||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||
}
|
||||
@@ -212,16 +212,14 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por
|
||||
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
repositoryUsername := payload.RepositoryUsername
|
||||
repositoryPassword := payload.RepositoryPassword
|
||||
if !payload.RepositoryAuthentication {
|
||||
repositoryUsername = ""
|
||||
repositoryPassword = ""
|
||||
}
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package edgestacks
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
if parameters.authentication {
|
||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||
}
|
||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||
}
|
||||
@@ -109,12 +109,33 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
updateAuthorizations := false
|
||||
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpointGroup.UserAccessPolicies) {
|
||||
endpointGroup.UserAccessPolicies = payload.UserAccessPolicies
|
||||
updateAuthorizations = true
|
||||
}
|
||||
|
||||
if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpointGroup.TeamAccessPolicies) {
|
||||
endpointGroup.TeamAccessPolicies = payload.TeamAccessPolicies
|
||||
updateAuthorizations = true
|
||||
}
|
||||
|
||||
if updateAuthorizations {
|
||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.GroupID == endpointGroup.ID {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
err = handler.AuthorizationService.CleanNAPWithOverridePolicies(&endpoint, endpointGroup)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.DataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package endpointgroups
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
// Handler is the HTTP handler used to handle endpoint group operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
AuthorizationService *authorization.Service
|
||||
DataStore portainer.DataStore
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||
// @accept multipart/form-data
|
||||
// @produce json
|
||||
// @param Name formData string true "Name that will be used to identify this endpoint (example: my-endpoint)"
|
||||
// @param EndpointType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5)
|
||||
// @param EndpointCreationType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5)
|
||||
// @param URL formData string false "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine)"
|
||||
// @param PublicURL formData string false "URL or IP address where exposed containers will be reachable. Defaults to URL if not specified (example: docker.mydomain.tld:2375)"
|
||||
// @param GroupID formData int false "Endpoint group identifier. If not specified will default to 1 (unassigned)."
|
||||
@@ -471,6 +471,7 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer.
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
|
||||
@@ -115,8 +115,18 @@ func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhub
|
||||
return nil, errors.New("failed fetching dockerhub limits")
|
||||
}
|
||||
|
||||
// An error with rateLimit-Limit or RateLimit-Remaining is likely for dockerhub pro accounts where there is no rate limit.
|
||||
// In that specific case the headers will not be present. Don't bubble up the error as its normal
|
||||
// See: https://docs.docker.com/docker-hub/download-rate-limit/
|
||||
rateLimit, err := parseRateLimitHeader(resp.Header, "RateLimit-Limit")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rateLimitRemaining, err := parseRateLimitHeader(resp.Header, "RateLimit-Remaining")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &dockerhubStatusResponse{
|
||||
Limit: rateLimit,
|
||||
|
||||
@@ -155,11 +155,14 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
endpoint.Kubernetes = *payload.Kubernetes
|
||||
}
|
||||
|
||||
updateAuthorizations := false
|
||||
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) {
|
||||
updateAuthorizations = true
|
||||
endpoint.UserAccessPolicies = payload.UserAccessPolicies
|
||||
}
|
||||
|
||||
if payload.TeamAccessPolicies != nil && !reflect.DeepEqual(payload.TeamAccessPolicies, endpoint.TeamAccessPolicies) {
|
||||
updateAuthorizations = true
|
||||
endpoint.TeamAccessPolicies = payload.TeamAccessPolicies
|
||||
}
|
||||
|
||||
@@ -252,6 +255,15 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
if updateAuthorizations {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
err = handler.AuthorizationService.CleanNAPWithOverridePolicies(endpoint, nil)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update user authorizations", err}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
|
||||
"net/http"
|
||||
|
||||
@@ -28,6 +29,7 @@ type Handler struct {
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
SnapshotService portainer.SnapshotService
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
AuthorizationService *authorization.Service
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage endpoint operations.
|
||||
|
||||
@@ -67,7 +67,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.1.1
|
||||
// @version 2.6.3
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ type publicSettingsResponse struct {
|
||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:"true"`
|
||||
// The URL used for oauth login
|
||||
OAuthLoginURI string `json:"OAuthLoginURI" example:"https://gitlab.com/oauth"`
|
||||
// The URL used for oauth logout
|
||||
OAuthLogoutURI string `json:"OAuthLogoutURI" example:"https://gitlab.com/oauth/logout"`
|
||||
// Whether telemetry is enabled
|
||||
EnableTelemetry bool `json:"EnableTelemetry" example:"true"`
|
||||
}
|
||||
@@ -34,20 +36,32 @@ type publicSettingsResponse struct {
|
||||
func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
|
||||
}
|
||||
|
||||
publicSettings := &publicSettingsResponse{
|
||||
LogoURL: settings.LogoURL,
|
||||
AuthenticationMethod: settings.AuthenticationMethod,
|
||||
EnableEdgeComputeFeatures: settings.EnableEdgeComputeFeatures,
|
||||
EnableTelemetry: settings.EnableTelemetry,
|
||||
OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login",
|
||||
settings.OAuthSettings.AuthorizationURI,
|
||||
settings.OAuthSettings.ClientID,
|
||||
settings.OAuthSettings.RedirectURI,
|
||||
settings.OAuthSettings.Scopes),
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve the settings from the database", Err: err}
|
||||
}
|
||||
|
||||
publicSettings := generatePublicSettings(settings)
|
||||
return response.JSON(w, publicSettings)
|
||||
}
|
||||
|
||||
func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResponse {
|
||||
publicSettings := &publicSettingsResponse{
|
||||
LogoURL: appSettings.LogoURL,
|
||||
AuthenticationMethod: appSettings.AuthenticationMethod,
|
||||
EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures,
|
||||
EnableTelemetry: appSettings.EnableTelemetry,
|
||||
}
|
||||
//if OAuth authentication is on, compose the related fields from application settings
|
||||
if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth {
|
||||
publicSettings.OAuthLogoutURI = appSettings.OAuthSettings.LogoutURI
|
||||
publicSettings.OAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
|
||||
appSettings.OAuthSettings.AuthorizationURI,
|
||||
appSettings.OAuthSettings.ClientID,
|
||||
appSettings.OAuthSettings.RedirectURI,
|
||||
appSettings.OAuthSettings.Scopes)
|
||||
//control prompt=login param according to the SSO setting
|
||||
if !appSettings.OAuthSettings.SSO {
|
||||
publicSettings.OAuthLoginURI += "&prompt=login"
|
||||
}
|
||||
}
|
||||
return publicSettings
|
||||
}
|
||||
|
||||
70
api/http/handler/settings/settings_public_test.go
Normal file
70
api/http/handler/settings/settings_public_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
const (
|
||||
dummyOAuthClientID = "1a2b3c4d"
|
||||
dummyOAuthScopes = "scopes"
|
||||
dummyOAuthAuthenticationURI = "example.com/auth"
|
||||
dummyOAuthRedirectURI = "example.com/redirect"
|
||||
dummyOAuthLogoutURI = "example.com/logout"
|
||||
)
|
||||
|
||||
var (
|
||||
dummyOAuthLoginURI string
|
||||
mockAppSettings *portainer.Settings
|
||||
)
|
||||
|
||||
func setup() {
|
||||
dummyOAuthLoginURI = fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s",
|
||||
dummyOAuthAuthenticationURI,
|
||||
dummyOAuthClientID,
|
||||
dummyOAuthRedirectURI,
|
||||
dummyOAuthScopes)
|
||||
mockAppSettings = &portainer.Settings{
|
||||
AuthenticationMethod: portainer.AuthenticationOAuth,
|
||||
OAuthSettings: portainer.OAuthSettings{
|
||||
AuthorizationURI: dummyOAuthAuthenticationURI,
|
||||
ClientID: dummyOAuthClientID,
|
||||
Scopes: dummyOAuthScopes,
|
||||
RedirectURI: dummyOAuthRedirectURI,
|
||||
LogoutURI: dummyOAuthLogoutURI,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePublicSettingsWithSSO(t *testing.T) {
|
||||
setup()
|
||||
mockAppSettings.OAuthSettings.SSO = true
|
||||
publicSettings := generatePublicSettings(mockAppSettings)
|
||||
if publicSettings.AuthenticationMethod != portainer.AuthenticationOAuth {
|
||||
t.Errorf("wrong AuthenticationMethod, want: %d, got: %d", portainer.AuthenticationOAuth, publicSettings.AuthenticationMethod)
|
||||
}
|
||||
if publicSettings.OAuthLoginURI != dummyOAuthLoginURI {
|
||||
t.Errorf("wrong OAuthLoginURI when SSO is switched on, want: %s, got: %s", dummyOAuthLoginURI, publicSettings.OAuthLoginURI)
|
||||
}
|
||||
if publicSettings.OAuthLogoutURI != dummyOAuthLogoutURI {
|
||||
t.Errorf("wrong OAuthLogoutURI, want: %s, got: %s", dummyOAuthLogoutURI, publicSettings.OAuthLogoutURI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePublicSettingsWithoutSSO(t *testing.T) {
|
||||
setup()
|
||||
mockAppSettings.OAuthSettings.SSO = false
|
||||
publicSettings := generatePublicSettings(mockAppSettings)
|
||||
if publicSettings.AuthenticationMethod != portainer.AuthenticationOAuth {
|
||||
t.Errorf("wrong AuthenticationMethod, want: %d, got: %d", portainer.AuthenticationOAuth, publicSettings.AuthenticationMethod)
|
||||
}
|
||||
expectedOAuthLoginURI := dummyOAuthLoginURI + "&prompt=login"
|
||||
if publicSettings.OAuthLoginURI != expectedOAuthLoginURI {
|
||||
t.Errorf("wrong OAuthLoginURI when SSO is switched off, want: %s, got: %s", expectedOAuthLoginURI, publicSettings.OAuthLoginURI)
|
||||
}
|
||||
if publicSettings.OAuthLogoutURI != dummyOAuthLogoutURI {
|
||||
t.Errorf("wrong OAuthLogoutURI, want: %s, got: %s", dummyOAuthLogoutURI, publicSettings.OAuthLogoutURI)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -113,9 +112,8 @@ type composeStackFromGitRepositoryPayload struct {
|
||||
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
RepositoryPassword string `example:"myGitPassword"`
|
||||
// Path to the Stack file inside the Git repository
|
||||
ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
AdditionalFiles []string
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
|
||||
// A list of environment variables used during stack deployment
|
||||
Env []portainer.Pair
|
||||
}
|
||||
@@ -128,11 +126,8 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
|
||||
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
return errors.New("Invalid RepositoryReferenceName")
|
||||
}
|
||||
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
|
||||
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -146,8 +141,8 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
}
|
||||
|
||||
payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name)
|
||||
if payload.ComposeFile == "" {
|
||||
payload.ComposeFile = filesystem.ComposeFileDefaultName
|
||||
if payload.ComposeFilePathInRepository == "" {
|
||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
|
||||
@@ -161,45 +156,23 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
|
||||
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackID),
|
||||
Name: payload.Name,
|
||||
Type: portainer.DockerComposeStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFile,
|
||||
AdditionalFiles: payload.AdditionalFiles,
|
||||
AutoUpdate: payload.AutoUpdate,
|
||||
Env: payload.Env,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: payload.RepositoryURL,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
},
|
||||
ID: portainer.StackID(stackID),
|
||||
Name: payload.Name,
|
||||
Type: portainer.DockerComposeStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFilePathInRepository,
|
||||
Env: payload.Env,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
}
|
||||
}
|
||||
|
||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
}
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
|
||||
}
|
||||
@@ -264,11 +237,11 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
|
||||
}
|
||||
if !isUnique {
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name)
|
||||
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: errorMessage, Err: errors.New(errorMessage)}
|
||||
}
|
||||
|
||||
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||
|
||||
@@ -2,7 +2,11 @@ package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
|
||||
@@ -10,15 +14,29 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
type kubernetesStackPayload struct {
|
||||
const defaultReferenceName = "refs/heads/master"
|
||||
|
||||
type kubernetesStringDeploymentPayload struct {
|
||||
ComposeFormat bool
|
||||
Namespace string
|
||||
StackFileContent string
|
||||
}
|
||||
|
||||
func (payload *kubernetesStackPayload) Validate(r *http.Request) error {
|
||||
type kubernetesGitDeploymentPayload struct {
|
||||
ComposeFormat bool
|
||||
Namespace string
|
||||
RepositoryURL string
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
FilePathInRepository string
|
||||
}
|
||||
|
||||
func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.StackFileContent) {
|
||||
return errors.New("Invalid stack file content")
|
||||
}
|
||||
@@ -28,32 +46,146 @@ func (payload *kubernetesStackPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.Namespace) {
|
||||
return errors.New("Invalid namespace")
|
||||
}
|
||||
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
|
||||
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
}
|
||||
if govalidator.IsNull(payload.FilePathInRepository) {
|
||||
return errors.New("Invalid file path in repository")
|
||||
}
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
payload.RepositoryReferenceName = defaultReferenceName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type createKubernetesStackResponse struct {
|
||||
Output string `json:"Output"`
|
||||
}
|
||||
|
||||
func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
var payload kubernetesStackPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
var payload kubernetesStringDeploymentPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
output, err := handler.deployKubernetesStack(endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackID),
|
||||
Type: portainer.KubernetesStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: filesystem.ManifestFileDefaultName,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to deploy Kubernetes stack", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Stack().CreateStack(stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
resp := &createKubernetesStackResponse{
|
||||
Output: string(output),
|
||||
Output: output,
|
||||
}
|
||||
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) {
|
||||
func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
var payload kubernetesGitDeploymentPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackID),
|
||||
Type: portainer.KubernetesStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.FilePathInRepository,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
}
|
||||
|
||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err}
|
||||
}
|
||||
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Stack().CreateStack(stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
resp := &createKubernetesStackResponse{
|
||||
Output: output,
|
||||
}
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) {
|
||||
handler.stackCreationMutex.Lock()
|
||||
defer handler.stackCreationMutex.Unlock()
|
||||
|
||||
return handler.KubernetesDeployer.Deploy(endpoint, data, composeFormat, namespace)
|
||||
if composeFormat {
|
||||
convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(stackConfig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stackConfig = string(convertedConfig)
|
||||
}
|
||||
|
||||
return handler.KubernetesDeployer.Deploy(request, endpoint, stackConfig, namespace)
|
||||
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) {
|
||||
repositoryUsername := gitInfo.RepositoryUsername
|
||||
repositoryPassword := gitInfo.RepositoryPassword
|
||||
if !gitInfo.RepositoryAuthentication {
|
||||
repositoryUsername = ""
|
||||
repositoryPassword = ""
|
||||
}
|
||||
|
||||
err := handler.GitService.CloneRepository(projectPath, gitInfo.RepositoryURL, gitInfo.RepositoryReferenceName, repositoryUsername, repositoryPassword)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
content, err := ioutil.ReadFile(filepath.Join(projectPath, gitInfo.FilePathInRepository))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
64
api/http/handler/stacks/create_kubernetes_stack_test.go
Normal file
64
api/http/handler/stacks/create_kubernetes_stack_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type git struct {
|
||||
content string
|
||||
}
|
||||
|
||||
func (g *git) CloneRepository(destination string, repositoryURL, referenceName, username, password string) error {
|
||||
return g.ClonePublicRepository(repositoryURL, referenceName, destination)
|
||||
}
|
||||
func (g *git) ClonePublicRepository(repositoryURL string, referenceName string, destination string) error {
|
||||
return ioutil.WriteFile(path.Join(destination, "deployment.yml"), []byte(g.content), 0755)
|
||||
}
|
||||
func (g *git) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error {
|
||||
return g.ClonePublicRepository(repositoryURL, referenceName, destination)
|
||||
}
|
||||
|
||||
func TestCloneAndConvertGitRepoFile(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "kube-create-stack")
|
||||
assert.NoError(t, err, "failed to create a tmp dir")
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
content := `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
ports:
|
||||
- containerPort: 80`
|
||||
|
||||
h := &Handler{
|
||||
GitService: &git{
|
||||
content: content,
|
||||
},
|
||||
}
|
||||
gitInfo := &kubernetesGitDeploymentPayload{
|
||||
FilePathInRepository: "deployment.yml",
|
||||
}
|
||||
fileContent, err := h.cloneManifestContentFromGitRepo(gitInfo, dir)
|
||||
assert.NoError(t, err, "failed to clone or convert the file from Git repo")
|
||||
assert.Equal(t, content, fileContent)
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -120,9 +119,7 @@ type swarmStackFromGitRepositoryPayload struct {
|
||||
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
RepositoryPassword string `example:"myGitPassword"`
|
||||
// Path to the Stack file inside the Git repository
|
||||
ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
AdditionalFiles []string
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
}
|
||||
|
||||
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
||||
@@ -135,14 +132,11 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
|
||||
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
return errors.New("Invalid RepositoryReferenceName")
|
||||
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
|
||||
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
}
|
||||
if govalidator.IsNull(payload.ComposeFile) {
|
||||
payload.ComposeFile = filesystem.ComposeFileDefaultName
|
||||
if govalidator.IsNull(payload.ComposeFilePathInRepository) {
|
||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -165,48 +159,26 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||
|
||||
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackID),
|
||||
Name: payload.Name,
|
||||
Type: portainer.DockerSwarmStack,
|
||||
SwarmID: payload.SwarmID,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFile,
|
||||
AdditionalFiles: payload.AdditionalFiles,
|
||||
AutoUpdate: payload.AutoUpdate,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: payload.RepositoryURL,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
},
|
||||
ID: portainer.StackID(stackID),
|
||||
Name: payload.Name,
|
||||
Type: portainer.DockerSwarmStack,
|
||||
SwarmID: payload.SwarmID,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFilePathInRepository,
|
||||
Env: payload.Env,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
}
|
||||
}
|
||||
|
||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
}
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
|
||||
}
|
||||
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package stacks
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
if parameters.authentication {
|
||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||
}
|
||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||
}
|
||||
@@ -52,10 +52,12 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/stacks/{id}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/stacks/{id}/associate",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.stackAssociate))).Methods(http.MethodPut)
|
||||
h.Handle("/stacks/{id}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/stacks/{id}/git/config",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackGitConfigUpdate))).Methods(http.MethodPost)
|
||||
h.Handle("/stacks/{id}/git",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdateGit))).Methods(http.MethodPut)
|
||||
h.Handle("/stacks/{id}/file",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet)
|
||||
h.Handle("/stacks/{id}/migrate",
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func validateStackAutoUpdate(autoUpdate *portainer.StackAutoUpdate) error {
|
||||
if autoUpdate == nil {
|
||||
return nil
|
||||
}
|
||||
if autoUpdate.Interval == "" && autoUpdate.Webhook == "" {
|
||||
return errors.New("Both Interval and Webhook fields are empty")
|
||||
}
|
||||
if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) {
|
||||
return errors.New("Invalid Webhook format")
|
||||
}
|
||||
if autoUpdate.Interval != "" {
|
||||
if _, err := time.ParseDuration(autoUpdate.Interval); err != nil {
|
||||
return errors.New("Invalid Interval format")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_ValidateStackAutoUpdate_Valid(t *testing.T) {
|
||||
mock := &portainer.StackAutoUpdate{
|
||||
Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada",
|
||||
Interval: "5h30m40s10ms",
|
||||
}
|
||||
err := validateStackAutoUpdate(mock)
|
||||
assert.NoError(t, err)
|
||||
mock = nil
|
||||
err = validateStackAutoUpdate(mock)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_ValidateStackAutoUpdate_InValid(t *testing.T) {
|
||||
mock := &portainer.StackAutoUpdate{}
|
||||
err := validateStackAutoUpdate(mock)
|
||||
assert.Error(t, err)
|
||||
mock.Webhook = "fake-web-hook"
|
||||
err = validateStackAutoUpdate(mock)
|
||||
assert.Error(t, err)
|
||||
mock.Webhook = ""
|
||||
mock.Interval = "1dd2hh3mm"
|
||||
err = validateStackAutoUpdate(mock)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
91
api/http/handler/stacks/stack_associate.go
Normal file
91
api/http/handler/stacks/stack_associate.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PUT request on /api/stacks/:id/associate?endpointId=<endpointId>&swarmId=<swarmId>&orphanedRunning=<orphanedRunning>
|
||||
func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
|
||||
}
|
||||
|
||||
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
|
||||
}
|
||||
|
||||
swarmId, err := request.RetrieveQueryParameter(r, "swarmId", true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: swarmId", err}
|
||||
}
|
||||
|
||||
orphanedRunning, err := request.RetrieveBooleanQueryParameter(r, "orphanedRunning", false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: orphanedRunning", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
|
||||
}
|
||||
|
||||
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
|
||||
if resourceControl != nil {
|
||||
resourceControl.ResourceID = fmt.Sprintf("%d_%s", endpointID, stack.Name)
|
||||
|
||||
err = handler.DataStore.ResourceControl().UpdateResourceControl(resourceControl.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control changes inside the database", err}
|
||||
}
|
||||
}
|
||||
|
||||
stack.EndpointID = portainer.EndpointID(endpointID)
|
||||
stack.SwarmID = swarmId
|
||||
|
||||
if orphanedRunning {
|
||||
stack.Status = portainer.StackStatusActive
|
||||
} else {
|
||||
stack.Status = portainer.StackStatusInactive
|
||||
}
|
||||
|
||||
stack.CreationDate = time.Now().Unix()
|
||||
stack.CreatedBy = user.Username
|
||||
stack.UpdateDate = 0
|
||||
stack.UpdatedBy = ""
|
||||
|
||||
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
|
||||
}
|
||||
|
||||
stack.ResourceControl = resourceControl
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
@@ -12,9 +13,10 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
|
||||
@@ -76,7 +78,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
if !endpoint.SecuritySettings.AllowStackManagementForRegularUsers {
|
||||
if endpointutils.IsDockerEndpoint(endpoint) && !endpoint.SecuritySettings.AllowStackManagementForRegularUsers {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
|
||||
@@ -110,11 +112,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
|
||||
case portainer.DockerComposeStack:
|
||||
return handler.createComposeStack(w, r, method, endpoint, tokenData.ID)
|
||||
case portainer.KubernetesStack:
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized}
|
||||
}
|
||||
|
||||
return handler.createKubernetesStack(w, r, endpoint)
|
||||
return handler.createKubernetesStack(w, r, method, endpoint)
|
||||
}
|
||||
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)}
|
||||
@@ -147,6 +145,16 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request,
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)}
|
||||
}
|
||||
|
||||
func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
switch method {
|
||||
case "string":
|
||||
return handler.createKubernetesStackFromFileContent(w, r, endpoint)
|
||||
case "repository":
|
||||
return handler.createKubernetesStackFromGitRepository(w, r, endpoint)
|
||||
}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)}
|
||||
}
|
||||
|
||||
func (handler *Handler) isValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {
|
||||
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
|
||||
if err != nil {
|
||||
@@ -226,3 +234,22 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
|
||||
stack.ResourceControl = resourceControl
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneAndSaveConfig(stack *portainer.Stack, projectPath, repositoryURL, refName, configFilePath string, auth bool, username, password string) error {
|
||||
if !auth {
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
|
||||
err := handler.GitService.CloneRepository(projectPath, repositoryURL, refName, username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to clone git repository: %w", err)
|
||||
}
|
||||
|
||||
stack.GitConfig = &gittypes.RepoConfig{
|
||||
URL: repositoryURL,
|
||||
ReferenceName: refName,
|
||||
ConfigFilePath: configFilePath,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
29
api/http/handler/stacks/stack_create_test.go
Normal file
29
api/http/handler/stacks/stack_create_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_stackHandler_cloneAndSaveConfig_shouldCallGitCloneAndSaveConfigOnStack(t *testing.T) {
|
||||
handler := NewHandler(&security.RequestBouncer{})
|
||||
handler.GitService = testhelpers.NewGitService()
|
||||
|
||||
url := "url"
|
||||
refName := "ref"
|
||||
configPath := "path"
|
||||
stack := &portainer.Stack{}
|
||||
err := handler.cloneAndSaveConfig(stack, "", url, refName, configPath, false, "", "")
|
||||
assert.NoError(t, err, "clone and save should not fail")
|
||||
|
||||
assert.Equal(t, gittypes.RepoConfig{
|
||||
URL: url,
|
||||
ReferenceName: refName,
|
||||
ConfigFilePath: configPath,
|
||||
}, *stack.GitConfig)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id} [delete]
|
||||
func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
@@ -58,42 +58,42 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
|
||||
// The EndpointID property is not available for these stacks, this API endpoint
|
||||
// can use the optional EndpointID query parameter to set a valid endpoint identifier to be
|
||||
// used in the context of this request.
|
||||
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
|
||||
}
|
||||
endpointIdentifier := stack.EndpointID
|
||||
if endpointID != 0 {
|
||||
endpointIdentifier = portainer.EndpointID(endpointID)
|
||||
|
||||
isOrphaned := portainer.EndpointID(endpointID) != stack.EndpointID
|
||||
|
||||
if isOrphaned && !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to remove orphaned stack", errors.New("Permission denied to remove orphaned stack")}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointIdentifier)
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
if !isOrphaned {
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.deleteStack(stack, endpoint)
|
||||
|
||||
@@ -46,34 +46,38 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
|
||||
|
||||
if endpoint != nil {
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
|
||||
}
|
||||
}
|
||||
|
||||
stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api/http/errors"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -8,7 +9,6 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
@@ -40,38 +40,42 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
if endpoint != nil {
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err}
|
||||
}
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
if resourceControl != nil {
|
||||
stack.ResourceControl = resourceControl
|
||||
if resourceControl != nil {
|
||||
stack.ResourceControl = resourceControl
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -12,8 +13,9 @@ import (
|
||||
)
|
||||
|
||||
type stackListOperationFilters struct {
|
||||
SwarmID string `json:"SwarmID"`
|
||||
EndpointID int `json:"EndpointID"`
|
||||
SwarmID string `json:"SwarmID"`
|
||||
EndpointID int `json:"EndpointID"`
|
||||
IncludeOrphanedStacks bool `json:"IncludeOrphanedStacks"`
|
||||
}
|
||||
|
||||
// @id StackList
|
||||
@@ -37,11 +39,16 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err}
|
||||
}
|
||||
|
||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err}
|
||||
}
|
||||
|
||||
stacks, err := handler.DataStore.Stack().Stacks()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve stacks from the database", err}
|
||||
}
|
||||
stacks = filterStacks(stacks, &filters)
|
||||
stacks = filterStacks(stacks, &filters, endpoints)
|
||||
|
||||
resourceControls, err := handler.DataStore.ResourceControl().ResourceControls()
|
||||
if err != nil {
|
||||
@@ -56,6 +63,10 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
|
||||
stacks = authorization.DecorateStacks(stacks, resourceControls)
|
||||
|
||||
if !securityContext.IsAdmin {
|
||||
if filters.IncludeOrphanedStacks {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access orphaned stacks", httperrors.ErrUnauthorized}
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user information from the database", err}
|
||||
@@ -72,13 +83,20 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return response.JSON(w, stacks)
|
||||
}
|
||||
|
||||
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters) []portainer.Stack {
|
||||
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack {
|
||||
if filters.EndpointID == 0 && filters.SwarmID == "" {
|
||||
return stacks
|
||||
}
|
||||
|
||||
filteredStacks := make([]portainer.Stack, 0, len(stacks))
|
||||
for _, stack := range stacks {
|
||||
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
|
||||
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
}
|
||||
@@ -89,3 +107,13 @@ func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters)
|
||||
|
||||
return filteredStacks
|
||||
}
|
||||
|
||||
func isOrphanedStack(stack portainer.Stack, endpoints []portainer.Endpoint) bool {
|
||||
for _, endpoint := range endpoints {
|
||||
if stack.EndpointID == endpoint.ID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/start [post]
|
||||
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/stop [post]
|
||||
func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
@@ -191,6 +191,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
err = handler.deploySwarmStack(config)
|
||||
if err != nil {
|
||||
|
||||
196
api/http/handler/stacks/stack_update_git.go
Normal file
196
api/http/handler/stacks/stack_update_git.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
|
||||
type updateStackGitPayload struct {
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
}
|
||||
|
||||
func (payload *updateStackGitPayload) Validate(r *http.Request) error {
|
||||
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id StackUpdateGit
|
||||
// @summary Redeploy a stack
|
||||
// @description Pull and redeploy a stack via Git
|
||||
// @description **Access policy**: restricted
|
||||
// @tags stacks
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Stack identifier"
|
||||
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this optional parameter to set the endpoint identifier used by the stack."
|
||||
// @param body body updateStackGitPayload true "Git configs for pull and redeploy a stack"
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/git [put]
|
||||
func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
|
||||
}
|
||||
|
||||
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
if stack.GitConfig == nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Stack is not created from git", err}
|
||||
}
|
||||
|
||||
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
|
||||
// The EndpointID property is not available for these stacks, this API endpoint
|
||||
// can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack.
|
||||
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
|
||||
}
|
||||
if endpointID != int(stack.EndpointID) {
|
||||
stack.EndpointID = portainer.EndpointID(endpointID)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
var payload updateStackGitPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
|
||||
|
||||
backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath)
|
||||
err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to move git repository directory", err}
|
||||
}
|
||||
|
||||
repositoryUsername := payload.RepositoryUsername
|
||||
repositoryPassword := payload.RepositoryPassword
|
||||
if !payload.RepositoryAuthentication {
|
||||
repositoryUsername = ""
|
||||
repositoryPassword = ""
|
||||
}
|
||||
|
||||
err = handler.GitService.CloneRepository(stack.ProjectPath, stack.GitConfig.URL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
|
||||
if err != nil {
|
||||
restoreError := filesystem.MoveDirectory(backupProjectPath, stack.ProjectPath)
|
||||
if restoreError != nil {
|
||||
log.Printf("[WARN] [http,stacks,git] [error: %s] [message: failed restoring backup folder]", restoreError)
|
||||
}
|
||||
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = handler.FileService.RemoveDirectory(backupProjectPath)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] [http,stacks,git] [error: %s] [message: unable to remove git repository directory]", err)
|
||||
}
|
||||
}()
|
||||
|
||||
httpErr := handler.deployStack(r, stack, endpoint)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
if stack.Type == portainer.DockerSwarmStack {
|
||||
config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
err := handler.deploySwarmStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
}
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
err := handler.deployComposeStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
}
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
|
||||
type stackGitConfigUpdatePayload struct {
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
Env []portainer.Pair
|
||||
RepositoryReferenceName string
|
||||
}
|
||||
|
||||
func (payload *stackGitConfigUpdatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
return errors.New("Invalid RepositoryReferenceName")
|
||||
}
|
||||
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id Stacks
|
||||
// @summary Update and redeploy an existing stack (with Git config)
|
||||
// @description Update and redeploy an existing stack (with Git config)
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags stacks
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "Stack identifier"
|
||||
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this optional parameter to set the endpoint identifier used by the stack."
|
||||
// @param body body stackGitConfigUpdatePayload true "Stack Git config"
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/git [post]
|
||||
func (handler *Handler) stackGitConfigUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
var payload stackGitConfigUpdatePayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
|
||||
} else if stack.GitConfig == nil {
|
||||
msg := "No Git config in the found stack"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: msg, Err: errors.New(msg)}
|
||||
}
|
||||
|
||||
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
|
||||
// The EndpointID property is not available for these stacks, this API endpoint
|
||||
// can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack.
|
||||
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
|
||||
}
|
||||
if endpointID != int(stack.EndpointID) {
|
||||
stack.EndpointID = portainer.EndpointID(endpointID)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
//update retrieved stack data based on the payload
|
||||
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
|
||||
stack.AutoUpdate = payload.AutoUpdate
|
||||
stack.Env = payload.Env
|
||||
|
||||
//save the updated stack to DB
|
||||
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err}
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package templates
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
if parameters.authentication {
|
||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||
}
|
||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||
}
|
||||
@@ -63,12 +63,7 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
defer handler.cleanUp(projectPath)
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
path: projectPath,
|
||||
}
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
@@ -12,20 +13,22 @@ import (
|
||||
// Handler is the HTTP handler used to handle websocket operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore portainer.DataStore
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
requestBouncer *security.RequestBouncer
|
||||
connectionUpgrader websocket.Upgrader
|
||||
DataStore portainer.DataStore
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
requestBouncer *security.RequestBouncer
|
||||
connectionUpgrader websocket.Upgrader
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage websocket operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
func NewHandler(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
connectionUpgrader: websocket.Upgrader{},
|
||||
requestBouncer: bouncer,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
}
|
||||
h.PathPrefix("/websocket/exec").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec)))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
)
|
||||
|
||||
// @summary Execute a websocket on pod
|
||||
@@ -70,8 +73,14 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
token, useAdminToken, err := handler.getToken(r, endpoint, false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user service account token", err}
|
||||
}
|
||||
|
||||
params := &webSocketRequestParams{
|
||||
endpoint: endpoint,
|
||||
token: token,
|
||||
}
|
||||
|
||||
r.Header.Del("Origin")
|
||||
@@ -112,7 +121,7 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
|
||||
}
|
||||
|
||||
err = cli.StartExecProcess(namespace, podName, containerName, commandArray, stdinReader, stdoutWriter)
|
||||
err = cli.StartExecProcess(token, useAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err}
|
||||
}
|
||||
@@ -124,3 +133,37 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
kubecli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
tokenCache := handler.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID))
|
||||
|
||||
tokenManager, err := kubernetes.NewTokenManager(kubecli, handler.DataStore, tokenCache, setLocalAdminToken)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return tokenManager.GetAdminServiceAccountToken(), true, nil
|
||||
}
|
||||
|
||||
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", false, fmt.Errorf("can not get a valid user service account token")
|
||||
}
|
||||
|
||||
return token, false, nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r
|
||||
|
||||
proxy.Director = func(incoming *http.Request, out http.Header) {
|
||||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
|
||||
}
|
||||
|
||||
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
|
||||
@@ -64,6 +65,7 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
|
||||
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
|
||||
out.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
|
||||
@@ -8,4 +8,5 @@ type webSocketRequestParams struct {
|
||||
ID string
|
||||
nodeName string
|
||||
endpoint *portainer.Endpoint
|
||||
token string
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport, err := kubernetes.NewLocalTransport(tokenManager)
|
||||
transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.dataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp
|
||||
|
||||
endpointURL.Scheme = "http"
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.reverseTunnelService, endpoint.ID, tokenManager)
|
||||
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.reverseTunnelService, endpoint, tokenManager)
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En
|
||||
}
|
||||
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||
proxy.Transport = kubernetes.NewAgentTransport(factory.dataStore, factory.signatureService, tlsConfig, tokenManager)
|
||||
proxy.Transport = kubernetes.NewAgentTransport(factory.dataStore, factory.signatureService, tlsConfig, tokenManager, endpoint)
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
55
api/http/proxy/factory/kubernetes/agent_transport.go
Normal file
55
api/http/proxy/factory/kubernetes/agent_transport.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type agentTransport struct {
|
||||
*baseTransport
|
||||
signatureService portainer.DigitalSignatureService
|
||||
}
|
||||
|
||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
||||
func NewAgentTransport(dataStore portainer.DataStore, signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint) *agentTransport {
|
||||
transport := &agentTransport{
|
||||
signatureService: signatureService,
|
||||
baseTransport: newBaseTransport(
|
||||
&http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
tokenManager,
|
||||
endpoint,
|
||||
dataStore,
|
||||
),
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpoint.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/v2") {
|
||||
decorateAgentRequest(request, transport.dataStore)
|
||||
}
|
||||
|
||||
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
|
||||
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
|
||||
return transport.baseTransport.RoundTrip(request)
|
||||
}
|
||||
52
api/http/proxy/factory/kubernetes/edge_transport.go
Normal file
52
api/http/proxy/factory/kubernetes/edge_transport.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type edgeTransport struct {
|
||||
*baseTransport
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
}
|
||||
|
||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
|
||||
func NewEdgeTransport(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager) *edgeTransport {
|
||||
transport := &edgeTransport{
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
baseTransport: newBaseTransport(
|
||||
&http.Transport{},
|
||||
tokenManager,
|
||||
endpoint,
|
||||
dataStore,
|
||||
),
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpoint.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/v2") {
|
||||
decorateAgentRequest(request, transport.dataStore)
|
||||
}
|
||||
|
||||
response, err := transport.baseTransport.RoundTrip(request)
|
||||
|
||||
if err == nil {
|
||||
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
|
||||
} else {
|
||||
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
|
||||
}
|
||||
|
||||
return response, err
|
||||
}
|
||||
46
api/http/proxy/factory/kubernetes/local_transport.go
Normal file
46
api/http/proxy/factory/kubernetes/local_transport.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
)
|
||||
|
||||
type localTransport struct {
|
||||
*baseTransport
|
||||
}
|
||||
|
||||
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
|
||||
func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, dataStore portainer.DataStore) (*localTransport, error) {
|
||||
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport := &localTransport{
|
||||
baseTransport: newBaseTransport(
|
||||
&http.Transport{
|
||||
TLSClientConfig: config,
|
||||
},
|
||||
tokenManager,
|
||||
endpoint,
|
||||
dataStore,
|
||||
),
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpoint.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
return transport.baseTransport.RoundTrip(request)
|
||||
}
|
||||
15
api/http/proxy/factory/kubernetes/namespaces.go
Normal file
15
api/http/proxy/factory/kubernetes/namespaces.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func (transport *baseTransport) deleteNamespaceRequest(request *http.Request, namespace string) (*http.Response, error) {
|
||||
if err := transport.tokenManager.kubecli.NamespaceAccessPoliciesDeleteNamespace(namespace); err != nil {
|
||||
return nil, errors.WithMessagef(err, "failed to delete a namespace [%s] from portainer config", namespace)
|
||||
}
|
||||
|
||||
return transport.executeKubernetesRequest(request, true)
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
@@ -13,7 +11,6 @@ type tokenManager struct {
|
||||
tokenCache *tokenCache
|
||||
kubecli portainer.KubeClient
|
||||
dataStore portainer.DataStore
|
||||
mutex sync.Mutex
|
||||
adminToken string
|
||||
}
|
||||
|
||||
@@ -25,7 +22,6 @@ func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore
|
||||
tokenCache: cache,
|
||||
kubecli: kubecli,
|
||||
dataStore: dataStore,
|
||||
mutex: sync.Mutex{},
|
||||
adminToken: "",
|
||||
}
|
||||
|
||||
@@ -41,13 +37,13 @@ func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore
|
||||
return tokenManager, nil
|
||||
}
|
||||
|
||||
func (manager *tokenManager) getAdminServiceAccountToken() string {
|
||||
func (manager *tokenManager) GetAdminServiceAccountToken() string {
|
||||
return manager.adminToken
|
||||
}
|
||||
|
||||
func (manager *tokenManager) getUserServiceAccountToken(userID int) (string, error) {
|
||||
manager.mutex.Lock()
|
||||
defer manager.mutex.Unlock()
|
||||
func (manager *tokenManager) GetUserServiceAccountToken(userID int) (string, error) {
|
||||
manager.tokenCache.mutex.Lock()
|
||||
defer manager.tokenCache.mutex.Unlock()
|
||||
|
||||
token, ok := manager.tokenCache.getToken(userID)
|
||||
if !ok {
|
||||
|
||||
@@ -2,6 +2,7 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/orcaman/concurrent-map"
|
||||
)
|
||||
@@ -14,6 +15,7 @@ type (
|
||||
|
||||
tokenCache struct {
|
||||
userTokenCache cmap.ConcurrentMap
|
||||
mutex sync.Mutex
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,6 +37,18 @@ func (manager *TokenCacheManager) CreateTokenCache(endpointID int) *tokenCache {
|
||||
return tokenCache
|
||||
}
|
||||
|
||||
// GetOrCreateTokenCache will get the tokenCache from the manager map of caches if it exists,
|
||||
// otherwise it will create a new tokenCache object, associate it to the manager map of caches
|
||||
// and return a pointer to that tokenCache instance.
|
||||
func (manager *TokenCacheManager) GetOrCreateTokenCache(endpointID int) *tokenCache {
|
||||
key := strconv.Itoa(endpointID)
|
||||
if epCache, ok := manager.tokenCaches.Get(key); ok {
|
||||
return epCache.(*tokenCache)
|
||||
}
|
||||
|
||||
return manager.CreateTokenCache(endpointID)
|
||||
}
|
||||
|
||||
// RemoveUserFromCache will ensure that the specific userID is removed from all registered caches.
|
||||
func (manager *TokenCacheManager) RemoveUserFromCache(userID int) {
|
||||
for cache := range manager.tokenCaches.IterBuffered() {
|
||||
@@ -45,6 +59,7 @@ func (manager *TokenCacheManager) RemoveUserFromCache(userID int) {
|
||||
func newTokenCache() *tokenCache {
|
||||
return &tokenCache{
|
||||
userTokenCache: cmap.New(),
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,147 +2,73 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
)
|
||||
|
||||
type (
|
||||
localTransport struct {
|
||||
httpTransport *http.Transport
|
||||
tokenManager *tokenManager
|
||||
endpointIdentifier portainer.EndpointID
|
||||
}
|
||||
|
||||
agentTransport struct {
|
||||
dataStore portainer.DataStore
|
||||
httpTransport *http.Transport
|
||||
tokenManager *tokenManager
|
||||
signatureService portainer.DigitalSignatureService
|
||||
endpointIdentifier portainer.EndpointID
|
||||
}
|
||||
|
||||
edgeTransport struct {
|
||||
dataStore portainer.DataStore
|
||||
httpTransport *http.Transport
|
||||
tokenManager *tokenManager
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
endpointIdentifier portainer.EndpointID
|
||||
}
|
||||
)
|
||||
|
||||
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
|
||||
func NewLocalTransport(tokenManager *tokenManager) (*localTransport, error) {
|
||||
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport := &localTransport{
|
||||
httpTransport: &http.Transport{
|
||||
TLSClientConfig: config,
|
||||
},
|
||||
tokenManager: tokenManager,
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
type baseTransport struct {
|
||||
httpTransport *http.Transport
|
||||
tokenManager *tokenManager
|
||||
endpoint *portainer.Endpoint
|
||||
dataStore portainer.DataStore
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, dataStore portainer.DataStore) *baseTransport {
|
||||
return &baseTransport{
|
||||
httpTransport: httpTransport,
|
||||
tokenManager: tokenManager,
|
||||
endpoint: endpoint,
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/api/v[0-9](\.[0-9])?`)
|
||||
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
|
||||
|
||||
switch {
|
||||
case strings.EqualFold(requestPath, "/namespaces"):
|
||||
return transport.executeKubernetesRequest(request, true)
|
||||
case strings.HasPrefix(requestPath, "/namespaces"):
|
||||
return transport.proxyNamespacedRequest(request, requestPath)
|
||||
default:
|
||||
return transport.executeKubernetesRequest(request, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fullRequestPath string) (*http.Response, error) {
|
||||
requestPath := strings.TrimPrefix(fullRequestPath, "/namespaces/")
|
||||
split := strings.SplitN(requestPath, "/", 2)
|
||||
namespace := split[0]
|
||||
|
||||
requestPath = ""
|
||||
if len(split) > 1 {
|
||||
requestPath = split[1]
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
switch {
|
||||
case requestPath == "" && request.Method == "DELETE":
|
||||
return transport.deleteNamespaceRequest(request, namespace)
|
||||
default:
|
||||
return transport.executeKubernetesRequest(request, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *baseTransport) executeKubernetesRequest(request *http.Request, shouldLog bool) (*http.Response, error) {
|
||||
return transport.httpTransport.RoundTrip(request)
|
||||
}
|
||||
|
||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
||||
func NewAgentTransport(datastore portainer.DataStore, signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager) *agentTransport {
|
||||
transport := &agentTransport{
|
||||
dataStore: datastore,
|
||||
httpTransport: &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
tokenManager: tokenManager,
|
||||
signatureService: signatureService,
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/v2") {
|
||||
decorateAgentRequest(request, transport.dataStore)
|
||||
}
|
||||
|
||||
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
|
||||
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
|
||||
return transport.httpTransport.RoundTrip(request)
|
||||
}
|
||||
|
||||
// NewEdgeTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
|
||||
func NewEdgeTransport(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, endpointIdentifier portainer.EndpointID, tokenManager *tokenManager) *edgeTransport {
|
||||
transport := &edgeTransport{
|
||||
dataStore: datastore,
|
||||
httpTransport: &http.Transport{},
|
||||
tokenManager: tokenManager,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
endpointIdentifier: endpointIdentifier,
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/v2") {
|
||||
decorateAgentRequest(request, transport.dataStore)
|
||||
}
|
||||
|
||||
response, err := transport.httpTransport.RoundTrip(request)
|
||||
|
||||
if err == nil {
|
||||
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpointIdentifier)
|
||||
} else {
|
||||
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpointIdentifier)
|
||||
}
|
||||
|
||||
return response, err
|
||||
}
|
||||
var (
|
||||
namespaceRegex = regexp.MustCompile(`^/namespaces/([^/]*)$`)
|
||||
)
|
||||
|
||||
func getRoundTripToken(
|
||||
request *http.Request,
|
||||
@@ -156,9 +82,9 @@ func getRoundTripToken(
|
||||
|
||||
var token string
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
token = tokenManager.getAdminServiceAccountToken()
|
||||
token = tokenManager.GetAdminServiceAccountToken()
|
||||
} else {
|
||||
token, err = tokenManager.getUserServiceAccountToken(int(tokenData.ID))
|
||||
token, err = tokenManager.GetUserServiceAccountToken(int(tokenData.ID))
|
||||
if err != nil {
|
||||
log.Printf("Failed retrieving service account token: %v", err)
|
||||
return "", err
|
||||
|
||||
@@ -45,11 +45,13 @@ import (
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
// Server implements the portainer.Server interface
|
||||
type Server struct {
|
||||
AuthorizationService *authorization.Service
|
||||
BindAddress string
|
||||
AssetsPath string
|
||||
Status *portainer.Status
|
||||
@@ -135,6 +137,7 @@ func (server *Server) Start() error {
|
||||
endpointHandler.SnapshotService = server.SnapshotService
|
||||
endpointHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
endpointHandler.ComposeStackManager = server.ComposeStackManager
|
||||
endpointHandler.AuthorizationService = server.AuthorizationService
|
||||
|
||||
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer)
|
||||
endpointEdgeHandler.DataStore = server.DataStore
|
||||
@@ -142,6 +145,7 @@ func (server *Server) Start() error {
|
||||
endpointEdgeHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
|
||||
endpointGroupHandler.AuthorizationService = server.AuthorizationService
|
||||
endpointGroupHandler.DataStore = server.DataStore
|
||||
|
||||
var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer)
|
||||
@@ -200,7 +204,7 @@ func (server *Server) Start() error {
|
||||
userHandler.DataStore = server.DataStore
|
||||
userHandler.CryptoService = server.CryptoService
|
||||
|
||||
var websocketHandler = websocket.NewHandler(requestBouncer)
|
||||
var websocketHandler = websocket.NewHandler(server.KubernetesTokenCacheManager, requestBouncer)
|
||||
websocketHandler.DataStore = server.DataStore
|
||||
websocketHandler.SignatureService = server.SignatureService
|
||||
websocketHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package authorization
|
||||
|
||||
import "github.com/portainer/portainer/api"
|
||||
import (
|
||||
"github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
// Service represents a service used to
|
||||
// update authorizations associated to a user or team.
|
||||
type Service struct {
|
||||
dataStore portainer.DataStore
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
}
|
||||
|
||||
// NewService returns a point to a new Service instance.
|
||||
|
||||
134
api/internal/authorization/endpint_role_with_override.go
Normal file
134
api/internal/authorization/endpint_role_with_override.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package authorization
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
// CleanNAPWithOverridePolicies Clean Namespace Access Policies with override policies
|
||||
func (service *Service) CleanNAPWithOverridePolicies(
|
||||
endpoint *portainer.Endpoint,
|
||||
endpointGroup *portainer.EndpointGroup,
|
||||
) error {
|
||||
kubecli, err := service.K8sClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessPolicies, err := kubecli.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hasChange := false
|
||||
|
||||
for namespace, policy := range accessPolicies {
|
||||
for teamID := range policy.TeamAccessPolicies {
|
||||
access, err := service.getTeamEndpointAccessWithPolicies(teamID, endpoint, endpointGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !access {
|
||||
delete(accessPolicies[namespace].TeamAccessPolicies, teamID)
|
||||
hasChange = true
|
||||
}
|
||||
}
|
||||
|
||||
for userID := range policy.UserAccessPolicies {
|
||||
access, err := service.getUserEndpointAccessWithPolicies(userID, endpoint, endpointGroup)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !access {
|
||||
delete(accessPolicies[namespace].UserAccessPolicies, userID)
|
||||
hasChange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasChange {
|
||||
err = kubecli.UpdateNamespaceAccessPolicies(accessPolicies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) getUserEndpointAccessWithPolicies(
|
||||
userID portainer.UserID,
|
||||
endpoint *portainer.Endpoint,
|
||||
endpointGroup *portainer.EndpointGroup,
|
||||
) (bool, error) {
|
||||
memberships, err := service.dataStore.TeamMembership().TeamMembershipsByUserID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if endpointGroup == nil {
|
||||
endpointGroup, err = service.dataStore.EndpointGroup().EndpointGroup(endpoint.GroupID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if userAccess(userID, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies, memberships) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if userAccess(userID, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies, memberships) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
|
||||
}
|
||||
|
||||
func userAccess(
|
||||
userID portainer.UserID,
|
||||
userAccessPolicies portainer.UserAccessPolicies,
|
||||
teamAccessPolicies portainer.TeamAccessPolicies,
|
||||
memberships []portainer.TeamMembership,
|
||||
) bool {
|
||||
if _, ok := userAccessPolicies[userID]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, membership := range memberships {
|
||||
if _, ok := teamAccessPolicies[membership.TeamID]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (service *Service) getTeamEndpointAccessWithPolicies(
|
||||
teamID portainer.TeamID,
|
||||
endpoint *portainer.Endpoint,
|
||||
endpointGroup *portainer.EndpointGroup,
|
||||
) (bool, error) {
|
||||
if endpointGroup == nil {
|
||||
var err error
|
||||
endpointGroup, err = service.dataStore.EndpointGroup().EndpointGroup(endpoint.GroupID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if teamAccess(teamID, endpoint.TeamAccessPolicies) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if teamAccess(teamID, endpointGroup.TeamAccessPolicies) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func teamAccess(
|
||||
teamID portainer.TeamID,
|
||||
teamAccessPolicies portainer.TeamAccessPolicies,
|
||||
) bool {
|
||||
_, ok := teamAccessPolicies[teamID];
|
||||
return ok
|
||||
}
|
||||
17
api/internal/endpoint/endpoint.go
Normal file
17
api/internal/endpoint/endpoint.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package endpoint
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
// IsKubernetesEndpoint returns true if this is a kubernetes endpoint
|
||||
func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.KubernetesLocalEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||
}
|
||||
|
||||
// IsDocketEndpoint returns true if this is a docker endpoint
|
||||
func IsDocketEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.DockerEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnDockerEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment
|
||||
}
|
||||
@@ -9,3 +9,17 @@ import (
|
||||
func IsLocalEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5
|
||||
}
|
||||
|
||||
// IsKubernetesEndpoint returns true if this is a kubernetes endpoint
|
||||
func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.KubernetesLocalEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||
}
|
||||
|
||||
// IsDockerEndpoint returns true if this is a docker endpoint
|
||||
func IsDockerEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.DockerEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnDockerEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment
|
||||
}
|
||||
|
||||
12
api/internal/testhelpers/git_service.go
Normal file
12
api/internal/testhelpers/git_service.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package testhelpers
|
||||
|
||||
type gitService struct{}
|
||||
|
||||
// NewGitService creates new mock for portainer.GitService.
|
||||
func NewGitService() *gitService {
|
||||
return &gitService{}
|
||||
}
|
||||
|
||||
func (service *gitService) CloneRepository(destination string, repositoryURL, referenceName string, username, password string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package jwt
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -51,23 +51,13 @@ func NewService(userSessionDuration string) (*Service, error) {
|
||||
|
||||
// GenerateToken generates a new JWT token.
|
||||
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
|
||||
expireToken := time.Now().Add(service.userSessionTimeout).Unix()
|
||||
cl := claims{
|
||||
UserID: int(data.ID),
|
||||
Username: data.Username,
|
||||
Role: int(data.Role),
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: expireToken,
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl)
|
||||
return service.generateSignedToken(data, nil)
|
||||
}
|
||||
|
||||
signedToken, err := token.SignedString(service.secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
// GenerateTokenForOAuth generates a new JWT for OAuth login
|
||||
// token expiry time from the OAuth provider is considered
|
||||
func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
|
||||
return service.generateSignedToken(data, expiryTime)
|
||||
}
|
||||
|
||||
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
|
||||
@@ -97,3 +87,26 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
|
||||
func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration) {
|
||||
service.userSessionTimeout = userSessionDuration
|
||||
}
|
||||
|
||||
func (service *Service) generateSignedToken(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
|
||||
expireToken := time.Now().Add(service.userSessionTimeout).Unix()
|
||||
if expiryTime != nil && !expiryTime.IsZero() {
|
||||
expireToken = expiryTime.Unix()
|
||||
}
|
||||
cl := claims{
|
||||
UserID: int(data.ID),
|
||||
Username: data.Username,
|
||||
Role: int(data.Role),
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: expireToken,
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl)
|
||||
|
||||
signedToken, err := token.SignedString(service.secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
|
||||
38
api/jwt/jwt_test.go
Normal file
38
api/jwt/jwt_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGenerateSignedToken(t *testing.T) {
|
||||
svc, err := NewService("24h")
|
||||
assert.NoError(t, err, "failed to create a copy of service")
|
||||
|
||||
token := &portainer.TokenData{
|
||||
Username: "Joe",
|
||||
ID: 1,
|
||||
Role: 1,
|
||||
}
|
||||
expirtationTime := time.Now().Add(1 * time.Hour)
|
||||
|
||||
generatedToken, err := svc.generateSignedToken(token, &expirtationTime)
|
||||
assert.NoError(t, err, "failed to generate a signed token")
|
||||
|
||||
parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return svc.secret, nil
|
||||
})
|
||||
assert.NoError(t, err, "failed to parse generated token")
|
||||
|
||||
tokenClaims, ok := parsedToken.Claims.(*claims)
|
||||
assert.Equal(t, true, ok, "failed to claims out of generated ticket")
|
||||
|
||||
assert.Equal(t, token.Username, tokenClaims.Username)
|
||||
assert.Equal(t, int(token.ID), tokenClaims.UserID)
|
||||
assert.Equal(t, int(token.Role), tokenClaims.Role)
|
||||
assert.Equal(t, expirtationTime.Unix(), tokenClaims.ExpiresAt)
|
||||
}
|
||||
@@ -3,18 +3,14 @@ package cli
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type (
|
||||
accessPolicies struct {
|
||||
UserAccessPolicies portainer.UserAccessPolicies `json:"UserAccessPolicies"`
|
||||
TeamAccessPolicies portainer.TeamAccessPolicies `json:"TeamAccessPolicies"`
|
||||
}
|
||||
|
||||
namespaceAccessPolicies map[string]accessPolicies
|
||||
namespaceAccessPolicies map[string]portainer.K8sNamespaceAccessPolicy
|
||||
)
|
||||
|
||||
func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string) error {
|
||||
@@ -69,7 +65,7 @@ func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, service
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasUserAccessToNamespace(userID int, teamIDs []int, policies accessPolicies) bool {
|
||||
func hasUserAccessToNamespace(userID int, teamIDs []int, policies portainer.K8sNamespaceAccessPolicy) bool {
|
||||
_, userAccess := policies.UserAccessPolicies[portainer.UserID(userID)]
|
||||
if userAccess {
|
||||
return true
|
||||
@@ -84,3 +80,65 @@ func hasUserAccessToNamespace(userID int, teamIDs []int, policies accessPolicies
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace
|
||||
func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error {
|
||||
kcl.lock.Lock()
|
||||
defer kcl.lock.Unlock()
|
||||
|
||||
policies, err := kcl.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "failed to fetch access policies")
|
||||
}
|
||||
|
||||
delete(policies, ns)
|
||||
|
||||
return kcl.UpdateNamespaceAccessPolicies(policies)
|
||||
}
|
||||
|
||||
// GetNamespaceAccessPolicies gets the namespace access policies
|
||||
// from config maps in the portainer namespace
|
||||
func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) {
|
||||
configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{})
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessData := configMap.Data[portainerConfigMapAccessPoliciesKey]
|
||||
|
||||
var policies map[string]portainer.K8sNamespaceAccessPolicy
|
||||
err = json.Unmarshal([]byte(accessData), &policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// UpdateNamespaceAccessPolicies updates the namespace access policies
|
||||
func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]portainer.K8sNamespaceAccessPolicy) error {
|
||||
data, err := json.Marshal(accessPolicies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{})
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configMap.Data[portainerConfigMapAccessPoliciesKey] = string(data)
|
||||
_, err = kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Update(configMap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
68
api/kubernetes/cli/access_test.go
Normal file
68
api/kubernetes/cli/access_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
ktypes "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConfigExists(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
namespaceToDelete string
|
||||
expectedConfig map[string]portainer.K8sNamespaceAccessPolicy
|
||||
}{
|
||||
{
|
||||
name: "doesn't change config, when designated namespace absent",
|
||||
namespaceToDelete: "missing-namespace",
|
||||
expectedConfig: map[string]portainer.K8sNamespaceAccessPolicy{
|
||||
"ns1": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
"ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "removes designated namespace from config, when namespace is present",
|
||||
namespaceToDelete: "ns2",
|
||||
expectedConfig: map[string]portainer.K8sNamespaceAccessPolicy{
|
||||
"ns1": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
k := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
config := &ktypes.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: portainerConfigMapName,
|
||||
Namespace: portainerNamespace,
|
||||
},
|
||||
Data: map[string]string{
|
||||
"NamespaceAccessPolicies": `{"ns1":{"UserAccessPolicies":{"2":{"RoleId":0}}}, "ns2":{"UserAccessPolicies":{"2":{"RoleId":0}}}}`,
|
||||
},
|
||||
}
|
||||
_, err := k.cli.CoreV1().ConfigMaps(portainerNamespace).Create(config)
|
||||
assert.NoError(t, err, "failed to create a portainer config")
|
||||
defer func() {
|
||||
k.cli.CoreV1().ConfigMaps(portainerNamespace).Delete(portainerConfigMapName, nil)
|
||||
}()
|
||||
|
||||
err = k.NamespaceAccessPoliciesDeleteNamespace(test.namespaceToDelete)
|
||||
assert.NoError(t, err, "failed to delete namespace")
|
||||
|
||||
policies, err := k.GetNamespaceAccessPolicies()
|
||||
assert.NoError(t, err, "failed to fetch policies")
|
||||
assert.Equal(t, test.expectedConfig, policies)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
cmap "github.com/orcaman/concurrent-map"
|
||||
|
||||
@@ -25,8 +26,9 @@ type (
|
||||
|
||||
// KubeClient represent a service used to execute Kubernetes operations
|
||||
KubeClient struct {
|
||||
cli *kubernetes.Clientset
|
||||
cli kubernetes.Interface
|
||||
instanceID string
|
||||
lock *sync.Mutex
|
||||
}
|
||||
)
|
||||
|
||||
@@ -72,6 +74,7 @@ func (factory *ClientFactory) createKubeClient(endpoint *portainer.Endpoint) (po
|
||||
kubecli := &KubeClient{
|
||||
cli: cli,
|
||||
instanceID: factory.instanceID,
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
return kubecli, nil
|
||||
|
||||
@@ -14,13 +14,18 @@ import (
|
||||
// StartExecProcess will start an exec process inside a container located inside a pod inside a specific namespace
|
||||
// using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write
|
||||
// to the stdout parameter.
|
||||
// This function only works against a local endpoint using an in-cluster config.
|
||||
func (kcl *KubeClient) StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error {
|
||||
// This function only works against a local endpoint using an in-cluster config with the user's SA token.
|
||||
func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error {
|
||||
config, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !useAdminToken {
|
||||
config.BearerToken = token
|
||||
config.BearerTokenFile = ""
|
||||
}
|
||||
|
||||
req := kcl.cli.CoreV1().RESTClient().
|
||||
Post().
|
||||
Resource("pods").
|
||||
|
||||
@@ -4,14 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/oauth2"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// Service represents a service used to authenticate users against an authorization server
|
||||
@@ -23,31 +24,35 @@ func NewService() *Service {
|
||||
}
|
||||
|
||||
// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint.
|
||||
// On success, it will then return the username associated to authenticated user by fetching this information
|
||||
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
|
||||
// from the resource server and matching it with the user identifier setting.
|
||||
func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
token, err := getAccessToken(code, configuration)
|
||||
token, err := getOAuthToken(code, configuration)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] - Failed retrieving access token: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return getUsername(token, configuration)
|
||||
username, err := getUsername(token.AccessToken, configuration)
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] - Failed retrieving oauth user name: %v", err)
|
||||
return "", err
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func getAccessToken(code string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
func getOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
|
||||
unescapedCode, err := url.QueryUnescape(code)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config := buildConfig(configuration)
|
||||
token, err := config.Exchange(context.Background(), unescapedCode)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token.AccessToken, nil
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func getUsername(token string, configuration *portainer.OAuthSettings) (string, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package portainer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
@@ -392,6 +393,11 @@ type (
|
||||
// JobType represents a job type
|
||||
JobType int
|
||||
|
||||
K8sNamespaceAccessPolicy struct {
|
||||
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
|
||||
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
|
||||
}
|
||||
|
||||
// KubernetesData contains all the Kubernetes related endpoint information
|
||||
KubernetesData struct {
|
||||
Snapshots []KubernetesSnapshot `json:"Snapshots"`
|
||||
@@ -491,6 +497,8 @@ type (
|
||||
Scopes string `json:"Scopes"`
|
||||
OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"`
|
||||
DefaultTeamID TeamID `json:"DefaultTeamID"`
|
||||
SSO bool `json:"SSO"`
|
||||
LogoutURI string `json:"LogoutURI"`
|
||||
}
|
||||
|
||||
// Pair defines a key/value string pair
|
||||
@@ -683,12 +691,6 @@ type (
|
||||
SwarmID string `json:"SwarmId" example:"jpofkc0i9uo9wtx1zesuk649w"`
|
||||
// Path to the Stack file
|
||||
EntryPoint string `json:"EntryPoint" example:"docker-compose.yml"`
|
||||
// Additional Stack files
|
||||
AdditionalFiles []string `json:"AdditionalFiles" example:""`
|
||||
// Stack auto update settings
|
||||
AutoUpdate *StackAutoUpdate `json:"AutoUpdate" example:""`
|
||||
// Git settings
|
||||
GitConfig *gittypes.RepoConfig `json:"GitConfig"`
|
||||
// A list of environment variables used during stack deployment
|
||||
Env []Pair `json:"Env" example:""`
|
||||
//
|
||||
@@ -705,13 +707,8 @@ type (
|
||||
UpdateDate int64 `example:"1587399600"`
|
||||
// The username which last updated this stack
|
||||
UpdatedBy string `example:"bob"`
|
||||
}
|
||||
|
||||
// StackAutoUpdate represents the git auto sync config for stack deployment
|
||||
StackAutoUpdate struct {
|
||||
Interval string
|
||||
Webhook string //a UUID generated from client
|
||||
JobID string
|
||||
// The git config of this stack
|
||||
GitConfig *gittypes.RepoConfig
|
||||
}
|
||||
|
||||
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
|
||||
@@ -1153,13 +1150,13 @@ type (
|
||||
|
||||
// GitService represents a service for managing Git
|
||||
GitService interface {
|
||||
ClonePublicRepository(repositoryURL, referenceName string, destination string) error
|
||||
ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error
|
||||
CloneRepository(destination string, repositoryURL, referenceName, username, password string) error
|
||||
}
|
||||
|
||||
// JWTService represents a service for managing JWT tokens
|
||||
JWTService interface {
|
||||
GenerateToken(data *TokenData) (string, error)
|
||||
GenerateTokenForOAuth(data *TokenData, expiryTime *time.Time) (string, error)
|
||||
ParseAndVerifyToken(token string) (*TokenData, error)
|
||||
SetUserSessionDuration(userSessionDuration time.Duration)
|
||||
}
|
||||
@@ -1168,12 +1165,16 @@ type (
|
||||
KubeClient interface {
|
||||
SetupUserServiceAccount(userID int, teamIDs []int) error
|
||||
GetServiceAccountBearerToken(userID int) (string, error)
|
||||
StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error
|
||||
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error
|
||||
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
|
||||
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
|
||||
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
|
||||
}
|
||||
|
||||
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint
|
||||
KubernetesDeployer interface {
|
||||
Deploy(endpoint *Endpoint, data string, composeFormat bool, namespace string) ([]byte, error)
|
||||
Deploy(request *http.Request, endpoint *Endpoint, data string, namespace string) (string, error)
|
||||
ConvertCompose(data string) ([]byte, error)
|
||||
}
|
||||
|
||||
// KubernetesSnapshotter represents a service used to create Kubernetes endpoint snapshots
|
||||
@@ -1342,9 +1343,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.5.0"
|
||||
APIVersion = "2.6.3"
|
||||
// DBVersion is the version number of the Portainer database
|
||||
DBVersion = 27
|
||||
DBVersion = 30
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
ComposeSyntaxMaxVersion = "3.9"
|
||||
// AssetsServerURL represents the URL of the Portainer asset server
|
||||
|
||||
@@ -1023,3 +1023,8 @@ json-tree .branch-preview {
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* !uib-typeahead override */
|
||||
|
||||
.my-8 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -20,18 +20,18 @@ export function ContainerGroupDefaultModel() {
|
||||
}
|
||||
|
||||
export function ContainerGroupViewModel(data) {
|
||||
const addressPorts = data.properties.ipAddress.ports;
|
||||
const addressPorts = data.properties.ipAddress ? data.properties.ipAddress.ports : [];
|
||||
const container = data.properties.containers.length ? data.properties.containers[0] : {};
|
||||
const containerPorts = container ? container.properties.ports : [];
|
||||
|
||||
this.Id = data.id;
|
||||
this.Name = data.name;
|
||||
this.Location = data.location;
|
||||
this.IPAddress = data.properties.ipAddress.ip;
|
||||
this.IPAddress = data.properties.ipAddress ? data.properties.ipAddress.ip : '';
|
||||
this.Ports = addressPorts.length ? addressPorts.map((binding, index) => ({ container: containerPorts[index].port, host: binding.port, protocol: binding.protocol })) : [];
|
||||
this.Image = container.properties.image || '';
|
||||
this.OSType = data.properties.osType;
|
||||
this.AllocatePublicIP = data.properties.ipAddress.type === 'Public';
|
||||
this.AllocatePublicIP = data.properties.ipAddress && data.properties.ipAddress.type === 'Public';
|
||||
this.CPU = container.properties.resources.requests.cpu;
|
||||
this.Memory = container.properties.resources.requests.memoryInGB;
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
</div>
|
||||
<!-- !os-input -->
|
||||
<!-- port-mapping -->
|
||||
<div class="form-group">
|
||||
<div class="form-group" ng-if="$ctrl.container.Ports.length > 0">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Port mapping</label>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,8 @@ angular.module('portainer.azure').controller('AzureCreateContainerInstanceContro
|
||||
'Notifications',
|
||||
'Authentication',
|
||||
'ResourceControlService',
|
||||
function ($q, $scope, $state, AzureService, Notifications, Authentication, ResourceControlService) {
|
||||
'FormValidator',
|
||||
function ($q, $scope, $state, AzureService, Notifications, Authentication, ResourceControlService, FormValidator) {
|
||||
var allResourceGroups = [];
|
||||
var allProviders = [];
|
||||
|
||||
@@ -70,6 +71,11 @@ angular.module('portainer.azure').controller('AzureCreateContainerInstanceContro
|
||||
return 'At least one port binding is required';
|
||||
}
|
||||
|
||||
const error = FormValidator.validateAccessControl(model.AccessControlData, Authentication.isAdmin());
|
||||
if (error !== '') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,3 +30,5 @@ angular
|
||||
.constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none'])
|
||||
.constant('KUBERNETES_DEFAULT_NAMESPACE', 'default')
|
||||
.constant('KUBERNETES_SYSTEM_NAMESPACES', ['kube-system', 'kube-public', 'kube-node-lease', 'portainer']);
|
||||
|
||||
export const PORTAINER_FADEOUT = 1500;
|
||||
|
||||
@@ -456,7 +456,7 @@ angular.module('portainer.docker', ['portainer.app']).config([
|
||||
|
||||
var stack = {
|
||||
name: 'docker.stacks.stack',
|
||||
url: '/:name?id&type&external',
|
||||
url: '/:name?id&type®ular&external&orphaned&orphanedRunning',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: '~Portainer/views/stacks/edit/stack.html',
|
||||
|
||||
@@ -19,7 +19,7 @@ export default class porImageRegistryContainerController {
|
||||
if (this.EndpointHelper.isAgentEndpoint(this.endpoint) || this.EndpointHelper.isLocalEndpoint(this.endpoint)) {
|
||||
try {
|
||||
this.pullRateLimits = await this.DockerHubService.checkRateLimits(this.endpoint);
|
||||
this.setValidity(this.pullRateLimits.remaining >= 0);
|
||||
this.setValidity(!this.pullRateLimits.limit || (this.pullRateLimits.limit && this.pullRateLimits.remaining >= 0));
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed loading DockerHub pull rate limits', e);
|
||||
|
||||
@@ -48,7 +48,7 @@ angular.module('portainer.docker').controller('LogViewerController', [
|
||||
};
|
||||
|
||||
this.downloadLogs = function () {
|
||||
const data = new Blob([_.reduce(this.state.filteredLogs, (acc, log) => acc + '\n' + log, '')]);
|
||||
const data = new Blob([_.reduce(this.state.filteredLogs, (acc, log) => acc + '\n' + log.line, '')]);
|
||||
FileSaver.saveAs(data, this.resourceName + '_logs.txt');
|
||||
};
|
||||
},
|
||||
|
||||
@@ -67,39 +67,6 @@ angular.module('portainer.docker').factory('ServiceHelper', [
|
||||
return [];
|
||||
};
|
||||
|
||||
helper.translateEnvironmentVariables = function (env) {
|
||||
if (env) {
|
||||
var variables = [];
|
||||
env.forEach(function (variable) {
|
||||
var idx = variable.indexOf('=');
|
||||
var keyValue = [variable.slice(0, idx), variable.slice(idx + 1)];
|
||||
var originalValue = keyValue.length > 1 ? keyValue[1] : '';
|
||||
variables.push({
|
||||
key: keyValue[0],
|
||||
value: originalValue,
|
||||
originalKey: keyValue[0],
|
||||
originalValue: originalValue,
|
||||
added: true,
|
||||
});
|
||||
});
|
||||
return variables;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
helper.translateEnvironmentVariablesToEnv = function (env) {
|
||||
if (env) {
|
||||
var variables = [];
|
||||
env.forEach(function (variable) {
|
||||
if (variable.key && variable.key !== '') {
|
||||
variables.push(variable.key + '=' + variable.value);
|
||||
}
|
||||
});
|
||||
return variables;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
helper.translatePreferencesToKeyValue = function (preferences) {
|
||||
if (preferences) {
|
||||
var keyValuePreferences = [];
|
||||
|
||||
@@ -91,6 +91,20 @@ export function ContainerStatsViewModel(data) {
|
||||
this.CPUCores = data.cpu_stats.cpu_usage.percpu_usage.length;
|
||||
}
|
||||
this.Networks = _.values(data.networks);
|
||||
if (data.blkio_stats !== undefined) {
|
||||
//TODO: take care of multiple block devices
|
||||
var readData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'Read');
|
||||
if (readData !== undefined) {
|
||||
this.BytesRead = readData.value;
|
||||
}
|
||||
var writeData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'Write');
|
||||
if (writeData !== undefined) {
|
||||
this.BytesWrite = writeData.value;
|
||||
}
|
||||
} else {
|
||||
//no IO related data is available
|
||||
this.noIOdata = true;
|
||||
}
|
||||
}
|
||||
|
||||
export function ContainerDetailsViewModel(data) {
|
||||
|
||||
@@ -18,6 +18,10 @@ function isJSON(jsonString) {
|
||||
// This handler wrap the JSON objects in an array.
|
||||
// Used by the API in: Image push, Image create, Events query.
|
||||
export function jsonObjectsToArrayHandler(data) {
|
||||
// catching empty data helps the function not to fail and prevents unwanted error message to user.
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
var str = '[' + data.replace(/\n/g, ' ').replace(/\}\s*\{/g, '}, {') + ']';
|
||||
return angular.fromJson(str);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
|
||||
import { ContainerCapabilities, ContainerCapability } from '../../../models/containerCapabilities';
|
||||
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { ContainerDetailsViewModel } from '../../../models/container';
|
||||
@@ -78,6 +81,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
MemoryReservation: 0,
|
||||
CmdMode: 'default',
|
||||
EntrypointMode: 'default',
|
||||
Env: [],
|
||||
NodeName: null,
|
||||
capabilities: [],
|
||||
Sysctls: [],
|
||||
@@ -95,6 +99,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
pullImageValidity: true,
|
||||
};
|
||||
|
||||
$scope.handleEnvVarChange = handleEnvVarChange;
|
||||
function handleEnvVarChange(value) {
|
||||
$scope.formValues.Env = value;
|
||||
}
|
||||
|
||||
$scope.refreshSlider = function () {
|
||||
$timeout(function () {
|
||||
$scope.$broadcast('rzSliderForceRender');
|
||||
@@ -153,14 +162,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
$scope.formValues.Volumes.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addEnvironmentVariable = function () {
|
||||
$scope.config.Env.push({ name: '', value: '' });
|
||||
};
|
||||
|
||||
$scope.removeEnvironmentVariable = function (index) {
|
||||
$scope.config.Env.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addPortBinding = function () {
|
||||
$scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' });
|
||||
};
|
||||
@@ -254,13 +255,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
}
|
||||
|
||||
function prepareEnvironmentVariables(config) {
|
||||
var env = [];
|
||||
config.Env.forEach(function (v) {
|
||||
if (v.name && v.value) {
|
||||
env.push(v.name + '=' + v.value);
|
||||
}
|
||||
});
|
||||
config.Env = env;
|
||||
config.Env = envVarsUtils.convertToArrayOfStrings($scope.formValues.Env);
|
||||
}
|
||||
|
||||
function prepareVolumes(config) {
|
||||
@@ -537,14 +532,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
}
|
||||
|
||||
function loadFromContainerEnvironmentVariables() {
|
||||
var envArr = [];
|
||||
for (var e in $scope.config.Env) {
|
||||
if ({}.hasOwnProperty.call($scope.config.Env, e)) {
|
||||
var arr = $scope.config.Env[e].split(/\=(.*)/);
|
||||
envArr.push({ name: arr[0], value: arr[1] });
|
||||
}
|
||||
}
|
||||
$scope.config.Env = envArr;
|
||||
$scope.formValues.Env = envVarsUtils.parseArrayOfStrings($scope.config.Env);
|
||||
}
|
||||
|
||||
function loadFromContainerLabels() {
|
||||
|
||||
@@ -583,37 +583,13 @@
|
||||
<!-- !tab-labels -->
|
||||
<!-- tab-env -->
|
||||
<div class="tab-pane" id="env">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<!-- environment-variables -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Environment variables</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
|
||||
</span>
|
||||
</div>
|
||||
<!-- environment-variable-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="variable in config.Env" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">name</span>
|
||||
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO" />
|
||||
</div>
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">value</span>
|
||||
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar" />
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !environment-variable-input-list -->
|
||||
</div>
|
||||
<!-- !environment-variables -->
|
||||
</form>
|
||||
<environment-variables-panel
|
||||
ng-model="formValues.Env"
|
||||
explanation="These values will be applied to the container when deployed"
|
||||
on-change="(handleEnvVarChange)"
|
||||
></environment-variables-panel>
|
||||
</div>
|
||||
<!-- !tab-labels -->
|
||||
<!-- !tab-env -->
|
||||
<!-- tab-restart-policy -->
|
||||
<div class="tab-pane" id="restart-policy">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
|
||||
@@ -14,6 +14,7 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
|
||||
$scope.state = {
|
||||
refreshRate: '5',
|
||||
networkStatsUnavailable: false,
|
||||
ioStatsUnavailable: false,
|
||||
};
|
||||
|
||||
$scope.$on('$destroy', function () {
|
||||
@@ -44,6 +45,13 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
|
||||
ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart);
|
||||
}
|
||||
|
||||
function updateIOChart(stats, chart) {
|
||||
var label = moment(stats.read).format('HH:mm:ss');
|
||||
if (stats.noIOData !== true) {
|
||||
ChartService.UpdateIOChart(label, stats.BytesRead, stats.BytesWrite, chart);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCPUChart(stats, chart) {
|
||||
var label = moment(stats.read).format('HH:mm:ss');
|
||||
var value = stats.isWindows ? calculateCPUPercentWindows(stats) : calculateCPUPercentUnix(stats);
|
||||
@@ -77,14 +85,15 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
|
||||
var networkChart = $scope.networkChart;
|
||||
var cpuChart = $scope.cpuChart;
|
||||
var memoryChart = $scope.memoryChart;
|
||||
var ioChart = $scope.ioChart;
|
||||
|
||||
stopRepeater();
|
||||
setUpdateRepeater(networkChart, cpuChart, memoryChart);
|
||||
setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart);
|
||||
$('#refreshRateChange').show();
|
||||
$('#refreshRateChange').fadeOut(1500);
|
||||
};
|
||||
|
||||
function startChartUpdate(networkChart, cpuChart, memoryChart) {
|
||||
function startChartUpdate(networkChart, cpuChart, memoryChart, ioChart) {
|
||||
$q.all({
|
||||
stats: ContainerService.containerStats($transition$.params().id),
|
||||
top: ContainerService.containerTop($transition$.params().id),
|
||||
@@ -95,10 +104,14 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
|
||||
if (stats.Networks.length === 0) {
|
||||
$scope.state.networkStatsUnavailable = true;
|
||||
}
|
||||
if (stats.noIOData === true) {
|
||||
$scope.state.ioStatsUnavailable = true;
|
||||
}
|
||||
updateNetworkChart(stats, networkChart);
|
||||
updateMemoryChart(stats, memoryChart);
|
||||
updateCPUChart(stats, cpuChart);
|
||||
setUpdateRepeater(networkChart, cpuChart, memoryChart);
|
||||
updateIOChart(stats, ioChart);
|
||||
setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
stopRepeater();
|
||||
@@ -106,7 +119,7 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
|
||||
});
|
||||
}
|
||||
|
||||
function setUpdateRepeater(networkChart, cpuChart, memoryChart) {
|
||||
function setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart) {
|
||||
var refreshRate = $scope.state.refreshRate;
|
||||
$scope.repeater = $interval(function () {
|
||||
$q.all({
|
||||
@@ -119,6 +132,7 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
|
||||
updateNetworkChart(stats, networkChart);
|
||||
updateMemoryChart(stats, memoryChart);
|
||||
updateCPUChart(stats, cpuChart);
|
||||
updateIOChart(stats, ioChart);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
stopRepeater();
|
||||
@@ -140,7 +154,11 @@ angular.module('portainer.docker').controller('ContainerStatsController', [
|
||||
var memoryChart = ChartService.CreateMemoryChart(memoryChartCtx);
|
||||
$scope.memoryChart = memoryChart;
|
||||
|
||||
startChartUpdate(networkChart, cpuChart, memoryChart);
|
||||
var ioChartCtx = $('#ioChart');
|
||||
var ioChart = ChartService.CreateIOChart(ioChartCtx);
|
||||
$scope.ioChart = ioChart;
|
||||
|
||||
startChartUpdate(networkChart, cpuChart, memoryChart, ioChart);
|
||||
}
|
||||
|
||||
function initView() {
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
<span class="small text-muted"> <i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i> Network stats are unavailable for this container. </span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="state.ioStatsUnavailable">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted"> <i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i> I/O stats are unavailable for this container. </span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
@@ -49,7 +54,7 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div ng-class="{ true: 'col-md-6 col-sm-12', false: 'col-lg-4 col-md-6 col-sm-12' }[state.networkStatsUnavailable]">
|
||||
<div class="col-lg-6 col-md-6 col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-chart-area" title-text="Memory usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
@@ -59,7 +64,8 @@
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div ng-class="{ true: 'col-md-6 col-sm-12', false: 'col-lg-4 col-md-6 col-sm-12' }[state.networkStatsUnavailable]">
|
||||
|
||||
<div class="col-lg-6 col-md-6 col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-chart-area" title-text="CPU usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
@@ -69,7 +75,8 @@
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-12 col-sm-12" ng-if="!state.networkStatsUnavailable">
|
||||
|
||||
<div class="col-lg-6 col-md-6 col-sm-12" ng-if="!state.networkStatsUnavailable">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-chart-area" title-text="Network usage (aggregate)"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
@@ -80,6 +87,19 @@
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6 col-md-6 col-sm-12" ng-if="!state.ioStatsUnavailable">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-chart-area" title-text="I/O usage (aggregate)"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative;">
|
||||
<canvas id="ioChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<container-processes-datatable
|
||||
title-text="Processes"
|
||||
|
||||
@@ -1,58 +1,56 @@
|
||||
angular.module('portainer.docker').controller('BuildImageController', [
|
||||
'$scope',
|
||||
'$window',
|
||||
'ModalService',
|
||||
'BuildService',
|
||||
'Notifications',
|
||||
'HttpRequestHelper',
|
||||
function ($scope, $window, ModalService, BuildService, Notifications, HttpRequestHelper) {
|
||||
$scope.state = {
|
||||
BuildType: 'editor',
|
||||
actionInProgress: false,
|
||||
activeTab: 0,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
angular.module('portainer.docker').controller('BuildImageController', BuildImageController);
|
||||
|
||||
$scope.formValues = {
|
||||
ImageNames: [{ Name: '' }],
|
||||
UploadFile: null,
|
||||
DockerFileContent: '',
|
||||
URL: '',
|
||||
Path: 'Dockerfile',
|
||||
NodeName: null,
|
||||
};
|
||||
function BuildImageController($scope, $async, $window, ModalService, BuildService, Notifications, HttpRequestHelper) {
|
||||
$scope.state = {
|
||||
BuildType: 'editor',
|
||||
actionInProgress: false,
|
||||
activeTab: 0,
|
||||
isEditorDirty: false,
|
||||
};
|
||||
|
||||
$window.onbeforeunload = () => {
|
||||
if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
$scope.formValues = {
|
||||
ImageNames: [{ Name: '' }],
|
||||
UploadFile: null,
|
||||
DockerFileContent: '',
|
||||
URL: '',
|
||||
Path: 'Dockerfile',
|
||||
NodeName: null,
|
||||
};
|
||||
|
||||
$scope.addImageName = function () {
|
||||
$scope.formValues.ImageNames.push({ Name: '' });
|
||||
};
|
||||
|
||||
$scope.removeImageName = function (index) {
|
||||
$scope.formValues.ImageNames.splice(index, 1);
|
||||
};
|
||||
|
||||
function buildImageBasedOnBuildType(method, names) {
|
||||
var buildType = $scope.state.BuildType;
|
||||
var dockerfilePath = $scope.formValues.Path;
|
||||
|
||||
if (buildType === 'upload') {
|
||||
var file = $scope.formValues.UploadFile;
|
||||
return BuildService.buildImageFromUpload(names, file, dockerfilePath);
|
||||
} else if (buildType === 'url') {
|
||||
var URL = $scope.formValues.URL;
|
||||
return BuildService.buildImageFromURL(names, URL, dockerfilePath);
|
||||
} else {
|
||||
var dockerfileContent = $scope.formValues.DockerFileContent;
|
||||
return BuildService.buildImageFromDockerfileContent(names, dockerfileContent);
|
||||
}
|
||||
$window.onbeforeunload = () => {
|
||||
if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
$scope.buildImage = function () {
|
||||
$scope.addImageName = function () {
|
||||
$scope.formValues.ImageNames.push({ Name: '' });
|
||||
};
|
||||
|
||||
$scope.removeImageName = function (index) {
|
||||
$scope.formValues.ImageNames.splice(index, 1);
|
||||
};
|
||||
|
||||
function buildImageBasedOnBuildType(method, names) {
|
||||
var buildType = $scope.state.BuildType;
|
||||
var dockerfilePath = $scope.formValues.Path;
|
||||
|
||||
if (buildType === 'upload') {
|
||||
var file = $scope.formValues.UploadFile;
|
||||
return BuildService.buildImageFromUpload(names, file, dockerfilePath);
|
||||
} else if (buildType === 'url') {
|
||||
var URL = $scope.formValues.URL;
|
||||
return BuildService.buildImageFromURL(names, URL, dockerfilePath);
|
||||
} else {
|
||||
var dockerfileContent = $scope.formValues.DockerFileContent;
|
||||
return BuildService.buildImageFromDockerfileContent(names, dockerfileContent);
|
||||
}
|
||||
}
|
||||
|
||||
$scope.buildImage = buildImage;
|
||||
|
||||
async function buildImage() {
|
||||
return $async(async () => {
|
||||
var buildType = $scope.state.BuildType;
|
||||
|
||||
if (buildType === 'editor' && $scope.formValues.DockerFileContent === '') {
|
||||
@@ -71,44 +69,42 @@ angular.module('portainer.docker').controller('BuildImageController', [
|
||||
var nodeName = $scope.formValues.NodeName;
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
|
||||
|
||||
buildImageBasedOnBuildType(buildType, imageNames)
|
||||
.then(function success(data) {
|
||||
$scope.buildLogs = data.buildLogs;
|
||||
$scope.state.activeTab = 1;
|
||||
if (data.hasError) {
|
||||
Notifications.error('An error occured during build', { msg: 'Please check build logs output' });
|
||||
} else {
|
||||
Notifications.success('Image successfully built');
|
||||
$scope.state.isEditorDirty = false;
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to build image');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.validImageNames = function () {
|
||||
for (var i = 0; i < $scope.formValues.ImageNames.length; i++) {
|
||||
var item = $scope.formValues.ImageNames[i];
|
||||
if (item.Name !== '') {
|
||||
return true;
|
||||
try {
|
||||
const data = await buildImageBasedOnBuildType(buildType, imageNames);
|
||||
$scope.buildLogs = data.buildLogs;
|
||||
$scope.state.activeTab = 1;
|
||||
if (data.hasError) {
|
||||
Notifications.error('An error occurred during build', { msg: 'Please check build logs output' });
|
||||
} else {
|
||||
Notifications.success('Image successfully built');
|
||||
$scope.state.isEditorDirty = false;
|
||||
}
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to build image');
|
||||
} finally {
|
||||
$scope.state.actionInProgress = false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
$scope.editorUpdate = function (cm) {
|
||||
$scope.formValues.DockerFileContent = cm.getValue();
|
||||
$scope.state.isEditorDirty = true;
|
||||
};
|
||||
|
||||
this.uiCanExit = async function () {
|
||||
if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) {
|
||||
return ModalService.confirmWebEditorDiscard();
|
||||
$scope.validImageNames = function () {
|
||||
for (var i = 0; i < $scope.formValues.ImageNames.length; i++) {
|
||||
var item = $scope.formValues.ImageNames[i];
|
||||
if (item.Name !== '') {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
},
|
||||
]);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
$scope.editorUpdate = function (cm) {
|
||||
$scope.formValues.DockerFileContent = cm.getValue();
|
||||
$scope.state.isEditorDirty = true;
|
||||
};
|
||||
|
||||
this.uiCanExit = async function () {
|
||||
if ($scope.state.BuildType === 'editor' && $scope.formValues.DockerFileContent && $scope.state.isEditorDirty) {
|
||||
return ModalService.confirmWebEditorDiscard();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ angular.module('portainer.docker').controller('ImportImageController', [
|
||||
Notifications.success('Images successfully uploaded');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err.message, 'Unable to upload image');
|
||||
Notifications.error('Failure', err, 'Unable to upload image');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
|
||||
@@ -109,6 +111,11 @@ angular.module('portainer.docker').controller('CreateServiceController', [
|
||||
|
||||
$scope.allowBindMounts = false;
|
||||
|
||||
$scope.handleEnvVarChange = handleEnvVarChange;
|
||||
function handleEnvVarChange(value) {
|
||||
$scope.formValues.Env = value;
|
||||
}
|
||||
|
||||
$scope.refreshSlider = function () {
|
||||
$timeout(function () {
|
||||
$scope.$broadcast('rzSliderForceRender');
|
||||
@@ -168,14 +175,6 @@ angular.module('portainer.docker').controller('CreateServiceController', [
|
||||
$scope.formValues.Secrets.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addEnvironmentVariable = function () {
|
||||
$scope.formValues.Env.push({ name: '', value: '' });
|
||||
};
|
||||
|
||||
$scope.removeEnvironmentVariable = function (index) {
|
||||
$scope.formValues.Env.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addPlacementConstraint = function () {
|
||||
$scope.formValues.PlacementConstraints.push({ key: '', operator: '==', value: '' });
|
||||
};
|
||||
@@ -277,13 +276,7 @@ angular.module('portainer.docker').controller('CreateServiceController', [
|
||||
}
|
||||
|
||||
function prepareEnvConfig(config, input) {
|
||||
var env = [];
|
||||
input.Env.forEach(function (v) {
|
||||
if (v.name) {
|
||||
env.push(v.name + '=' + v.value);
|
||||
}
|
||||
});
|
||||
config.TaskTemplate.ContainerSpec.Env = env;
|
||||
config.TaskTemplate.ContainerSpec.Env = envVarsUtils.convertToArrayOfStrings(input.Env);
|
||||
}
|
||||
|
||||
function prepareLabelsConfig(config, input) {
|
||||
|
||||
@@ -160,6 +160,7 @@
|
||||
<li class="active interactive"><a data-target="#command" data-toggle="tab">Command & Logging</a></li>
|
||||
<li class="interactive"><a data-target="#volumes" data-toggle="tab">Volumes</a></li>
|
||||
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
|
||||
<li class="interactive"><a data-target="#env" data-toggle="tab">Env</a></li>
|
||||
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
|
||||
<li class="interactive"><a data-target="#update-config" data-toggle="tab">Update config & Restart</a></li>
|
||||
<li class="interactive" ng-if="applicationState.endpoint.apiVersion >= 1.25"><a data-target="#secrets" data-toggle="tab">Secrets</a></li>
|
||||
@@ -202,34 +203,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- !workdir-user-input -->
|
||||
<!-- environment-variables -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Environment variables</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addEnvironmentVariable()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add environment variable
|
||||
</span>
|
||||
</div>
|
||||
<!-- environment-variable-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="variable in formValues.Env" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">name</span>
|
||||
<input type="text" class="form-control" ng-model="variable.name" placeholder="e.g. FOO" />
|
||||
</div>
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">value</span>
|
||||
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. bar" />
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !environment-variable-input-list -->
|
||||
</div>
|
||||
<!-- !environment-variables -->
|
||||
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Logging
|
||||
</div>
|
||||
@@ -443,6 +416,15 @@
|
||||
</form>
|
||||
</div>
|
||||
<!-- !tab-network -->
|
||||
<!-- tab-env -->
|
||||
<div class="tab-pane" id="env">
|
||||
<environment-variables-panel
|
||||
ng-model="formValues.Env"
|
||||
explanation="These values will be applied to the service when created"
|
||||
on-change="(handleEnvVarChange)"
|
||||
></environment-variables-panel>
|
||||
</div>
|
||||
<!-- !tab-env -->
|
||||
<!-- tab-labels -->
|
||||
<div class="tab-pane" id="labels">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div ng-if="service.EnvironmentVariables" id="service-env-variables">
|
||||
<ng-form ng-if="service.EnvironmentVariables" id="service-env-variables" name="serviceEnvForm">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-tasks" title-text="Environment variables">
|
||||
<div class="nopadding" authorization="DockerServiceUpdate">
|
||||
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating ||addEnvironmentVariable(service)" ng-disabled="isUpdating">
|
||||
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating || addEnvironmentVariable(service)" ng-disabled="isUpdating">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> environment variable
|
||||
</a>
|
||||
</div>
|
||||
@@ -10,49 +10,20 @@
|
||||
<rd-widget-body ng-if="service.EnvironmentVariables.length === 0">
|
||||
<p>There are no environment variables for this service.</p>
|
||||
</rd-widget-body>
|
||||
<rd-widget-body ng-if="service.EnvironmentVariables.length > 0" classes="no-padding">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="var in service.EnvironmentVariables | orderBy: 'originalKey'">
|
||||
<td>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon fit-text-size">name</span>
|
||||
<input type="text" class="form-control" ng-model="var.key" ng-disabled="var.added || isUpdating" placeholder="e.g. FOO" />
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-addon fit-text-size">value</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="var.value"
|
||||
ng-change="updateEnvironmentVariable(service, var)"
|
||||
placeholder="e.g. bar"
|
||||
ng-disabled="isUpdating"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
/>
|
||||
<span class="input-group-btn" authorization="DockerServiceUpdate">
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeEnvironmentVariable(service, var)" ng-disabled="isUpdating">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<rd-widget-body ng-if="service.EnvironmentVariables.length > 0">
|
||||
<environment-variables-panel is-name-disabled="true" ng-model="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-panel>
|
||||
</rd-widget-body>
|
||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['EnvironmentVariables'])" ng-click="updateService(service)">Apply changes</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="!hasChanges(service, ['EnvironmentVariables']) || serviceEnvForm.$invalid"
|
||||
ng-click="updateService(service)"
|
||||
>
|
||||
Apply changes
|
||||
</button>
|
||||
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
@@ -64,4 +35,4 @@
|
||||
</div>
|
||||
</rd-widget-footer>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</ng-form>
|
||||
|
||||
@@ -18,6 +18,9 @@ require('./includes/tasks.html');
|
||||
require('./includes/updateconfig.html');
|
||||
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
|
||||
angular.module('portainer.docker').controller('ServiceController', [
|
||||
@@ -114,21 +117,25 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
};
|
||||
|
||||
$scope.addEnvironmentVariable = function addEnvironmentVariable(service) {
|
||||
service.EnvironmentVariables.push({ key: '', value: '', originalValue: '' });
|
||||
service.EnvironmentVariables.push({ name: '', value: '' });
|
||||
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
|
||||
};
|
||||
$scope.removeEnvironmentVariable = function removeEnvironmentVariable(service, item) {
|
||||
const index = service.EnvironmentVariables.indexOf(item);
|
||||
const removedElement = service.EnvironmentVariables.splice(index, 1);
|
||||
if (removedElement !== null) {
|
||||
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
|
||||
}
|
||||
};
|
||||
$scope.updateEnvironmentVariable = function updateEnvironmentVariable(service, variable) {
|
||||
if (variable.value !== variable.originalValue || variable.key !== variable.originalKey) {
|
||||
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
|
||||
}
|
||||
};
|
||||
|
||||
$scope.onChangeEnvVars = onChangeEnvVars;
|
||||
|
||||
function onChangeEnvVars(env) {
|
||||
const service = $scope.service;
|
||||
|
||||
const orgEnv = service.EnvironmentVariables;
|
||||
service.EnvironmentVariables = env.map((v) => {
|
||||
const orgVar = orgEnv.find(({ name }) => v.name === name);
|
||||
const added = orgVar && orgVar.added;
|
||||
return { ...v, added };
|
||||
});
|
||||
|
||||
updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables);
|
||||
}
|
||||
|
||||
$scope.addConfig = function addConfig(service, config) {
|
||||
if (
|
||||
config &&
|
||||
@@ -395,7 +402,7 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
var config = ServiceHelper.serviceToConfig(service.Model);
|
||||
config.Name = service.Name;
|
||||
config.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceLabels);
|
||||
config.TaskTemplate.ContainerSpec.Env = ServiceHelper.translateEnvironmentVariablesToEnv(service.EnvironmentVariables);
|
||||
config.TaskTemplate.ContainerSpec.Env = envVarsUtils.convertToArrayOfStrings(service.EnvironmentVariables);
|
||||
config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceContainerLabels);
|
||||
|
||||
if ($scope.hasChanges(service, ['Image'])) {
|
||||
@@ -625,7 +632,10 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
function translateServiceArrays(service) {
|
||||
service.ServiceSecrets = service.Secrets ? service.Secrets.map(SecretHelper.flattenSecret) : [];
|
||||
service.ServiceConfigs = service.Configs ? service.Configs.map(ConfigHelper.flattenConfig) : [];
|
||||
service.EnvironmentVariables = ServiceHelper.translateEnvironmentVariables(service.Env);
|
||||
service.EnvironmentVariables = envVarsUtils
|
||||
.parseArrayOfStrings(service.Env)
|
||||
.map((v) => ({ ...v, added: true }))
|
||||
.sort((v1, v2) => (v1.name > v2.name ? 1 : -1));
|
||||
service.LogDriverOpts = ServiceHelper.translateLogDriverOptsToKeyValue(service.LogDriverOpts);
|
||||
service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels);
|
||||
service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user