Compare commits

..

55 Commits

Author SHA1 Message Date
Felix Han
3dab784612 disable admin auto populate when not selecting any groups 2021-07-17 01:12:45 +12:00
Felix Han
dbcce2e9a0 preload admin groups 2021-07-16 14:24:49 +12:00
Felix Han
a36aba4167 moved admin groups API call to newly created ldap service 2021-07-16 14:06:55 +12:00
Felix Han
55571e2b7f Merge branch 'feat/EE-568/admin-auto-population-ldap' into feat/EE-986/add-admin-mapping-section-inLDAP 2021-07-16 12:14:02 +12:00
ArrisLee
6d7a5da1f2 update handler struct to support ldap 2021-07-16 12:10:56 +12:00
Felix Han
7952bf6407 Merge branch 'feat/EE-568/admin-auto-population-ldap' into feat/EE-986/add-admin-mapping-section-inLDAP 2021-07-16 11:27:31 +12:00
Hui
5c774141f4 feat(ldap): auto admin group mapping EE-994 2021-07-16 11:26:06 +12:00
Felix Han
78df820b24 Merge branch 'feat/EE-568/admin-auto-population-ldap' into feat/EE-986/add-admin-mapping-section-inLDAP 2021-07-16 11:05:02 +12:00
Hui
3eebe3a08a feat(ldap): update LDAP settings for auto admin population EE-992 (#5282)
* update LDAP settings for auto admin population

* cleanup
2021-07-14 14:51:45 +12:00
Felix Han
2d98180146 feat(settings): added auto populate admin group section in LDAP 2021-07-07 01:33:09 +12:00
fhanportainer
d48f6bd02c fix(ingress): fixed hostname field when having multiple ingresses EE-1072 (#5273) 2021-07-05 18:17:20 +12:00
Stéphane Busso
340805f880 fix download logs (#5243) 2021-07-05 11:10:10 +12:00
zees-dev
f6c5c552aa feat(oauth/team-memberships): oauth team memberships teaser EE-341 (#5088)
* EE oauth team memberships feature teaser

* bugfix: deleting a default team should reset default team id to 0

* error wrapping, refactor team deletion code
2021-07-02 18:20:10 +12:00
dbuduev
90a472c08b feat(registry): Add ProGet registry type EE-703 (#5196)
* intermediate commit

* feat(registry): backport ProGet registry to CE (#954)

* backport EE changes

* label updates and remove auth-toggle

Co-authored-by: Dennis Buduev <dennis.buduev@portainer.io>
2021-07-01 14:57:15 +12:00
Richard Wei
8b80eb1731 fix(app):Set resource assignment default to off EE-1043 (#5248)
test passed.
2021-06-30 19:15:28 +12:00
yi-portainer
d2404458ea Merge branch 'release/2.6' into develop 2021-06-25 00:02:42 +12:00
Chaim Lev-Ari
1ddf76dbda fix(git-form): show git form and clear auth values (#5224)
* fix(custom-templates): show git form

fix [EE-1025]

* fix(git-form): empty auth values when auth is off
2021-06-23 12:33:22 +12:00
Chaim Lev-Ari
6a39a5cf44 fix(git-form): show git form and clear auth values (#5224)
* fix(custom-templates): show git form

fix [EE-1025]

* fix(git-form): empty auth values when auth is off
2021-06-22 21:41:50 +12:00
cong meng
a13ad8927f fix(stack) ignore username and password when authentication is disabled EE-161 (#5222)
* fix(stack) ignore username and password when authentication is disabled EE-161

* fix(stack) ignore username and password when authentication is disabled for stack creation EE-161

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-22 19:59:05 +12:00
cong meng
8e3751d0b7 fix(stack) Unable to update and redeploy a stack created from a git repository if it has failed once EE-1012 (#5212)
testing passed
2021-06-22 12:58:54 +12:00
Dmitry Salakhov
89f53458c6 fix(stack): allow standard users use advanced deployment (#5205) 2021-06-21 09:53:48 +12:00
cong meng
5466e68f50 fix(ACI): At least one team or user should be specified when creating a restricted container in Azure ACI EE-578 (#5204)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-18 11:30:18 +12:00
Stéphane Busso
60ef6d0270 Bump version to 2.6.0 2021-06-17 16:55:11 +12:00
Hui
caa6c15032 feat(k8s): advanced deployment from Git repo EE-447 (#5166)
* feat(stack): UI updates in git repo deployment method for k8s EE-640. (#5097)

* feat(stack): UI updates in git repo deployment method for k8s EE-640.

* feat(stack): supports the combination of GIT + COMPOSE.

* feat(stack): rename variable

* feat(stack): add git repo deployment method for k8s EE-638

* cleanup

* update payload validation rules

* make repo ref optional in frond end

Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
2021-06-16 23:47:32 +02:00
cong meng
6b759438b8 fix(k8s) cleaning up namespace access policies when removing users orteams from endpoint or endpoint group EE-718 (#5184)
* fix(k8s) cleaning up namespace access policies when removing users or teams from endpoint or endpoint group EE-718

* fix(k8s) minor code cleanup EE-718

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-16 20:15:29 +12:00
Hui
2170ad49ef fix(DB): downgrade DB version from 31 to 30 EE-955 (#5193)
* downgrade DB version from 31 to 30

* rename unit test func

* refactor migration func for DB 30

* move test helper func

* use struct method
2021-06-16 19:58:30 +12:00
yi-portainer
6a88c2ae36 Merge branch 'release/2.5' into develop 2021-06-16 17:31:00 +12:00
Alice Groux
7f96220a09 feat(k8s/advanced-deployment): allow standard users to see and use advanced deployment feature EE-446 (#5050) 2021-06-16 17:28:44 +12:00
Dmitry Salakhov
0b93714de4 feat(stacks): redeploy git stack [EE-161] (#5139)
* feat(git): save git config when creating stack (#5048)

* feat(git): save git config when creating stack

* chore(fs): test fileExists

* fix(git): fix tests to use CloneRepository

* refactor(git): move options to new object

* feat(stacks): redeploy git stack api (#5112)

* feat(stacks): redeploy git stacks form

[EE-666]

* feat(stack): show loading after confirmation

* fix(stacks): show same size description

* fix(stacks): reload state when deployed

* feat(stacks): set stopped stacks status to activate when updating

* feat(stacks): backup stack folder before cloning

* feat(stacks): don't accept prune and env on update git

Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
2021-06-16 09:11:35 +12:00
cong meng
296ecc5960 fix(k8s) Adding a Kube app does not allow Global to be set after removing persisted folder EE-563 (#5143)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-15 15:53:31 +12:00
Chaim Lev-Ari
d7bc4f9b96 fix(stacks): show missing status stacks (#5047)
Co-authored-by: dbuduev <dbuduev@gmail.com>
2021-06-14 14:40:00 +02:00
itsconquest
a5e8cf62d2 feat(UX): introduce new env variables UI (#4175)
* feat(app): introduce new env vars ui

feat(app): introduce new env vars ui

feat(UX): WIP new env variables UI

feat(UX): update button and placeholder

feat(UX): mention .env file in message

feat(UX): allow add/remove value & load correctly

feat(UX): restrict filesize to 1MB

feat(UX): vertical align error message

feat(UX): fill UI from file & when switching modes

feat(UX): strip un-needed newline character

feat(UX): introduce component to other views

feat(UX): fix title alignment

feat(UX): only populate editor on mode switch when key exists

feat(UX): prevent trimming of whitespace on values

feat(UX): change editor to async

feat(UX): add message describing use

feat(UX): Refactor variable text to editorText

refactor(app): rename env vars controller

refactor(app): move env var explanation to parent

refactor(app): order env var panels

refactor(app): move simple env vars mode to component

refactor(app): parse env vars

refactor(app): move styles to css

refactor(app): rename functions

refactor(container): parse env vars

refactor(env-vars): move utils to helper module

refactor(env-vars): use util function for parse dot env file

fix(env-vars): ignore comments

refactor(services): use env vars utils

refactor(env-vars): rename files

refactor(env-panel): use utils

style(stack): revert EnvContent to Env

style(service): revert EnvContent to Env

style(container): revert EnvContent to Env

refactor(env-vars): support default value

refactor(service): use new env var component

refactor(env-var): use one way data flow

refactor(containers): remove unused function

* fix(env-vars): prevent using non .env files

* refactor(env-vars): move env vars items to a component

* feat(app): fixed env vars form validation in Stack

* feat(services): disable env form submit if invalid

* fix(app): show key pairs correctly

* fix(env-var): use the same validation as with kubernetes

* fix(env-vars): parse env var

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
2021-06-14 18:59:07 +12:00
zees-dev
6e9f472723 feat(container-stats): introduce container block I/O stats (#5017)
* feat(container-stats):introduce container block io stats

* Change charts to 2x2 view

* fix(container-stats): handle missing io stats by detecting stats based on op codes

Co-authored-by: DarkAEther <30438425+DarkAEther@users.noreply.github.com>
2021-06-14 15:57:00 +12:00
Hui
49bd139466 fix swagger param (#5183) 2021-06-14 14:45:57 +12:00
cong meng
dc180d85c5 Feat 4612 real time metrics for kube nodes (#4708)
* feat(k8s/node): display realtime node metrics GH#4612

* feat(k8s): show observation timestamp instead of real timestamp GH#4612

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-14 12:29:41 +12:00
Maxime Bajeux
45ceece1a9 feat(application): Invalid environment variable form validation when creating an application (#5019) 2021-06-14 11:06:54 +12:00
Chaim Lev-Ari
0b85684168 fix(app): parse response with null body (#4654)
* fix(app): parse response with null body

* style(docker): add comment explaining change

* fix(images): show correct error when failing import

* fix(images): use async await
2021-06-11 12:05:54 +12:00
Hui
f674573cdf feat(OAuth): Add SSO support for OAuth EE-390 (#5087)
* add updateSettingsToDB28 func and test

* update DBversion const

* migration func naming modification

* feat(oauth): add sso, hide internal auth teaser and logout options. (#5039)

* cleanup and make helper func for unit testing

* dbversion update

* feat(publicSettings): public settings response modification for OAuth SSO EE-608 (#5062)

* feat(oauth): updated logout logic with logoutUrl. (#5064)

* add exclusive token generation for OAuth

* swagger annotation revision

* add unit test

* updates based on tech review feedback

* feat(oauth): updated oauth settings model

* feat(oauth): added oauth logout url

* feat(oauth): fixed SSO toggle and logout issue.

* set SSO to ON by default

* update migrator unit test

* set SSO to true by default for new instance

* prevent applying the SSO logout url to the initial admin user

Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
2021-06-11 10:09:04 +12:00
Richard Wei
14ac005627 fix(app):fix local k8s endpoint not saved EE-825 (#5162) 2021-06-11 09:36:17 +12:00
cong meng
26ead28d7b Feat(stacks): orphaned stacks #4397 (#4834)
* feat(stack): add the ability for an administrator user to manage orphaned stacks (#4397)

* feat(stack): apply small font size to the information text of associate (#4397)

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-10 14:52:33 +12:00
zees-dev
eae2f5c9fc feat(kubernetes/summary): summary of k8s actions upon deploying/updating resources EE-436 (#5137)
* feat EE-440/EE-436 kubernetes-resources-summary-panel

* bugfix: returning created resources after update

* fixed patch based bugs - displaying accurate updates for k8s resources

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-10 10:38:23 +12:00
cong meng
1f2a90a722 fix(frontend): When a docker endpoint is selected, configuring a newly added k8s agent fails EE-821 (#5115)
* fix(frontend): When a docker endpoint is selected, configuring a newly added k8s agent fails EE-821

* fix(frontend): restore endpointID in a finally block EE-821

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-09 21:54:36 +02:00
fhanportainer
267968e099 fix(aci): fixed aci with persistence or networking issue. (#4996) 2021-06-10 01:34:19 +12:00
cong meng
defd929366 Fix(kube) advanced deployment CE-83 (#4866)
* refactor(http/kube): convert compose format

* feat(kube/deploy): deploy to agent

* feat(kube/deploy): show more details about error

* refactor(kube): return string from deploy

* feat(kube/deploy): revert to use local kubectl

* Revert "feat(kube/deploy): revert to use local kubectl"

This reverts commit 7c4a1c70

* feat(kube/deploy): GH#4321 use the v2 version of agent api instead of v3

Co-authored-by: Chaim Lev-Ari <chiptus@gmail.com>
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-06-09 01:55:17 +02:00
testA113
2fb17c9cf9 Merge pull request #4983 from portainer/feat/EE-352/CE-truncate-image-name-in-tables
feat(k8s): truncate image name in tables
2021-06-04 15:20:26 +12:00
dbuduev
c8d78ad15f Merge pull request #5146 from portainer/feat/EE-872/test-scaffolding
feat(bolt): implement bolt db test store EE-872
2021-06-04 13:44:56 +12:00
Dennis Buduev
96a6129d8a feat(bolt): implement boltdb test store EE-872 2021-06-04 13:33:18 +12:00
Alice Groux
b8660ed2a0 feat(k8s/applications): reorder placement policies and select mandatory by default (#5063) 2021-06-03 13:42:44 +02:00
Chaim Lev-Ari
9ec1f2ed6d fix(endpoints): set sysctl setting for new endpoints (#5028) 2021-06-03 11:36:54 +02:00
yi-portainer
8bfa5132cd Merge branch 'release/2.5' into develop 2021-06-03 20:39:54 +12:00
wheresolivia
cafcebe27e Merge pull request #4668 from portainer/feat-4667-custom-portainer-folder
chore(dev-build): custom portainer data folder
2021-06-03 13:28:33 +12:00
yi-portainer
1d46f2bb35 * update portainer version to 2.5.1 2021-05-28 10:21:29 +12:00
alice groux
872a8262f1 feat(k8s): add full name on hovering over the image name 2021-04-14 14:59:17 +02:00
alice groux
c339afb562 feat(k8s): cut image name to 64 chars with truncate filter in all applications datatables 2021-04-13 16:09:37 +02:00
Chaim Lev-Ari
78661b50ca chore(dev-build): custom portainer data folder 2021-04-12 08:49:07 +03:00
205 changed files with 5297 additions and 1343 deletions

View 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)
}
}

View File

@@ -39,6 +39,9 @@ func (store *Store) Init() error {
GroupSearchSettings: []portainer.LDAPGroupSearchSettings{
portainer.LDAPGroupSearchSettings{},
},
AdminGroupSearchSettings: []portainer.LDAPGroupSearchSettings{
portainer.LDAPGroupSearchSettings{},
},
},
OAuthSettings: portainer.OAuthSettings{},

View 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)
}

View 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)
}
}

View File

@@ -0,0 +1,21 @@
package migrator
import portainer "github.com/portainer/portainer/api"
func (m *Migrator) migrateDBVersionTo32() error {
if err := m.migrateAdminGroupSearchSettings(); err != nil {
return err
}
return nil
}
func (m *Migrator) migrateAdminGroupSearchSettings() error {
legacySettings, err := m.settingsService.Settings()
if err != nil {
return err
}
if legacySettings.LDAPSettings.AdminGroupSearchSettings == nil {
legacySettings.LDAPSettings.AdminGroupSearchSettings = []portainer.LDAPGroupSearchSettings{}
}
return m.settingsService.UpdateSettings(legacySettings)
}

View File

@@ -0,0 +1,52 @@
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"
)
func TestMigrateStackEntryPoint(t *testing.T) {
testingDBStorePath, _ = os.Getwd()
testingDBFileName = "portainer-ee-mig-32.db"
databasePath := path.Join(testingDBStorePath, testingDBFileName)
dbConn, err := bolt.Open(databasePath, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
t.Errorf("failed to init testing DB connection: %v", err)
}
defer dbConn.Close()
defer os.Remove(testingDBFileName)
internalDBConn := &internal.DbConnection{
DB: dbConn,
}
settingsService, err := settings.NewService(internalDBConn)
if err != nil {
t.Errorf("failed to init testing settings service: %v", err)
}
dummySettingsObj := map[string]interface{}{
"LogoURL": "example.com",
}
if err := internal.UpdateObject(internalDBConn, "settings", []byte("SETTINGS"), dummySettingsObj); err != nil {
t.Errorf("failed to create mock settings: %v", err)
}
m := &Migrator{
db: dbConn,
settingsService: settingsService,
}
if err := m.migrateAdminGroupSearchSettings(); 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.LDAPSettings.AdminGroupSearchSettings == nil {
t.Error("LDAP AdminGroupSearchSettings should not be nil")
}
}

View File

@@ -358,5 +358,20 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 2.6.0
if m.currentDBVersion < 30 {
err := m.migrateDBVersionTo30()
if err != nil {
return err
}
}
// Portainer 2.9.0
if m.currentDBVersion < 32 {
if err := m.migrateDBVersionTo32(); err != nil {
return err
}
}
return m.versionService.StoreDBVersion(portainer.DBVersion)
}

View File

@@ -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(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
return exec.NewKubernetesDeployer(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(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,

View File

@@ -2,71 +2,188 @@ package exec
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"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
}
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
func NewKubernetesDeployer(binaryPath string) *KubernetesDeployer {
func NewKubernetesDeployer(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
return &KubernetesDeployer{
binaryPath: binaryPath,
binaryPath: binaryPath,
dataStore: datastore,
reverseTunnelService: reverseTunnelService,
signatureService: signatureService,
}
}
// 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(endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
if endpoint.Type == portainer.KubernetesLocalEnvironment {
token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
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", 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(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
}
req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey())
req.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
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")

View File

@@ -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)
}

View 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)
}

View 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)
}

View 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
}

View File

@@ -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"))
}

View File

@@ -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 {

View File

@@ -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"))
}

View File

@@ -2,16 +2,17 @@ package git
import (
"context"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/archive"
"github.com/stretchr/testify/assert"
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/pkg/errors"
"github.com/portainer/portainer/api/archive"
"github.com/stretchr/testify/assert"
)
var bareRepoDir string
@@ -59,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")
}
@@ -74,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"))
}

7
api/git/types/types.go Normal file
View File

@@ -0,0 +1,7 @@
package gittypes
type RepoConfig struct {
URL string
ReferenceName string
ConfigFilePath string
}

View File

@@ -5,6 +5,7 @@ import (
"log"
"net/http"
"strings"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
@@ -53,28 +54,32 @@ func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *ht
var payload authenticatePayload
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}
}
u, err := handler.DataStore.User().UserByUsername(payload.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 err == bolterrors.ErrObjectNotFound && (settings.AuthenticationMethod == portainer.AuthenticationInternal || settings.AuthenticationMethod == portainer.AuthenticationOAuth) {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
return &httperror.HandlerError{StatusCode: http.StatusUnprocessableEntity, Message: "Invalid credentials", Err: httperrors.ErrUnauthorized}
}
if settings.AuthenticationMethod == portainer.AuthenticationLDAP {
if u == nil && settings.LDAPSettings.AutoCreateUsers {
if u == nil && (settings.LDAPSettings.AutoCreateUsers || settings.LDAPSettings.AdminAutoPopulate) {
return handler.authenticateLDAPAndCreateUser(w, payload.Username, payload.Password, &settings.LDAPSettings)
} else if u == nil && !settings.LDAPSettings.AutoCreateUsers {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
} else if u == nil && !settings.LDAPSettings.AutoCreateUsers && !settings.LDAPSettings.AdminAutoPopulate {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Invalid credentials",
Err: httperrors.ErrUnauthorized,
}
}
return handler.authenticateLDAP(w, u, payload.Password, &settings.LDAPSettings)
}
@@ -88,6 +93,36 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
return handler.authenticateInternal(w, user, password)
}
if ldapSettings.AdminAutoPopulate {
userGroups, err := handler.LDAPService.GetUserGroups(user.Username, ldapSettings)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Failed to retrieve user groups from LDAP server",
Err: err,
}
}
adminGroupsMap := make(map[string]bool)
for _, adminGroup := range ldapSettings.AdminGroups {
adminGroupsMap[adminGroup] = true
}
for _, userGroup := range userGroups {
if adminGroupsMap[userGroup] {
user.Role = portainer.AdministratorRole
if err := handler.DataStore.User().UpdateUser(user.ID, user); err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to update user role inside the database",
Err: err,
}
}
break
}
}
}
err = handler.addUserIntoTeams(user, ldapSettings)
if err != nil {
log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error())
@@ -99,7 +134,7 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.
func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError {
err := handler.CryptoService.CompareHashAndData(user.Password, password)
if err != nil {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", httperrors.ErrUnauthorized}
return &httperror.HandlerError{StatusCode: http.StatusUnprocessableEntity, Message: "Invalid credentials", Err: httperrors.ErrUnauthorized}
}
return handler.writeToken(w, user)
@@ -108,7 +143,7 @@ func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portai
func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
if err != nil {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", err}
return &httperror.HandlerError{StatusCode: http.StatusUnprocessableEntity, Message: "Invalid credentials", Err: err}
}
user := &portainer.User{
@@ -116,9 +151,31 @@ func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, use
Role: portainer.StandardUserRole,
}
if ldapSettings.AdminAutoPopulate {
userGroups, err := handler.LDAPService.GetUserGroups(username, ldapSettings)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusUnprocessableEntity,
Message: "Failed to retrieve user groups from LDAP server",
Err: err,
}
}
adminGroupsMap := make(map[string]bool)
for _, adminGroup := range ldapSettings.AdminGroups {
adminGroupsMap[adminGroup] = true
}
for _, userGroup := range userGroups {
if adminGroupsMap[userGroup] {
user.Role = portainer.AdministratorRole
break
}
}
}
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}
}
err = handler.addUserIntoTeams(user, ldapSettings)
@@ -130,19 +187,21 @@ 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, composeTokenData(user))
}
return handler.persistAndWriteToken(w, tokenData)
func (handler *Handler) writeTokenForOAuth(w http.ResponseWriter, user *portainer.User, expiryTime *time.Time) *httperror.HandlerError {
token, err := handler.JWTService.GenerateTokenForOAuth(composeTokenData(user), expiryTime)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to generate JWT token", Err: err}
}
return response.JSON(w, &authenticateResponse{JWT: token})
}
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 +263,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,
}
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"log"
"net/http"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
@@ -25,7 +26,24 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
return nil
}
// @id AuthenticateOauth
func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, *time.Time, error) {
if code == "" {
return "", nil, errors.New("Invalid OAuth authorization code")
}
if settings == nil {
return "", nil, errors.New("Invalid OAuth configuration")
}
username, expiryTime, err := handler.OAuthService.Authenticate(code, settings)
if err != nil {
return "", nil, err
}
return username, expiryTime, nil
}
// @id ValidateOAuth
// @summary Authenticate with OAuth
// @tags auth
// @accept json
@@ -36,52 +54,35 @@ func (payload *oauthPayload) Validate(r *http.Request) error {
// @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")
}
if settings == nil {
return "", errors.New("Invalid OAuth configuration")
}
username, err := handler.OAuthService.Authenticate(code, settings)
if err != nil {
return "", err
}
return username, nil
}
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)
username, expiryTime, 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 +93,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,11 +105,11 @@ 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}
}
}
}
return handler.writeToken(w, user)
return handler.writeTokenForOAuth(w, user, expiryTime)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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.

View File

@@ -17,6 +17,7 @@ import (
"github.com/portainer/portainer/api/http/handler/endpointproxy"
"github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/ldap"
"github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
@@ -49,6 +50,7 @@ type Handler struct {
EndpointHandler *endpoints.Handler
EndpointProxyHandler *endpointproxy.Handler
FileHandler *file.Handler
LDAPHandler *ldap.Handler
MOTDHandler *motd.Handler
RegistryHandler *registries.Handler
ResourceControlHandler *resourcecontrols.Handler
@@ -177,6 +179,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
default:
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/ldap"):
http.StripPrefix("/api", h.LDAPHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/motd"):
http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/registries"):

View File

@@ -0,0 +1,57 @@
package ldap
import (
"net/http"
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
)
// Handler is the HTTP handler used to handle LDAP search Operations
type Handler struct {
*mux.Router
DataStore portainer.DataStore
FileService portainer.FileService
LDAPService portainer.LDAPService
}
// NewHandler returns a new Handler
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/ldap/check",
bouncer.AdminAccess(httperror.LoggerHandler(h.ldapCheck))).Methods(http.MethodPost)
h.Handle("/ldap/admin-groups",
bouncer.AdminAccess(httperror.LoggerHandler(h.ldapAdminGroups))).Methods(http.MethodPost)
h.Handle("/ldap/test",
bouncer.AdminAccess(httperror.LoggerHandler(h.ldapTestLogin))).Methods(http.MethodPost)
return h
}
func (handler *Handler) prefillSettings(ldapSettings *portainer.LDAPSettings) error {
if !ldapSettings.AnonymousMode && ldapSettings.Password == "" {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return err
}
ldapSettings.Password = settings.LDAPSettings.Password
}
if (ldapSettings.TLSConfig.TLS || ldapSettings.StartTLS) && !ldapSettings.TLSConfig.TLSSkipVerify {
caCertPath, err := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA)
if err != nil {
return err
}
ldapSettings.TLSConfig.TLSCACertPath = caCertPath
}
return nil
}

View File

@@ -0,0 +1,46 @@
package ldap
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
type checkPayload struct {
LDAPSettings portainer.LDAPSettings
}
func (payload *checkPayload) Validate(r *http.Request) error {
if len(payload.LDAPSettings.URL) == 0 {
return errors.New("Invalid LDAP URL")
}
return nil
}
// POST request on /ldap/check
func (handler *Handler) ldapCheck(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload checkPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings := &payload.LDAPSettings
err = handler.prefillSettings(settings)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch default settings", Err: err}
}
err = handler.LDAPService.TestConnectivity(settings)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to connect to LDAP server", Err: err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,47 @@
package ldap
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
)
type adminGroupsPayload struct {
LDAPSettings portainer.LDAPSettings
}
func (payload *adminGroupsPayload) Validate(r *http.Request) error {
if len(payload.LDAPSettings.URL) == 0 {
return errors.New("Invalid LDAP URLs. At least one URL is required")
}
if len(payload.LDAPSettings.AdminGroupSearchSettings) == 0 {
return errors.New("Invalid AdminGroupSearchSettings. At least one search setting is required")
}
return nil
}
func (handler *Handler) ldapAdminGroups(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload adminGroupsPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
settings := &payload.LDAPSettings
err = handler.prefillSettings(settings)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch default settings", Err: err}
}
groups, err := handler.LDAPService.SearchAdminGroups(settings)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to search admin groups", Err: err}
}
return response.JSON(w, groups)
}

View File

@@ -0,0 +1,54 @@
package ldap
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
)
type testLoginPayload struct {
LDAPSettings portainer.LDAPSettings
Username string
Password string
}
type testLoginResponse struct {
Valid bool `json:"valid"`
}
func (payload *testLoginPayload) Validate(r *http.Request) error {
if len(payload.LDAPSettings.URL) == 0 {
return errors.New("Invalid LDAP URL")
}
return nil
}
// POST request on /ldap/test
func (handler *Handler) ldapTestLogin(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload testLoginPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
settings := &payload.LDAPSettings
err = handler.prefillSettings(settings)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to fetch default settings", err}
}
err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, settings)
if err != nil && err != httperrors.ErrUnauthorized {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to test user authorization", err}
}
return response.JSON(w, &testLoginResponse{Valid: err != httperrors.ErrUnauthorized})
}

View File

@@ -5,7 +5,7 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/security"
)
@@ -18,19 +18,28 @@ func hideFields(registry *portainer.Registry) {
// Handler is the HTTP handler used to handle registry operations.
type Handler struct {
*mux.Router
requestBouncer *security.RequestBouncer
DataStore portainer.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
requestBouncer *security.RequestBouncer
DataStore portainer.DataStore
FileService portainer.FileService
ProxyManager *proxy.Manager
}
// NewHandler creates a handler to manage registry operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
}
h := newHandler(bouncer)
h.initRouter(bouncer)
return h
}
func newHandler(bouncer *security.RequestBouncer) *Handler {
return &Handler{
Router: mux.NewRouter(),
requestBouncer: bouncer,
}
}
func (h *Handler) initRouter(bouncer accessGuard) {
h.Handle("/registries",
bouncer.AdminAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost)
h.Handle("/registries",
@@ -45,5 +54,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete)
h.PathPrefix("/registries/proxies/gitlab").Handler(
bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry)))
return h
}
type accessGuard interface {
AdminAccess(h http.Handler) http.Handler
RestrictedAccess(h http.Handler) http.Handler
AuthenticatedAccess(h http.Handler) http.Handler
}

View File

@@ -2,6 +2,7 @@ package registries
import (
"errors"
"fmt"
"net/http"
"github.com/asaskevich/govalidator"
@@ -14,10 +15,12 @@ import (
type registryCreatePayload struct {
// Name that will be used to identify this registry
Name string `example:"my-registry" validate:"required"`
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4"`
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5"`
// URL or IP address of the Docker registry
URL string `example:"registry.mydomain.tld:2375" validate:"required"`
URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"`
// BaseURL required for ProGet registry
BaseURL string `example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication bool `example:"false" validate:"required"`
// Username used to authenticate against this registry. Required when Authentication is true
@@ -30,7 +33,7 @@ type registryCreatePayload struct {
Quay portainer.QuayRegistryData
}
func (payload *registryCreatePayload) Validate(r *http.Request) error {
func (payload *registryCreatePayload) Validate(_ *http.Request) error {
if govalidator.IsNull(payload.Name) {
return errors.New("Invalid registry name")
}
@@ -40,9 +43,17 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled")
}
if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry {
return errors.New("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)")
switch payload.Type {
case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry:
default:
return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)")
}
if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" {
return fmt.Errorf("BaseURL is required for registry type %d (ProGet)", portainer.ProGetRegistry)
}
return nil
}
@@ -70,6 +81,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
Type: portainer.RegistryType(payload.Type),
Name: payload.Name,
URL: payload.URL,
BaseURL: payload.BaseURL,
Authentication: payload.Authentication,
Username: payload.Username,
Password: payload.Password,

View File

@@ -0,0 +1,104 @@
package registries
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func Test_registryCreatePayload_Validate(t *testing.T) {
basePayload := registryCreatePayload{Name: "Test registry", URL: "http://example.com"}
t.Run("Can't create a ProGet registry if BaseURL is empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.ProGetRegistry
err := payload.Validate(nil)
assert.Error(t, err)
})
t.Run("Can create a GitLab registry if BaseURL is empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.GitlabRegistry
err := payload.Validate(nil)
assert.NoError(t, err)
})
t.Run("Can create a ProGet registry if BaseURL is not empty", func(t *testing.T) {
payload := basePayload
payload.Type = portainer.ProGetRegistry
payload.BaseURL = "http://example.com"
err := payload.Validate(nil)
assert.NoError(t, err)
})
}
type testRegistryService struct {
portainer.RegistryService
createRegistry func(r *portainer.Registry) error
updateRegistry func(ID portainer.RegistryID, r *portainer.Registry) error
getRegistry func(ID portainer.RegistryID) (*portainer.Registry, error)
}
type testDataStore struct {
portainer.DataStore
registry *testRegistryService
}
func (t testDataStore) Registry() portainer.RegistryService {
return t.registry
}
func (t testRegistryService) CreateRegistry(r *portainer.Registry) error {
return t.createRegistry(r)
}
func (t testRegistryService) UpdateRegistry(ID portainer.RegistryID, r *portainer.Registry) error {
return t.updateRegistry(ID, r)
}
func (t testRegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) {
return t.getRegistry(ID)
}
func (t testRegistryService) Registries() ([]portainer.Registry, error) {
return nil, nil
}
func TestHandler_registryCreate(t *testing.T) {
payload := registryCreatePayload{
Name: "Test registry",
Type: portainer.ProGetRegistry,
URL: "http://example.com",
BaseURL: "http://example.com",
Authentication: false,
Username: "username",
Password: "password",
Gitlab: portainer.GitlabRegistryData{},
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
registry := portainer.Registry{}
handler := Handler{}
handler.DataStore = testDataStore{
registry: &testRegistryService{
createRegistry: func(r *portainer.Registry) error {
registry = *r
return nil
},
},
}
handlerError := handler.registryCreate(w, r)
assert.Nil(t, handlerError)
assert.Equal(t, payload.Name, registry.Name)
assert.Equal(t, payload.Type, registry.Type)
assert.Equal(t, payload.URL, registry.URL)
assert.Equal(t, payload.BaseURL, registry.BaseURL)
assert.Equal(t, payload.Authentication, registry.Authentication)
assert.Equal(t, payload.Username, registry.Username)
assert.Equal(t, payload.Password, registry.Password)
}

View File

@@ -12,18 +12,14 @@ import (
)
type registryUpdatePayload struct {
// Name that will be used to identify this registry
Name *string `validate:"required" example:"my-registry"`
// URL or IP address of the Docker registry
URL *string `validate:"required" example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication *bool `example:"false" validate:"required"`
// Username used to authenticate against this registry. Required when Authentication is true
Username *string `example:"registry_user"`
// Password used to authenticate against this registry. required when Authentication is true
Password *string `example:"registry_password"`
UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies
Name *string `json:",omitempty" example:"my-registry" validate:"required"`
URL *string `json:",omitempty" example:"registry.mydomain.tld:2375/feed" validate:"required"`
BaseURL *string `json:",omitempty" example:"registry.mydomain.tld:2375"`
Authentication *bool `json:",omitempty" example:"false" validate:"required"`
Username *string `json:",omitempty" example:"registry_user"`
Password *string `json:",omitempty" example:"registry_password"`
UserAccessPolicies portainer.UserAccessPolicies `json:",omitempty"`
TeamAccessPolicies portainer.TeamAccessPolicies `json:",omitempty"`
Quay *portainer.QuayRegistryData
}
@@ -84,6 +80,10 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
registry.URL = *payload.URL
}
if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil {
registry.BaseURL = *payload.BaseURL
}
if payload.Authentication != nil {
if *payload.Authentication {
registry.Authentication = true

View File

@@ -0,0 +1,79 @@
package registries
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func ps(s string) *string {
return &s
}
func pb(b bool) *bool {
return &b
}
type TestBouncer struct{}
func (t TestBouncer) AdminAccess(h http.Handler) http.Handler {
return h
}
func (t TestBouncer) RestrictedAccess(h http.Handler) http.Handler {
return h
}
func (t TestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
return h
}
func TestHandler_registryUpdate(t *testing.T) {
payload := registryUpdatePayload{
Name: ps("Updated test registry"),
URL: ps("http://example.org/feed"),
BaseURL: ps("http://example.org"),
Authentication: pb(true),
Username: ps("username"),
Password: ps("password"),
}
payloadBytes, err := json.Marshal(payload)
assert.NoError(t, err)
registry := portainer.Registry{Type: portainer.ProGetRegistry, ID: 5}
r := httptest.NewRequest(http.MethodPut, "/registries/5", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
updatedRegistry := portainer.Registry{}
handler := newHandler(nil)
handler.initRouter(TestBouncer{})
handler.DataStore = testDataStore{
registry: &testRegistryService{
getRegistry: func(_ portainer.RegistryID) (*portainer.Registry, error) {
return &registry, nil
},
updateRegistry: func(ID portainer.RegistryID, r *portainer.Registry) error {
assert.Equal(t, ID, r.ID)
updatedRegistry = *r
return nil
},
},
}
handler.Router.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
// Registry type should remain intact
assert.Equal(t, registry.Type, updatedRegistry.Type)
assert.Equal(t, *payload.Name, updatedRegistry.Name)
assert.Equal(t, *payload.URL, updatedRegistry.URL)
assert.Equal(t, *payload.BaseURL, updatedRegistry.BaseURL)
assert.Equal(t, *payload.Authentication, updatedRegistry.Authentication)
assert.Equal(t, *payload.Username, updatedRegistry.Username)
assert.Equal(t, *payload.Password, updatedRegistry.Password)
}

View File

@@ -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
}

View 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)
}
}

View File

@@ -52,6 +52,9 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
return errors.New("Invalid user session timeout")
}
}
if payload.LDAPSettings.AdminAutoPopulate && len(payload.LDAPSettings.AdminGroupSearchSettings) == 0 {
return errors.New("Invalid AdminGroupSearchSettings")
}
return nil
}

View File

@@ -169,19 +169,10 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
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}
}
@@ -246,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()

View File

@@ -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}
}
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{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(endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to deploy Kubernetes stack", err}
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(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(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(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
}

View 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)
}

View File

@@ -173,21 +173,12 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
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)

View File

@@ -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)
}

View File

@@ -52,8 +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",
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",

View 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)
}

View File

@@ -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
}

View 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)
}

View File

@@ -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)

View File

@@ -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))

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -0,0 +1,180 @@
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
}
// PUT request on /api/stacks/:id/git?endpointId=<endpointId>
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
}

View File

@@ -3,11 +3,12 @@ package teams
import (
"net/http"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
)
// @id TeamDelete
@@ -29,7 +30,7 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http
}
_, err = handler.DataStore.Team().Team(portainer.TeamID(teamID))
if err == errors.ErrObjectNotFound {
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err}
@@ -45,5 +46,27 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err}
}
// update default team if deleted team was default
err = handler.updateDefaultTeamIfDeleted(portainer.TeamID(teamID))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to reset default team", err}
}
return response.Empty(w)
}
// updateDefaultTeamIfDeleted resets the default team to nil if default team was the deleted team
func (handler *Handler) updateDefaultTeamIfDeleted(teamID portainer.TeamID) error {
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return errors.Wrap(err, "failed to fetch settings")
}
if teamID != settings.OAuthSettings.DefaultTeamID {
return nil
}
settings.OAuthSettings.DefaultTeamID = 0
err = handler.DataStore.Settings().UpdateSettings(settings)
return errors.Wrap(err, "failed to update settings")
}

View File

@@ -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)
}

View File

@@ -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}
}

View File

@@ -26,6 +26,7 @@ import (
"github.com/portainer/portainer/api/http/handler/endpointproxy"
"github.com/portainer/portainer/api/http/handler/endpoints"
"github.com/portainer/portainer/api/http/handler/file"
"github.com/portainer/portainer/api/http/handler/ldap"
"github.com/portainer/portainer/api/http/handler/motd"
"github.com/portainer/portainer/api/http/handler/registries"
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
@@ -45,11 +46,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 +138,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 +146,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)
@@ -151,6 +156,11 @@ func (server *Server) Start() error {
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"))
var ldapHandler = ldap.NewHandler(requestBouncer)
ldapHandler.DataStore = server.DataStore
ldapHandler.FileService = server.FileService
ldapHandler.LDAPService = server.LDAPService
var motdHandler = motd.NewHandler(requestBouncer)
var registryHandler = registries.NewHandler(requestBouncer)
@@ -225,6 +235,7 @@ func (server *Server) Start() error {
EndpointEdgeHandler: endpointEdgeHandler,
EndpointProxyHandler: endpointProxyHandler,
FileHandler: fileHandler,
LDAPHandler: ldapHandler,
MOTDHandler: motdHandler,
RegistryHandler: registryHandler,
ResourceControlHandler: resourceControlHandler,

View File

@@ -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.

View 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
}

View 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
}

View File

@@ -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
}

View 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
}

View File

@@ -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
View 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)
}

View File

@@ -9,12 +9,7 @@ import (
)
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 +64,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 +79,50 @@ func hasUserAccessToNamespace(userID int, teamIDs []int, policies accessPolicies
return false
}
// 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
}

View File

@@ -138,6 +138,67 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
return userGroups, nil
}
// SearchGroups searches for groups with the specified settings
func (*Service) SearchAdminGroups(settings *portainer.LDAPSettings) ([]string, error) {
type groupSet map[string]bool
connection, err := createConnection(settings)
if err != nil {
return nil, err
}
defer connection.Close()
if !settings.AnonymousMode {
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return nil, err
}
}
userGroups := map[string]groupSet{}
for _, searchSettings := range settings.AdminGroupSearchSettings {
searchRequest := ldap.NewSearchRequest(
searchSettings.GroupBaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
searchSettings.GroupFilter,
[]string{"cn", searchSettings.GroupAttribute},
nil,
)
sr, err := connection.Search(searchRequest)
if err != nil {
return nil, err
}
for _, entry := range sr.Entries {
members := entry.GetAttributeValues(searchSettings.GroupAttribute)
for _, username := range members {
_, ok := userGroups[username]
if !ok {
userGroups[username] = groupSet{}
}
userGroups[username][entry.GetAttributeValue("cn")] = true
}
}
}
groupsMap := make(map[string]bool)
for _, groups := range userGroups {
for group := range groups {
groupsMap[group] = true
}
}
groups := make([]string, 0, len(groupsMap))
for group := range groupsMap {
groups = append(groups, group)
}
return groups, nil
}
// Get a list of group names for specified user from LDAP/AD
func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
groups := make([]string, 0)

View File

@@ -4,14 +4,16 @@ import (
"context"
"encoding/json"
"fmt"
"golang.org/x/oauth2"
"io/ioutil"
"log"
"mime"
"net/http"
"net/url"
"time"
"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 +25,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)
func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, *time.Time, error) {
token, err := getOAuthToken(code, configuration)
if err != nil {
log.Printf("[DEBUG] - Failed retrieving access token: %v", err)
return "", err
return "", nil, 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 "", nil, err
}
return username, &token.Expiry, 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) {

View File

@@ -3,6 +3,8 @@ package portainer
import (
"io"
"time"
gittypes "github.com/portainer/portainer/api/git/types"
)
type (
@@ -390,6 +392,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"`
@@ -463,7 +470,10 @@ type (
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
GroupSearchSettings []LDAPGroupSearchSettings `json:"GroupSearchSettings"`
// Automatically provision users and assign them to matching LDAP group names
AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"`
AutoCreateUsers bool `json:"AutoCreateUsers" example:"true"`
AdminAutoPopulate bool `json:"AdminAutoPopulate" example:"true"`
AdminGroupSearchSettings []LDAPGroupSearchSettings `json:"AdminGroupSearchSettings"`
AdminGroups []string `json:"AdminGroups"`
}
// LicenseInformation represents information about an extension license
@@ -489,6 +499,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
@@ -502,12 +514,14 @@ type (
Registry struct {
// Registry Identifier
ID RegistryID `json:"Id" example:"1"`
// Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab)
Type RegistryType `json:"Type" enums:"1,2,3,4"`
// Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet)
Type RegistryType `json:"Type" enums:"1,2,3,4,5"`
// Registry Name
Name string `json:"Name" example:"my-registry"`
// URL or IP address of the Docker registry
URL string `json:"URL" example:"registry.mydomain.tld:2375"`
// Base URL, introduced for ProGet registry
BaseURL string `json:"BaseURL" example:"registry.mydomain.tld:2375"`
// Is authentication against this registry enabled
Authentication bool `json:"Authentication" example:"true"`
// Username used to authenticate against this registry
@@ -697,6 +711,8 @@ type (
UpdateDate int64 `example:"1587399600"`
// The username which last updated this stack
UpdatedBy string `example:"bob"`
// 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)
@@ -1138,13 +1154,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)
}
@@ -1154,11 +1170,14 @@ type (
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
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(endpoint *Endpoint, data string, namespace string) (string, error)
ConvertCompose(data string) ([]byte, error)
}
// KubernetesSnapshotter represents a service used to create Kubernetes endpoint snapshots
@@ -1171,11 +1190,12 @@ type (
AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
SearchAdminGroups(settings *LDAPSettings) ([]string, error)
}
// OAuthService represents a service used to authenticate users using OAuth
OAuthService interface {
Authenticate(code string, configuration *OAuthSettings) (string, error)
Authenticate(code string, configuration *OAuthSettings) (string, *time.Time, error)
}
// RegistryService represents a service for managing registry data
@@ -1327,9 +1347,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.5.0"
APIVersion = "2.6.0"
// DBVersion is the version number of the Portainer database
DBVersion = 27
DBVersion = 32
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
ComposeSyntaxMaxVersion = "3.9"
// AssetsServerURL represents the URL of the Portainer asset server
@@ -1475,6 +1495,8 @@ const (
CustomRegistry
// GitlabRegistry represents a gitlab registry
GitlabRegistry
// ProGetRegistry represents a proget registry
ProGetRegistry
)
const (

View File

@@ -1187,17 +1187,22 @@ definitions:
TeamAccessPolicies:
$ref: '#/definitions/portainer.TeamAccessPolicies'
Type:
description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab)
description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet)
enum:
- 1
- 2
- 3
- 4
- 5
type: integer
URL:
description: URL or IP address of the Docker registry
example: registry.mydomain.tld:2375
type: string
BaseURL:
description: Base URL or IP address of the ProGet registry
example: registry.mydomain.tld:2375
type: string
UserAccessPolicies:
$ref: '#/definitions/portainer.UserAccessPolicies'
Username:
@@ -1827,18 +1832,23 @@ definitions:
type: string
type:
description: 'Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container
registry), 3 (custom registry) or 4 (Gitlab registry)'
registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)'
enum:
- 1
- 2
- 3
- 4
- 5
example: 1
type: integer
url:
description: URL or IP address of the Docker registry
example: registry.mydomain.tld:2375
type: string
baseUrl:
description: Base URL or IP address of the ProGet registry
example: registry.mydomain.tld:2375
type: string
username:
description: Username used to authenticate against this registry. Required
when Authentication is true
@@ -1871,6 +1881,10 @@ definitions:
description: URL or IP address of the Docker registry
example: registry.mydomain.tld:2375
type: string
baseUrl:
description: Base URL or IP address of the ProGet registry
example: registry.mydomain.tld:2375
type: string
userAccessPolicies:
$ref: '#/definitions/portainer.UserAccessPolicies'
username:

View File

@@ -1023,3 +1023,8 @@ json-tree .branch-preview {
overflow-y: auto;
}
/* !uib-typeahead override */
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -13,6 +13,7 @@ angular
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_LDAP', 'api/ldap')
.constant('API_ENDPOINT_STACKS', 'api/stacks')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_SUPPORT', 'api/support')
@@ -30,3 +31,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;

View File

@@ -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&regular&external&orphaned&orphanedRunning',
views: {
'content@': {
templateUrl: '~Portainer/views/stacks/edit/stack.html',

View File

@@ -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');
};
},

View File

@@ -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 = [];

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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() {

View File

@@ -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;">

View File

@@ -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() {

View File

@@ -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"

View File

@@ -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();
}
};
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;">

View File

@@ -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>

View File

@@ -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);

View File

@@ -136,78 +136,7 @@
</div>
<!-- !upload -->
<!-- repository -->
<div ng-show="$ctrl.state.Method === 'repository'">
<div class="col-sm-12 form-section-title">
Git repository
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can use the URL of a git repository.
</span>
</div>
<div class="form-group">
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
<div class="col-sm-10">
<input
type="text"
class="form-control"
ng-model="$ctrl.formValues.RepositoryURL"
id="stack_repository_url"
placeholder="https://github.com/portainer/portainer-compose"
/>
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Specify a reference of the repository using the following syntax: branches with
<code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If not specified, will use the default <code>HEAD</code> reference normally the
<code>master</code> branch.
</span>
</div>
<div class="form-group">
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository reference</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.formValues.RepositoryReferenceName" id="stack_repository_reference_name" placeholder="refs/heads/master" />
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Indicate the path to the Compose file from the root of your repository.
</span>
</div>
<div class="form-group">
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.formValues.ComposeFilePathInRepository" id="stack_repository_path" placeholder="docker-compose.yml" />
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Authentication
</label>
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.formValues.RepositoryAuthentication" /><i></i> </label>
</div>
</div>
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
<span class="col-sm-12 text-muted small">
If your git account has 2FA enabled, you may receive an
<code>authentication required</code> error when deploying your stack. In this case, you will need to provide a personal-access token instead of your password.
</span>
</div>
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
<div class="col-sm-11 col-md-5">
<input type="text" class="form-control" ng-model="$ctrl.formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser" />
</div>
<label for="repository_password" class="col-sm-1 control-label text-left">
Password
</label>
<div class="col-sm-11 col-md-5">
<input type="password" class="form-control" ng-model="$ctrl.formValues.RepositoryPassword" name="repository_password" placeholder="myPassword" />
</div>
</div>
</div>
<git-form ng-show="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
<!-- !repository -->
<!-- template -->
<div ng-show="$ctrl.state.Method === 'template'">

View File

@@ -40,6 +40,7 @@ export class CreateEdgeStackViewController {
this.onChangeTemplate = this.onChangeTemplate.bind(this);
this.onChangeTemplateAsync = this.onChangeTemplateAsync.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this);
}
async uiCanExit() {
@@ -161,6 +162,10 @@ export class CreateEdgeStackViewController {
return this.EdgeStackService.createStackFromGitRepository(name, repositoryOptions, this.formValues.Groups);
}
onChangeFormValues(values) {
this.formValues = values;
}
editorUpdate(cm) {
this.formValues.StackFileContent = cm.getValue();
this.state.isEditorDirty = true;

View File

@@ -182,6 +182,16 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([
},
};
const nodeStats = {
name: 'kubernetes.cluster.node.stats',
url: '/stats',
views: {
'content@': {
component: 'kubernetesNodeStatsView',
},
},
};
const dashboard = {
name: 'kubernetes.dashboard',
url: '/dashboard',
@@ -280,6 +290,7 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([
$stateRegistryProvider.register(dashboard);
$stateRegistryProvider.register(deploy);
$stateRegistryProvider.register(node);
$stateRegistryProvider.register(nodeStats);
$stateRegistryProvider.register(resourcePools);
$stateRegistryProvider.register(resourcePoolCreation);
$stateRegistryProvider.register(resourcePool);

View File

@@ -116,7 +116,7 @@
>
<td ng-if="!$ctrl.isPod">{{ item.PodName }}</td>
<td>{{ item.Name }}</td>
<td>{{ item.Image }}</td>
<td title="{{ item.Image }}">{{ item.Image | truncate: 64 }}</td>
<td>{{ item.ImagePullPolicy }}</td>
<td
><span class="label label-{{ item.Status | kubernetesPodStatusColor }}">{{ item.Status }}</span></td

View File

@@ -147,8 +147,8 @@
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
</td>
<td
>{{ item.Image }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
<td title="{{ item.Image }}"
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
>
<td>{{ item.ApplicationType | kubernetesApplicationTypeText }}</td>
<td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.POD">

View File

@@ -92,7 +92,7 @@
><a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a></td
>
<td>{{ item.StackName }}</td>
<td>{{ item.Image }}</td>
<td title="{{ item.Image }}">{{ item.Image | truncate: 64 }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>

View File

@@ -118,8 +118,8 @@
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
</td>
<td
>{{ item.Image }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
<td title="{{ item.Image }}"
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
>
<td>{{ item.CPU | kubernetesApplicationCPUValue }}</td>
<td>{{ item.Memory | humansize }}</td>

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