Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b044aa9a84 | |||
| d9262d4b7f | |||
| efc3154617 | |||
| d68708add7 | |||
| 9bef7cd69f | |||
| ff82d4320f | |||
| 7ee16d1e51 | |||
| 6c6171c1f4 | |||
| d06667218f | |||
| 4a291247ac | |||
| 9ceb3a8051 | |||
| 1b6b4733bd | |||
| b9e535d7a5 | |||
| 407f0f5807 | |||
| ade66414a4 | |||
| 693f1319a4 | |||
| 42347d714f | |||
| a028413496 | |||
| 86e5ca57e9 | |||
| 1d150414d9 | |||
| f8451e944a | |||
| b5629c5b1a | |||
| 34d40e4876 | |||
| c4e75fc858 | |||
| 77503b448e | |||
| 25f325bbaa | |||
| 711128284e | |||
| 514da445a4 | |||
| 089d2cf0fe | |||
| aa32213f7c | |||
| 11feae19b7 | |||
| ddd804ee2e | |||
| c97f1d24cd | |||
| 4a49942ae5 | |||
| c9ccdaaea4 | |||
| f9218768c1 | |||
| 0af3c44e9a | |||
| 730925b286 | |||
| 7eaaf9a2a7 | |||
| 925326e8aa | |||
| dc05ad4c8c | |||
| 8ec7b4fcf5 | |||
| dc48fa685f | |||
| 7727fc6dcb | |||
| 5785ba5f4a | |||
| e110986728 | |||
| 587e2fa673 | |||
| 80827935da | |||
| f3a1250b27 | |||
| 79121f9977 | |||
| f678d05088 | |||
| c6341eead0 | |||
| 3e99fae070 | |||
| 249bcf5bac | |||
| 9c10a1def2 | |||
| 93120d23c6 | |||
| b59dd03b43 | |||
| 1263866548 | |||
| 0bdcff09f8 | |||
| ca9d9b9a77 | |||
| 6cfffb38f9 | |||
| e2979a631a | |||
| 7b924bde83 | |||
| 6bf7c90634 | |||
| f5749f82d8 | |||
| 8413b79fa9 | |||
| dffcdcc148 | |||
| 4b53c3422f | |||
| 3fb668474d | |||
| ff628bb438 | |||
| 819d0f6a16 | |||
| 601ae9daf2 | |||
| 09409804af | |||
| 1bccd521f8 | |||
| 5e2b3c1d07 | |||
| 210bdc8022 | |||
| 3cb96235b7 | |||
| d695657711 | |||
| 5131c4c10b | |||
| 912ebf4672 | |||
| dd0fc6fab8 | |||
| 910136ee9b | |||
| 61f652da04 | |||
| a2b4cd8050 | |||
| 774738110b | |||
| 851a1ac64c | |||
| d653391cdd | |||
| f96b70841f | |||
| 8d4807c9e7 | |||
| 87825f7ebb | |||
| be4f3ec81d | |||
| 56604a5445 | |||
| c0d282e85b | |||
| b9b32f0526 | |||
| be4beacdf7 | |||
| bf6b398a27 | |||
| 9a0f0a9701 | |||
| ef8edfb67b | |||
| 0e8da2db18 | |||
| e65d132b3d | |||
| 13b2fcffd2 | |||
| c1e486bf43 | |||
| 8c68e92e74 | |||
| a6ef27164c | |||
| d50a650686 | |||
| 35dd3916dd | |||
| 1a28e1091c | |||
| 124458c3d6 |
@@ -25,6 +25,7 @@ type Store struct {
|
||||
SettingsService *SettingsService
|
||||
RegistryService *RegistryService
|
||||
DockerHubService *DockerHubService
|
||||
StackService *StackService
|
||||
|
||||
db *bolt.DB
|
||||
checkForDataMigration bool
|
||||
@@ -41,6 +42,7 @@ const (
|
||||
settingsBucketName = "settings"
|
||||
registryBucketName = "registries"
|
||||
dockerhubBucketName = "dockerhub"
|
||||
stackBucketName = "stacks"
|
||||
)
|
||||
|
||||
// NewStore initializes a new Store and the associated services
|
||||
@@ -56,6 +58,7 @@ func NewStore(storePath string) (*Store, error) {
|
||||
SettingsService: &SettingsService{},
|
||||
RegistryService: &RegistryService{},
|
||||
DockerHubService: &DockerHubService{},
|
||||
StackService: &StackService{},
|
||||
}
|
||||
store.UserService.store = store
|
||||
store.TeamService.store = store
|
||||
@@ -66,6 +69,7 @@ func NewStore(storePath string) (*Store, error) {
|
||||
store.SettingsService.store = store
|
||||
store.RegistryService.store = store
|
||||
store.DockerHubService.store = store
|
||||
store.StackService.store = store
|
||||
|
||||
_, err := os.Stat(storePath + "/" + databaseFileName)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
@@ -91,7 +95,7 @@ func (store *Store) Open() error {
|
||||
|
||||
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
|
||||
resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
|
||||
registryBucketName, dockerhubBucketName}
|
||||
registryBucketName, dockerhubBucketName, stackBucketName}
|
||||
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
|
||||
|
||||
@@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
|
||||
return json.Unmarshal(data, endpoint)
|
||||
}
|
||||
|
||||
// MarshalStack encodes a stack to binary format.
|
||||
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
|
||||
return json.Marshal(stack)
|
||||
}
|
||||
|
||||
// UnmarshalStack decodes a stack from a binary data.
|
||||
func UnmarshalStack(data []byte, stack *portainer.Stack) error {
|
||||
return json.Unmarshal(data, stack)
|
||||
}
|
||||
|
||||
// MarshalRegistry encodes a registry to binary format.
|
||||
func MarshalRegistry(registry *portainer.Registry) ([]byte, error) {
|
||||
return json.Marshal(registry)
|
||||
|
||||
@@ -2,7 +2,7 @@ package bolt
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
func (m *Migrator) updateSettingsToVersion3() error {
|
||||
func (m *Migrator) updateSettingsToDBVersion3() error {
|
||||
legacySettings, err := m.SettingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package bolt
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
func (m *Migrator) updateEndpointsToDBVersion4() error {
|
||||
legacyEndpoints, err := m.EndpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range legacyEndpoints {
|
||||
endpoint.TLSConfig = portainer.TLSConfiguration{}
|
||||
if endpoint.TLS {
|
||||
endpoint.TLSConfig.TLS = true
|
||||
endpoint.TLSConfig.TLSSkipVerify = false
|
||||
endpoint.TLSConfig.TLSCACertPath = endpoint.TLSCACertPath
|
||||
endpoint.TLSConfig.TLSCertPath = endpoint.TLSCertPath
|
||||
endpoint.TLSConfig.TLSKeyPath = endpoint.TLSKeyPath
|
||||
}
|
||||
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package bolt
|
||||
|
||||
func (m *Migrator) updateSettingsToVersion5() error {
|
||||
legacySettings, err := m.SettingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacySettings.AllowBindMountsForRegularUsers = true
|
||||
|
||||
err = m.SettingsService.StoreSettings(legacySettings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package bolt
|
||||
|
||||
func (m *Migrator) updateSettingsToVersion6() error {
|
||||
legacySettings, err := m.SettingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacySettings.AllowPrivilegedModeForRegularUsers = true
|
||||
|
||||
err = m.SettingsService.StoreSettings(legacySettings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
+28
-4
@@ -30,7 +30,7 @@ func NewMigrator(store *Store, version int) *Migrator {
|
||||
func (m *Migrator) Migrate() error {
|
||||
|
||||
// Portainer < 1.12
|
||||
if m.CurrentDBVersion == 0 {
|
||||
if m.CurrentDBVersion < 1 {
|
||||
err := m.updateAdminUserToDBVersion1()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -38,7 +38,7 @@ func (m *Migrator) Migrate() error {
|
||||
}
|
||||
|
||||
// Portainer 1.12.x
|
||||
if m.CurrentDBVersion == 1 {
|
||||
if m.CurrentDBVersion < 2 {
|
||||
err := m.updateResourceControlsToDBVersion2()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -50,8 +50,32 @@ func (m *Migrator) Migrate() error {
|
||||
}
|
||||
|
||||
// Portainer 1.13.x
|
||||
if m.CurrentDBVersion == 2 {
|
||||
err := m.updateSettingsToVersion3()
|
||||
if m.CurrentDBVersion < 3 {
|
||||
err := m.updateSettingsToDBVersion3()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 1.14.0
|
||||
if m.CurrentDBVersion < 4 {
|
||||
err := m.updateEndpointsToDBVersion4()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1235
|
||||
if m.CurrentDBVersion < 5 {
|
||||
err := m.updateSettingsToVersion5()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/portainer/portainer/issues/1236
|
||||
if m.CurrentDBVersion < 6 {
|
||||
err := m.updateSettingsToVersion6()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package bolt
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
// StackService represents a service for managing stacks.
|
||||
type StackService struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// Stack returns a stack object by ID.
|
||||
func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, error) {
|
||||
var data []byte
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(stackBucketName))
|
||||
value := bucket.Get([]byte(ID))
|
||||
if value == nil {
|
||||
return portainer.ErrStackNotFound
|
||||
}
|
||||
|
||||
data = make([]byte, len(value))
|
||||
copy(data, value)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var stack portainer.Stack
|
||||
err = internal.UnmarshalStack(data, &stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stack, nil
|
||||
}
|
||||
|
||||
// Stacks returns an array containing all the stacks.
|
||||
func (service *StackService) Stacks() ([]portainer.Stack, error) {
|
||||
var stacks = make([]portainer.Stack, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(stackBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var stack portainer.Stack
|
||||
err := internal.UnmarshalStack(v, &stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stacks = append(stacks, stack)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stacks, nil
|
||||
}
|
||||
|
||||
// StacksBySwarmID return an array containing all the stacks related to the specified Swarm ID.
|
||||
func (service *StackService) StacksBySwarmID(id string) ([]portainer.Stack, error) {
|
||||
var stacks = make([]portainer.Stack, 0)
|
||||
err := service.store.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(stackBucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var stack portainer.Stack
|
||||
err := internal.UnmarshalStack(v, &stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stack.SwarmID == id {
|
||||
stacks = append(stacks, stack)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stacks, nil
|
||||
}
|
||||
|
||||
// CreateStack creates a new stack.
|
||||
func (service *StackService) CreateStack(stack *portainer.Stack) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(stackBucketName))
|
||||
|
||||
data, err := internal.MarshalStack(stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = bucket.Put([]byte(stack.ID), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateStack updates an stack.
|
||||
func (service *StackService) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error {
|
||||
data, err := internal.MarshalStack(stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(stackBucketName))
|
||||
err = bucket.Put([]byte(ID), data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteStack deletes an stack.
|
||||
func (service *StackService) DeleteStack(ID portainer.StackID) error {
|
||||
return service.store.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(stackBucketName))
|
||||
err := bucket.Delete([]byte(ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
+24
-8
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
@@ -16,12 +17,13 @@ import (
|
||||
type Service struct{}
|
||||
|
||||
const (
|
||||
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
|
||||
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
|
||||
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
|
||||
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
|
||||
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
|
||||
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password")
|
||||
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
|
||||
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
|
||||
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
|
||||
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
|
||||
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
|
||||
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
|
||||
errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file")
|
||||
)
|
||||
|
||||
// ParseFlags parse the CLI flags and return a portainer.Flags struct
|
||||
@@ -36,7 +38,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
|
||||
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
|
||||
NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAuth).Bool(),
|
||||
NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(),
|
||||
TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(),
|
||||
TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(),
|
||||
TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(),
|
||||
@@ -45,6 +47,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
|
||||
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
|
||||
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
|
||||
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
|
||||
// Deprecated flags
|
||||
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
|
||||
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
|
||||
@@ -52,6 +55,15 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||
}
|
||||
|
||||
kingpin.Parse()
|
||||
|
||||
if !filepath.IsAbs(*flags.Assets) {
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
*flags.Assets = filepath.Join(filepath.Dir(ex), *flags.Assets)
|
||||
}
|
||||
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
@@ -77,10 +89,14 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if *flags.NoAuth && (*flags.AdminPassword != "") {
|
||||
if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") {
|
||||
return errNoAuthExcludeAdminPassword
|
||||
}
|
||||
|
||||
if *flags.AdminPassword != "" && *flags.AdminPasswordFile != "" {
|
||||
return errAdminPassExcludeAdminPassFile
|
||||
}
|
||||
|
||||
displayDeprecationWarnings(*flags.Templates, *flags.Logo, *flags.Labels)
|
||||
|
||||
return nil
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ package cli
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "."
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLSVerify = "false"
|
||||
|
||||
@@ -3,7 +3,7 @@ package cli
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "."
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLSVerify = "false"
|
||||
|
||||
+61
-17
@@ -6,7 +6,9 @@ import (
|
||||
"github.com/portainer/portainer/cli"
|
||||
"github.com/portainer/portainer/cron"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
"github.com/portainer/portainer/exec"
|
||||
"github.com/portainer/portainer/file"
|
||||
"github.com/portainer/portainer/git"
|
||||
"github.com/portainer/portainer/http"
|
||||
"github.com/portainer/portainer/jwt"
|
||||
"github.com/portainer/portainer/ldap"
|
||||
@@ -54,6 +56,10 @@ func initStore(dataStorePath string) *bolt.Store {
|
||||
return store
|
||||
}
|
||||
|
||||
func initStackManager(assetsPath string) portainer.StackManager {
|
||||
return exec.NewStackManager(assetsPath)
|
||||
}
|
||||
|
||||
func initJWTService(authenticationEnabled bool) portainer.JWTService {
|
||||
if authenticationEnabled {
|
||||
jwtService, err := jwt.NewService()
|
||||
@@ -73,6 +79,10 @@ func initLDAPService() portainer.LDAPService {
|
||||
return &ldap.Service{}
|
||||
}
|
||||
|
||||
func initGitService() portainer.GitService {
|
||||
return &git.Service{}
|
||||
}
|
||||
|
||||
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
|
||||
authorizeEndpointMgmt := true
|
||||
if externalEnpointFile != "" {
|
||||
@@ -117,7 +127,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
||||
if err == portainer.ErrSettingsNotFound {
|
||||
settings := &portainer.Settings{
|
||||
LogoURL: *flags.Logo,
|
||||
DisplayExternalContributors: true,
|
||||
DisplayExternalContributors: false,
|
||||
AuthenticationMethod: portainer.AuthenticationInternal,
|
||||
LDAPSettings: portainer.LDAPSettings{
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
@@ -125,6 +135,8 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
||||
portainer.LDAPSearchSettings{},
|
||||
},
|
||||
},
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
}
|
||||
|
||||
if *flags.Templates != "" {
|
||||
@@ -163,12 +175,16 @@ func main() {
|
||||
store := initStore(*flags.Data)
|
||||
defer store.Close()
|
||||
|
||||
stackManager := initStackManager(*flags.Assets)
|
||||
|
||||
jwtService := initJWTService(!*flags.NoAuth)
|
||||
|
||||
cryptoService := initCryptoService()
|
||||
|
||||
ldapService := initLDAPService()
|
||||
|
||||
gitService := initGitService()
|
||||
|
||||
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
|
||||
|
||||
err := initSettings(store.SettingsService, flags)
|
||||
@@ -184,19 +200,21 @@ func main() {
|
||||
applicationStatus := initStatus(authorizeEndpointMgmt, flags)
|
||||
|
||||
if *flags.Endpoint != "" {
|
||||
var endpoints []portainer.Endpoint
|
||||
endpoints, err := store.EndpointService.Endpoints()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(endpoints) == 0 {
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: "primary",
|
||||
URL: *flags.Endpoint,
|
||||
TLS: *flags.TLSVerify,
|
||||
TLSCACertPath: *flags.TLSCacert,
|
||||
TLSCertPath: *flags.TLSCert,
|
||||
TLSKeyPath: *flags.TLSKey,
|
||||
Name: "primary",
|
||||
URL: *flags.Endpoint,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: *flags.TLSVerify,
|
||||
TLSSkipVerify: false,
|
||||
TLSCACertPath: *flags.TLSCacert,
|
||||
TLSCertPath: *flags.TLSCert,
|
||||
TLSKeyPath: *flags.TLSKey,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
}
|
||||
@@ -209,17 +227,40 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
if *flags.AdminPassword != "" {
|
||||
log.Printf("Creating admin user with password hash %s", *flags.AdminPassword)
|
||||
user := &portainer.User{
|
||||
Username: "admin",
|
||||
Role: portainer.AdministratorRole,
|
||||
Password: *flags.AdminPassword,
|
||||
}
|
||||
err := store.UserService.CreateUser(user)
|
||||
adminPasswordHash := ""
|
||||
if *flags.AdminPasswordFile != "" {
|
||||
content, err := fileService.GetFileContent(*flags.AdminPasswordFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
adminPasswordHash, err = cryptoService.Hash(content)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else if *flags.AdminPassword != "" {
|
||||
adminPasswordHash = *flags.AdminPassword
|
||||
}
|
||||
|
||||
if adminPasswordHash != "" {
|
||||
users, err := store.UserService.UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if len(users) == 0 {
|
||||
log.Printf("Creating admin user with password hash %s", adminPasswordHash)
|
||||
user := &portainer.User{
|
||||
Username: "admin",
|
||||
Role: portainer.AdministratorRole,
|
||||
Password: adminPasswordHash,
|
||||
}
|
||||
err := store.UserService.CreateUser(user)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
log.Println("Instance already has an administrator user defined. Skipping admin password related flags.")
|
||||
}
|
||||
}
|
||||
|
||||
var server portainer.Server = &http.Server{
|
||||
@@ -236,16 +277,19 @@ func main() {
|
||||
SettingsService: store.SettingsService,
|
||||
RegistryService: store.RegistryService,
|
||||
DockerHubService: store.DockerHubService,
|
||||
StackService: store.StackService,
|
||||
StackManager: stackManager,
|
||||
CryptoService: cryptoService,
|
||||
JWTService: jwtService,
|
||||
FileService: fileService,
|
||||
LDAPService: ldapService,
|
||||
GitService: gitService,
|
||||
SSL: *flags.SSL,
|
||||
SSLCert: *flags.SSLCert,
|
||||
SSLKey: *flags.SSLKey,
|
||||
}
|
||||
|
||||
log.Printf("Starting Portainer on %s", *flags.Addr)
|
||||
log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr)
|
||||
err = server.Start()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
+52
-15
@@ -22,6 +22,16 @@ type (
|
||||
endpointsToUpdate []*portainer.Endpoint
|
||||
endpointsToDelete []*portainer.Endpoint
|
||||
}
|
||||
|
||||
fileEndpoint struct {
|
||||
Name string `json:"Name"`
|
||||
URL string `json:"URL"`
|
||||
TLS bool `json:"TLS,omitempty"`
|
||||
TLSSkipVerify bool `json:"TLSSkipVerify,omitempty"`
|
||||
TLSCACert string `json:"TLSCACert,omitempty"`
|
||||
TLSCert string `json:"TLSCert,omitempty"`
|
||||
TLSKey string `json:"TLSKey,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -55,6 +65,28 @@ func isValidEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func convertFileEndpoints(fileEndpoints []fileEndpoint) []portainer.Endpoint {
|
||||
convertedEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
for _, e := range fileEndpoints {
|
||||
endpoint := portainer.Endpoint{
|
||||
Name: e.Name,
|
||||
URL: e.URL,
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
}
|
||||
if e.TLS {
|
||||
endpoint.TLSConfig.TLS = true
|
||||
endpoint.TLSConfig.TLSSkipVerify = e.TLSSkipVerify
|
||||
endpoint.TLSConfig.TLSCACertPath = e.TLSCACert
|
||||
endpoint.TLSConfig.TLSCertPath = e.TLSCert
|
||||
endpoint.TLSConfig.TLSKeyPath = e.TLSKey
|
||||
}
|
||||
convertedEndpoints = append(convertedEndpoints, endpoint)
|
||||
}
|
||||
|
||||
return convertedEndpoints
|
||||
}
|
||||
|
||||
func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint) int {
|
||||
for idx, v := range endpoints {
|
||||
if endpoint.Name == v.Name && isValidEndpoint(&v) {
|
||||
@@ -66,22 +98,25 @@ func endpointExists(endpoint *portainer.Endpoint, endpoints []portainer.Endpoint
|
||||
|
||||
func mergeEndpointIfRequired(original, updated *portainer.Endpoint) *portainer.Endpoint {
|
||||
var endpoint *portainer.Endpoint
|
||||
if original.URL != updated.URL || original.TLS != updated.TLS ||
|
||||
(updated.TLS && original.TLSCACertPath != updated.TLSCACertPath) ||
|
||||
(updated.TLS && original.TLSCertPath != updated.TLSCertPath) ||
|
||||
(updated.TLS && original.TLSKeyPath != updated.TLSKeyPath) {
|
||||
if original.URL != updated.URL || original.TLSConfig.TLS != updated.TLSConfig.TLS ||
|
||||
(updated.TLSConfig.TLS && original.TLSConfig.TLSSkipVerify != updated.TLSConfig.TLSSkipVerify) ||
|
||||
(updated.TLSConfig.TLS && original.TLSConfig.TLSCACertPath != updated.TLSConfig.TLSCACertPath) ||
|
||||
(updated.TLSConfig.TLS && original.TLSConfig.TLSCertPath != updated.TLSConfig.TLSCertPath) ||
|
||||
(updated.TLSConfig.TLS && original.TLSConfig.TLSKeyPath != updated.TLSConfig.TLSKeyPath) {
|
||||
endpoint = original
|
||||
endpoint.URL = updated.URL
|
||||
if updated.TLS {
|
||||
endpoint.TLS = true
|
||||
endpoint.TLSCACertPath = updated.TLSCACertPath
|
||||
endpoint.TLSCertPath = updated.TLSCertPath
|
||||
endpoint.TLSKeyPath = updated.TLSKeyPath
|
||||
if updated.TLSConfig.TLS {
|
||||
endpoint.TLSConfig.TLS = true
|
||||
endpoint.TLSConfig.TLSSkipVerify = updated.TLSConfig.TLSSkipVerify
|
||||
endpoint.TLSConfig.TLSCACertPath = updated.TLSConfig.TLSCACertPath
|
||||
endpoint.TLSConfig.TLSCertPath = updated.TLSConfig.TLSCertPath
|
||||
endpoint.TLSConfig.TLSKeyPath = updated.TLSConfig.TLSKeyPath
|
||||
} else {
|
||||
endpoint.TLS = false
|
||||
endpoint.TLSCACertPath = ""
|
||||
endpoint.TLSCertPath = ""
|
||||
endpoint.TLSKeyPath = ""
|
||||
endpoint.TLSConfig.TLS = false
|
||||
endpoint.TLSConfig.TLSSkipVerify = false
|
||||
endpoint.TLSConfig.TLSCACertPath = ""
|
||||
endpoint.TLSConfig.TLSCertPath = ""
|
||||
endpoint.TLSConfig.TLSKeyPath = ""
|
||||
}
|
||||
}
|
||||
return endpoint
|
||||
@@ -141,7 +176,7 @@ func (job endpointSyncJob) Sync() error {
|
||||
return err
|
||||
}
|
||||
|
||||
var fileEndpoints []portainer.Endpoint
|
||||
var fileEndpoints []fileEndpoint
|
||||
err = json.Unmarshal(data, &fileEndpoints)
|
||||
if endpointSyncError(err, job.logger) {
|
||||
return err
|
||||
@@ -156,7 +191,9 @@ func (job endpointSyncJob) Sync() error {
|
||||
return err
|
||||
}
|
||||
|
||||
sync := job.prepareSyncData(storedEndpoints, fileEndpoints)
|
||||
convertedFileEndpoints := convertFileEndpoints(fileEndpoints)
|
||||
|
||||
sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints)
|
||||
if sync.requireSync() {
|
||||
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
|
||||
if endpointSyncError(err, job.logger) {
|
||||
|
||||
+16
-11
@@ -4,31 +4,36 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
|
||||
func CreateTLSConfiguration(caCertPath, certPath, keyPath string, skipTLSVerify bool) (*tls.Config, error) {
|
||||
func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) {
|
||||
TLSConfig := &tls.Config{}
|
||||
|
||||
config := &tls.Config{}
|
||||
|
||||
if certPath != "" && keyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if config.TLSCertPath != "" && config.TLSKeyPath != "" {
|
||||
cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.Certificates = []tls.Certificate{cert}
|
||||
|
||||
TLSConfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
if caCertPath != "" {
|
||||
caCert, err := ioutil.ReadFile(caCertPath)
|
||||
if !config.TLSSkipVerify {
|
||||
caCert, err := ioutil.ReadFile(config.TLSCACertPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
caCertPool := x509.NewCertPool()
|
||||
caCertPool.AppendCertsFromPEM(caCert)
|
||||
config.RootCAs = caCertPool
|
||||
|
||||
TLSConfig.RootCAs = caCertPool
|
||||
}
|
||||
|
||||
config.InsecureSkipVerify = skipTLSVerify
|
||||
return config, nil
|
||||
TLSConfig.InsecureSkipVerify = config.TLSSkipVerify
|
||||
|
||||
return TLSConfig, nil
|
||||
}
|
||||
|
||||
+11
-2
@@ -13,8 +13,10 @@ const (
|
||||
const (
|
||||
ErrUserNotFound = Error("User not found")
|
||||
ErrUserAlreadyExists = Error("User already exists")
|
||||
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed.")
|
||||
ErrAdminAlreadyInitialized = Error("Admin user already initialized")
|
||||
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed")
|
||||
ErrAdminAlreadyInitialized = Error("An administrator user already exists")
|
||||
ErrCannotRemoveAdmin = Error("Cannot remove the default administrator account")
|
||||
ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator")
|
||||
)
|
||||
|
||||
// Team errors.
|
||||
@@ -48,6 +50,13 @@ const (
|
||||
ErrRegistryAlreadyExists = Error("A registry is already defined for this URL")
|
||||
)
|
||||
|
||||
// Stack errors
|
||||
const (
|
||||
ErrStackNotFound = Error("Stack not found")
|
||||
ErrStackAlreadyExists = Error("A stack already exists with this name")
|
||||
ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository")
|
||||
)
|
||||
|
||||
// Version errors.
|
||||
const (
|
||||
ErrDBVersionNotFound = Error("DB version not found")
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// StackManager represents a service for managing stacks.
|
||||
type StackManager struct {
|
||||
binaryPath string
|
||||
}
|
||||
|
||||
// NewStackManager initializes a new StackManager service.
|
||||
func NewStackManager(binaryPath string) *StackManager {
|
||||
return &StackManager{
|
||||
binaryPath: binaryPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Login executes the docker login command against a list of registries (including DockerHub).
|
||||
func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
for _, registry := range registries {
|
||||
if registry.Authentication {
|
||||
registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL)
|
||||
err := runCommandAndCaptureStdErr(command, registryArgs, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if dockerhub.Authentication {
|
||||
dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password)
|
||||
err := runCommandAndCaptureStdErr(command, dockerhubArgs, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout executes the docker logout command.
|
||||
func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
args = append(args, "logout")
|
||||
return runCommandAndCaptureStdErr(command, args, nil)
|
||||
}
|
||||
|
||||
// Deploy executes the docker stack deploy command.
|
||||
func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
|
||||
|
||||
env := make([]string, 0)
|
||||
for _, envvar := range stack.Env {
|
||||
env = append(env, envvar.Name+"="+envvar.Value)
|
||||
}
|
||||
|
||||
return runCommandAndCaptureStdErr(command, args, env)
|
||||
}
|
||||
|
||||
// Remove executes the docker stack rm command.
|
||||
func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
|
||||
args = append(args, "stack", "rm", stack.Name)
|
||||
return runCommandAndCaptureStdErr(command, args, nil)
|
||||
}
|
||||
|
||||
func runCommandAndCaptureStdErr(command string, args []string, env []string) error {
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if env != nil {
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return portainer.Error(stderr.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint) (string, []string) {
|
||||
// Assume Linux as a default
|
||||
command := path.Join(binaryPath, "docker")
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(binaryPath, "docker.exe")
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "-H", endpoint.URL)
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
args = append(args, "--tls")
|
||||
|
||||
if !endpoint.TLSConfig.TLSSkipVerify {
|
||||
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
|
||||
}
|
||||
|
||||
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
|
||||
args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
return command, args
|
||||
}
|
||||
+100
-1
@@ -1,6 +1,9 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
|
||||
"io"
|
||||
@@ -19,6 +22,10 @@ const (
|
||||
TLSCertFile = "cert.pem"
|
||||
// TLSKeyFile represents the name on disk for a TLS key file.
|
||||
TLSKeyFile = "key.pem"
|
||||
// ComposeStorePath represents the subfolder where compose files are stored in the file store folder.
|
||||
ComposeStorePath = "compose"
|
||||
// ComposeFileDefaultName represents the default name of a compose file.
|
||||
ComposeFileDefaultName = "docker-compose.yml"
|
||||
)
|
||||
|
||||
// Service represents a service for managing files and directories.
|
||||
@@ -48,9 +55,65 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = service.createDirectoryInStoreIfNotExist(ComposeStorePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// RemoveDirectory removes a directory on the filesystem.
|
||||
func (service *Service) RemoveDirectory(directoryPath string) error {
|
||||
return os.RemoveAll(directoryPath)
|
||||
}
|
||||
|
||||
// GetStackProjectPath returns the absolute path on the FS for a stack based
|
||||
// on its identifier.
|
||||
func (service *Service) GetStackProjectPath(stackIdentifier string) string {
|
||||
return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier)
|
||||
}
|
||||
|
||||
// StoreStackFileFromString creates a subfolder in the ComposeStorePath and stores a new file using the content from a string.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) StoreStackFileFromString(stackIdentifier, stackFileContent string) (string, error) {
|
||||
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
|
||||
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
|
||||
data := []byte(stackFileContent)
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
err = service.createFileInStore(composeFilePath, r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path.Join(service.fileStorePath, stackStorePath), nil
|
||||
}
|
||||
|
||||
// StoreStackFileFromReader creates a subfolder in the ComposeStorePath and stores a new file using the content from an io.Reader.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error) {
|
||||
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
|
||||
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
|
||||
|
||||
err = service.createFileInStore(composeFilePath, r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path.Join(service.fileStorePath, stackStorePath), nil
|
||||
}
|
||||
|
||||
// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r.
|
||||
func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error {
|
||||
storePath := path.Join(TLSStorePath, folder)
|
||||
@@ -95,7 +158,7 @@ func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSF
|
||||
return path.Join(service.fileStorePath, TLSStorePath, folder, fileName), nil
|
||||
}
|
||||
|
||||
// DeleteTLSFiles deletes a folder containing the TLS files for an endpoint.
|
||||
// DeleteTLSFiles deletes a folder in the TLS store path.
|
||||
func (service *Service) DeleteTLSFiles(folder string) error {
|
||||
storePath := path.Join(service.fileStorePath, TLSStorePath, folder)
|
||||
err := os.RemoveAll(storePath)
|
||||
@@ -105,6 +168,39 @@ func (service *Service) DeleteTLSFiles(folder string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTLSFile deletes a specific TLS file from a folder.
|
||||
func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileType) error {
|
||||
var fileName string
|
||||
switch fileType {
|
||||
case portainer.TLSFileCA:
|
||||
fileName = TLSCACertFile
|
||||
case portainer.TLSFileCert:
|
||||
fileName = TLSCertFile
|
||||
case portainer.TLSFileKey:
|
||||
fileName = TLSKeyFile
|
||||
default:
|
||||
return portainer.ErrUndefinedTLSFileType
|
||||
}
|
||||
|
||||
filePath := path.Join(service.fileStorePath, TLSStorePath, folder, fileName)
|
||||
|
||||
err := os.Remove(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileContent returns a string content from file.
|
||||
func (service *Service) GetFileContent(filePath string) (string, error) {
|
||||
content, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system.
|
||||
func (service *Service) createDirectoryInStoreIfNotExist(name string) error {
|
||||
path := path.Join(service.fileStorePath, name)
|
||||
@@ -128,14 +224,17 @@ func createDirectoryIfNotExist(path string, mode uint32) error {
|
||||
// createFile creates a new file in the file store with the content from r.
|
||||
func (service *Service) createFileInStore(filePath string, r io.Reader) error {
|
||||
path := path.Join(service.fileStorePath, filePath)
|
||||
|
||||
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"gopkg.in/src-d/go-git.v4"
|
||||
)
|
||||
|
||||
// Service represents a service for managing Git.
|
||||
type Service struct{}
|
||||
|
||||
// NewService initializes a new service.
|
||||
func NewService(dataStorePath string) (*Service, error) {
|
||||
service := &Service{}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// CloneRepository clones a git repository using the specified URL in the specified
|
||||
// destination folder.
|
||||
func (service *Service) CloneRepository(url, destination string) error {
|
||||
_, err := git.PlainClone(destination, false, &git.CloneOptions{
|
||||
URL: url,
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -22,7 +22,7 @@ type DockerHubHandler struct {
|
||||
DockerHubService portainer.DockerHubService
|
||||
}
|
||||
|
||||
// NewDockerHubHandler returns a new instance of NewDockerHubHandler.
|
||||
// NewDockerHubHandler returns a new instance of DockerHubHandler.
|
||||
func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler {
|
||||
h := &DockerHubHandler{
|
||||
Router: mux.NewRouter(),
|
||||
|
||||
@@ -57,10 +57,12 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag
|
||||
|
||||
type (
|
||||
postEndpointsRequest struct {
|
||||
Name string `valid:"required"`
|
||||
URL string `valid:"required"`
|
||||
PublicURL string `valid:"-"`
|
||||
TLS bool
|
||||
Name string `valid:"required"`
|
||||
URL string `valid:"required"`
|
||||
PublicURL string `valid:"-"`
|
||||
TLS bool
|
||||
TLSSkipVerify bool
|
||||
TLSSkipClientVerify bool
|
||||
}
|
||||
|
||||
postEndpointsResponse struct {
|
||||
@@ -73,10 +75,12 @@ type (
|
||||
}
|
||||
|
||||
putEndpointsRequest struct {
|
||||
Name string `valid:"-"`
|
||||
URL string `valid:"-"`
|
||||
PublicURL string `valid:"-"`
|
||||
TLS bool `valid:"-"`
|
||||
Name string `valid:"-"`
|
||||
URL string `valid:"-"`
|
||||
PublicURL string `valid:"-"`
|
||||
TLS bool `valid:"-"`
|
||||
TLSSkipVerify bool `valid:"-"`
|
||||
TLSSkipClientVerify bool `valid:"-"`
|
||||
}
|
||||
)
|
||||
|
||||
@@ -123,10 +127,13 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
PublicURL: req.PublicURL,
|
||||
TLS: req.TLS,
|
||||
Name: req.Name,
|
||||
URL: req.URL,
|
||||
PublicURL: req.PublicURL,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: req.TLS,
|
||||
TLSSkipVerify: req.TLSSkipVerify,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
}
|
||||
@@ -139,12 +146,19 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
|
||||
|
||||
if req.TLS {
|
||||
folder := strconv.Itoa(int(endpoint.ID))
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
|
||||
endpoint.TLSCACertPath = caCertPath
|
||||
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSCertPath = certPath
|
||||
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
|
||||
endpoint.TLSKeyPath = keyPath
|
||||
|
||||
if !req.TLSSkipVerify {
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
|
||||
endpoint.TLSConfig.TLSCACertPath = caCertPath
|
||||
}
|
||||
|
||||
if !req.TLSSkipClientVerify {
|
||||
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSConfig.TLSCertPath = certPath
|
||||
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
|
||||
endpoint.TLSConfig.TLSKeyPath = keyPath
|
||||
}
|
||||
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
@@ -284,18 +298,33 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
|
||||
|
||||
folder := strconv.Itoa(int(endpoint.ID))
|
||||
if req.TLS {
|
||||
endpoint.TLS = true
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
|
||||
endpoint.TLSCACertPath = caCertPath
|
||||
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSCertPath = certPath
|
||||
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
|
||||
endpoint.TLSKeyPath = keyPath
|
||||
endpoint.TLSConfig.TLS = true
|
||||
endpoint.TLSConfig.TLSSkipVerify = req.TLSSkipVerify
|
||||
if !req.TLSSkipVerify {
|
||||
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
|
||||
endpoint.TLSConfig.TLSCACertPath = caCertPath
|
||||
} else {
|
||||
endpoint.TLSConfig.TLSCACertPath = ""
|
||||
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA)
|
||||
}
|
||||
|
||||
if !req.TLSSkipClientVerify {
|
||||
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSConfig.TLSCertPath = certPath
|
||||
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
|
||||
endpoint.TLSConfig.TLSKeyPath = keyPath
|
||||
} else {
|
||||
endpoint.TLSConfig.TLSCertPath = ""
|
||||
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert)
|
||||
endpoint.TLSConfig.TLSKeyPath = ""
|
||||
handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey)
|
||||
}
|
||||
} else {
|
||||
endpoint.TLS = false
|
||||
endpoint.TLSCACertPath = ""
|
||||
endpoint.TLSCertPath = ""
|
||||
endpoint.TLSKeyPath = ""
|
||||
endpoint.TLSConfig.TLS = false
|
||||
endpoint.TLSConfig.TLSSkipVerify = true
|
||||
endpoint.TLSConfig.TLSCACertPath = ""
|
||||
endpoint.TLSConfig.TLSCertPath = ""
|
||||
endpoint.TLSConfig.TLSKeyPath = ""
|
||||
err = handler.FileService.DeleteTLSFiles(folder)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
@@ -350,7 +379,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h
|
||||
return
|
||||
}
|
||||
|
||||
if endpoint.TLS {
|
||||
if endpoint.TLSConfig.TLS {
|
||||
err = handler.FileService.DeleteTLSFiles(id)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
|
||||
@@ -3,34 +3,22 @@ package handler
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileHandler represents an HTTP API handler for managing static files.
|
||||
type FileHandler struct {
|
||||
http.Handler
|
||||
Logger *log.Logger
|
||||
allowedDirectories map[string]bool
|
||||
Logger *log.Logger
|
||||
}
|
||||
|
||||
// NewFileHandler returns a new instance of FileHandler.
|
||||
func NewFileHandler(assetPath string) *FileHandler {
|
||||
func NewFileHandler(assetPublicPath string) *FileHandler {
|
||||
h := &FileHandler{
|
||||
Handler: http.FileServer(http.Dir(assetPath)),
|
||||
Handler: http.FileServer(http.Dir(assetPublicPath)),
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
allowedDirectories: map[string]bool{
|
||||
"/": true,
|
||||
"/css": true,
|
||||
"/js": true,
|
||||
"/images": true,
|
||||
"/fonts": true,
|
||||
},
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -45,17 +33,10 @@ func isHTML(acceptContent []string) bool {
|
||||
}
|
||||
|
||||
func (handler *FileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
requestDirectory := path.Dir(r.URL.Path)
|
||||
if !handler.allowedDirectories[requestDirectory] {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceNotFound, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if !isHTML(r.Header["Accept"]) {
|
||||
w.Header().Set("Cache-Control", "max-age=31536000")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
|
||||
handler.Handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type Handler struct {
|
||||
RegistryHandler *RegistryHandler
|
||||
DockerHubHandler *DockerHubHandler
|
||||
ResourceHandler *ResourceHandler
|
||||
StackHandler *StackHandler
|
||||
StatusHandler *StatusHandler
|
||||
SettingsHandler *SettingsHandler
|
||||
TemplatesHandler *TemplatesHandler
|
||||
@@ -49,6 +50,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
|
||||
if strings.Contains(r.URL.Path, "/docker") {
|
||||
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
|
||||
} else if strings.Contains(r.URL.Path, "/stacks") {
|
||||
http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r)
|
||||
} else {
|
||||
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,14 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht
|
||||
resourceControlType = portainer.ServiceResourceControl
|
||||
case "volume":
|
||||
resourceControlType = portainer.VolumeResourceControl
|
||||
case "network":
|
||||
resourceControlType = portainer.NetworkResourceControl
|
||||
case "secret":
|
||||
resourceControlType = portainer.SecretResourceControl
|
||||
case "stack":
|
||||
resourceControlType = portainer.StackResourceControl
|
||||
case "config":
|
||||
resourceControlType = portainer.ConfigResourceControl
|
||||
default:
|
||||
httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
|
||||
@@ -45,18 +45,22 @@ func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler {
|
||||
|
||||
type (
|
||||
publicSettingsResponse struct {
|
||||
LogoURL string `json:"LogoURL"`
|
||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
LogoURL string `json:"LogoURL"`
|
||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||
}
|
||||
|
||||
putSettingsRequest struct {
|
||||
TemplatesURL string `valid:"required"`
|
||||
LogoURL string `valid:""`
|
||||
BlackListedLabels []portainer.Pair `valid:""`
|
||||
DisplayExternalContributors bool `valid:""`
|
||||
AuthenticationMethod int `valid:"required"`
|
||||
LDAPSettings portainer.LDAPSettings `valid:""`
|
||||
TemplatesURL string `valid:"required"`
|
||||
LogoURL string `valid:""`
|
||||
BlackListedLabels []portainer.Pair `valid:""`
|
||||
DisplayExternalContributors bool `valid:""`
|
||||
AuthenticationMethod int `valid:"required"`
|
||||
LDAPSettings portainer.LDAPSettings `valid:""`
|
||||
AllowBindMountsForRegularUsers bool `valid:""`
|
||||
AllowPrivilegedModeForRegularUsers bool `valid:""`
|
||||
}
|
||||
|
||||
putSettingsLDAPCheckRequest struct {
|
||||
@@ -85,9 +89,11 @@ func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r
|
||||
}
|
||||
|
||||
publicSettings := &publicSettingsResponse{
|
||||
LogoURL: settings.LogoURL,
|
||||
DisplayExternalContributors: settings.DisplayExternalContributors,
|
||||
AuthenticationMethod: settings.AuthenticationMethod,
|
||||
LogoURL: settings.LogoURL,
|
||||
DisplayExternalContributors: settings.DisplayExternalContributors,
|
||||
AuthenticationMethod: settings.AuthenticationMethod,
|
||||
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
|
||||
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
|
||||
}
|
||||
|
||||
encodeJSON(w, publicSettings, handler.Logger)
|
||||
@@ -109,11 +115,13 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
settings := &portainer.Settings{
|
||||
TemplatesURL: req.TemplatesURL,
|
||||
LogoURL: req.LogoURL,
|
||||
BlackListedLabels: req.BlackListedLabels,
|
||||
DisplayExternalContributors: req.DisplayExternalContributors,
|
||||
LDAPSettings: req.LDAPSettings,
|
||||
TemplatesURL: req.TemplatesURL,
|
||||
LogoURL: req.LogoURL,
|
||||
BlackListedLabels: req.BlackListedLabels,
|
||||
DisplayExternalContributors: req.DisplayExternalContributors,
|
||||
LDAPSettings: req.LDAPSettings,
|
||||
AllowBindMountsForRegularUsers: req.AllowBindMountsForRegularUsers,
|
||||
AllowPrivilegedModeForRegularUsers: req.AllowPrivilegedModeForRegularUsers,
|
||||
}
|
||||
|
||||
if req.AuthenticationMethod == 1 {
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/file"
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// StackHandler represents an HTTP API handler for managing Stack.
|
||||
type StackHandler struct {
|
||||
stackCreationMutex *sync.Mutex
|
||||
stackDeletionMutex *sync.Mutex
|
||||
*mux.Router
|
||||
Logger *log.Logger
|
||||
FileService portainer.FileService
|
||||
GitService portainer.GitService
|
||||
StackService portainer.StackService
|
||||
EndpointService portainer.EndpointService
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
RegistryService portainer.RegistryService
|
||||
DockerHubService portainer.DockerHubService
|
||||
StackManager portainer.StackManager
|
||||
}
|
||||
|
||||
// NewStackHandler returns a new instance of StackHandler.
|
||||
func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler {
|
||||
h := &StackHandler{
|
||||
Router: mux.NewRouter(),
|
||||
stackCreationMutex: &sync.Mutex{},
|
||||
stackDeletionMutex: &sync.Mutex{},
|
||||
Logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
}
|
||||
h.Handle("/{endpointId}/stacks",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost)
|
||||
h.Handle("/{endpointId}/stacks",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStacks))).Methods(http.MethodGet)
|
||||
h.Handle("/{endpointId}/stacks/{id}",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStack))).Methods(http.MethodGet)
|
||||
h.Handle("/{endpointId}/stacks/{id}",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteStack))).Methods(http.MethodDelete)
|
||||
h.Handle("/{endpointId}/stacks/{id}",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutStack))).Methods(http.MethodPut)
|
||||
h.Handle("/{endpointId}/stacks/{id}/stackfile",
|
||||
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStackFile))).Methods(http.MethodGet)
|
||||
return h
|
||||
}
|
||||
|
||||
type (
|
||||
postStacksRequest struct {
|
||||
Name string `valid:"required"`
|
||||
SwarmID string `valid:"required"`
|
||||
StackFileContent string `valid:""`
|
||||
GitRepository string `valid:""`
|
||||
PathInRepository string `valid:""`
|
||||
Env []portainer.Pair `valid:""`
|
||||
}
|
||||
postStacksResponse struct {
|
||||
ID string `json:"Id"`
|
||||
}
|
||||
getStackFileResponse struct {
|
||||
StackFileContent string `json:"StackFileContent"`
|
||||
}
|
||||
putStackRequest struct {
|
||||
StackFileContent string `valid:"required"`
|
||||
Env []portainer.Pair `valid:""`
|
||||
}
|
||||
)
|
||||
|
||||
// handlePostStacks handles POST requests on /:endpointId/stacks?method=<method>
|
||||
func (handler *StackHandler) handlePostStacks(w http.ResponseWriter, r *http.Request) {
|
||||
method := r.FormValue("method")
|
||||
if method == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if method == "string" {
|
||||
handler.handlePostStacksStringMethod(w, r)
|
||||
} else if method == "repository" {
|
||||
handler.handlePostStacksRepositoryMethod(w, r)
|
||||
} else if method == "file" {
|
||||
handler.handlePostStacksFileMethod(w, r)
|
||||
} else {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
endpointID := portainer.EndpointID(id)
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req postStacksRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stackName := req.Name
|
||||
if stackName == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stackFileContent := req.StackFileContent
|
||||
if stackFileContent == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
swarmID := req.SwarmID
|
||||
if swarmID == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stacks, err := handler.StackService.Stacks()
|
||||
if err != nil && err != portainer.ErrStackNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
if strings.EqualFold(stack.Name, stackName) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackName + "_" + swarmID),
|
||||
Name: stackName,
|
||||
SwarmID: swarmID,
|
||||
EntryPoint: file.ComposeFileDefaultName,
|
||||
Env: req.Env,
|
||||
}
|
||||
|
||||
projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
err = handler.StackService.CreateStack(stack)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
dockerhub, err := handler.DockerHubService.DockerHub()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registries, err := handler.RegistryService.Registries()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
|
||||
}
|
||||
|
||||
func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
endpointID := portainer.EndpointID(id)
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req postStacksRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stackName := req.Name
|
||||
if stackName == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
swarmID := req.SwarmID
|
||||
if swarmID == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.GitRepository == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if req.PathInRepository == "" {
|
||||
req.PathInRepository = file.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
stacks, err := handler.StackService.Stacks()
|
||||
if err != nil && err != portainer.ErrStackNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
if strings.EqualFold(stack.Name, stackName) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackName + "_" + swarmID),
|
||||
Name: stackName,
|
||||
SwarmID: swarmID,
|
||||
EntryPoint: req.PathInRepository,
|
||||
Env: req.Env,
|
||||
}
|
||||
|
||||
projectPath := handler.FileService.GetStackProjectPath(string(stack.ID))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
// Ensure projectPath is empty
|
||||
err = handler.FileService.RemoveDirectory(projectPath)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.GitService.CloneRepository(req.GitRepository, projectPath)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.StackService.CreateStack(stack)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
dockerhub, err := handler.DockerHubService.DockerHub()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registries, err := handler.RegistryService.Registries()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
|
||||
}
|
||||
|
||||
func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
id, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
endpointID := portainer.EndpointID(id)
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(endpointID)
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stackName := r.FormValue("Name")
|
||||
if stackName == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
swarmID := r.FormValue("SwarmID")
|
||||
if swarmID == "" {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
envParam := r.FormValue("Env")
|
||||
var env []portainer.Pair
|
||||
if err = json.Unmarshal([]byte(envParam), &env); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stackFile, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
defer stackFile.Close()
|
||||
|
||||
stacks, err := handler.StackService.Stacks()
|
||||
if err != nil && err != portainer.ErrStackNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
if strings.EqualFold(stack.Name, stackName) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackName + "_" + swarmID),
|
||||
Name: stackName,
|
||||
SwarmID: swarmID,
|
||||
EntryPoint: file.ComposeFileDefaultName,
|
||||
Env: env,
|
||||
}
|
||||
|
||||
projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
err = handler.StackService.CreateStack(stack)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
dockerhub, err := handler.DockerHubService.DockerHub()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registries, err := handler.RegistryService.Registries()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetStacks handles GET requests on /:endpointId/stacks?swarmId=<swarmId>
|
||||
func (handler *StackHandler) handleGetStacks(w http.ResponseWriter, r *http.Request) {
|
||||
swarmID := r.FormValue("swarmId")
|
||||
|
||||
vars := mux.Vars(r)
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
endpointID := portainer.EndpointID(id)
|
||||
|
||||
_, err = handler.EndpointService.Endpoint(endpointID)
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var stacks []portainer.Stack
|
||||
if swarmID == "" {
|
||||
stacks, err = handler.StackService.Stacks()
|
||||
} else {
|
||||
stacks, err = handler.StackService.StacksBySwarmID(swarmID)
|
||||
}
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
resourceControls, err := handler.ResourceControlService.ResourceControls()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin,
|
||||
securityContext.UserID, securityContext.UserMemberships)
|
||||
|
||||
encodeJSON(w, filteredStacks, handler.Logger)
|
||||
}
|
||||
|
||||
// handleGetStack handles GET requests on /:endpointId/stacks/:id
|
||||
func (handler *StackHandler) handleGetStack(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
stackID := vars["id"]
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpointID, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
|
||||
if err == portainer.ErrStackNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name)
|
||||
if err != nil && err != portainer.ErrResourceControlNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}}
|
||||
if resourceControl != nil {
|
||||
if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) {
|
||||
extendedStack.ResourceControl = *resourceControl
|
||||
} else {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
encodeJSON(w, extendedStack, handler.Logger)
|
||||
}
|
||||
|
||||
// handlePutStack handles PUT requests on /:endpointId/stacks/:id
|
||||
func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
stackID := vars["id"]
|
||||
|
||||
endpointID, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
|
||||
if err == portainer.ErrStackNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
var req putStackRequest
|
||||
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = govalidator.ValidateStruct(req)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
stack.Env = req.Env
|
||||
|
||||
_, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.StackService.UpdateStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
dockerhub, err := handler.DockerHubService.DockerHub()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
registries, err := handler.RegistryService.Registries()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
filteredRegistries, err := security.FilterRegistries(registries, securityContext)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.deployStack(endpoint, stack, dockerhub, filteredRegistries)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetStackFile handles GET requests on /:endpointId/stacks/:id/stackfile
|
||||
func (handler *StackHandler) handleGetStackFile(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
stackID := vars["id"]
|
||||
|
||||
endpointID, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
|
||||
if err == portainer.ErrStackNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
encodeJSON(w, &getStackFileResponse{StackFileContent: stackFileContent}, handler.Logger)
|
||||
}
|
||||
|
||||
// handleDeleteStack handles DELETE requests on /:endpointId/stacks/:id
|
||||
func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
stackID := vars["id"]
|
||||
|
||||
endpointID, err := strconv.Atoi(vars["endpointId"])
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainer.ErrEndpointNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
|
||||
if err == portainer.ErrStackNotFound {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
|
||||
return
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
handler.stackDeletionMutex.Lock()
|
||||
err = handler.StackManager.Remove(stack, endpoint)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
handler.stackDeletionMutex.Unlock()
|
||||
|
||||
err = handler.StackService.DeleteStack(portainer.StackID(stackID))
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *StackHandler) deployStack(endpoint *portainer.Endpoint, stack *portainer.Stack, dockerhub *portainer.DockerHub, registries []portainer.Registry) error {
|
||||
handler.stackCreationMutex.Lock()
|
||||
|
||||
err := handler.StackManager.Login(dockerhub, registries, endpoint)
|
||||
if err != nil {
|
||||
handler.stackCreationMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
err = handler.StackManager.Deploy(stack, endpoint)
|
||||
if err != nil {
|
||||
handler.stackCreationMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
err = handler.StackManager.Logout(endpoint)
|
||||
if err != nil {
|
||||
handler.stackCreationMutex.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
handler.stackCreationMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -43,16 +43,17 @@ func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
var templatesURL string
|
||||
if key == "containers" {
|
||||
switch key {
|
||||
case "containers":
|
||||
settings, err := handler.SettingsService.Settings()
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
templatesURL = settings.TemplatesURL
|
||||
} else if key == "linuxserver.io" {
|
||||
case "linuxserver.io":
|
||||
templatesURL = containerTemplatesURLLinuxServerIo
|
||||
} else {
|
||||
default:
|
||||
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder
|
||||
// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=<folder>
|
||||
func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
certificate := vars["certificate"]
|
||||
|
||||
@@ -82,6 +82,7 @@ type (
|
||||
}
|
||||
|
||||
postAdminInitRequest struct {
|
||||
Username string `valid:"required"`
|
||||
Password string `valid:"required"`
|
||||
}
|
||||
)
|
||||
@@ -358,10 +359,14 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
user, err := handler.UserService.UserByUsername("admin")
|
||||
if err == portainer.ErrUserNotFound {
|
||||
users, err := handler.UserService.UsersByRole(portainer.AdministratorRole)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if len(users) == 0 {
|
||||
user := &portainer.User{
|
||||
Username: "admin",
|
||||
Username: req.Username,
|
||||
Role: portainer.AdministratorRole,
|
||||
}
|
||||
user.Password, err = handler.CryptoService.Hash(req.Password)
|
||||
@@ -375,11 +380,7 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
if user != nil {
|
||||
} else {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger)
|
||||
return
|
||||
}
|
||||
@@ -396,6 +397,22 @@ func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
if userID == 1 {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveAdmin, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
if tokenData.ID == portainer.UserID(userID) {
|
||||
httperror.WriteErrorResponse(w, portainer.ErrAdminCannotRemoveSelf, http.StatusForbidden, handler.Logger)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.UserService.User(portainer.UserID(userID))
|
||||
|
||||
if err == portainer.ErrUserNotFound {
|
||||
|
||||
@@ -69,10 +69,10 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
|
||||
host = endpointURL.Path
|
||||
}
|
||||
|
||||
// Should not be managed here
|
||||
// TODO: Should not be managed here
|
||||
var tlsConfig *tls.Config
|
||||
if endpoint.TLS {
|
||||
tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
|
||||
if endpoint.TLSConfig.TLS {
|
||||
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create TLS configuration: %s", err)
|
||||
return
|
||||
|
||||
@@ -2,6 +2,83 @@ package proxy
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
type (
|
||||
// ExtendedStack represents a stack combined with its associated access control
|
||||
ExtendedStack struct {
|
||||
portainer.Stack
|
||||
ResourceControl portainer.ResourceControl `json:"ResourceControl"`
|
||||
}
|
||||
)
|
||||
|
||||
// applyResourceAccessControl returns an optionally decorated object as the first return value and the
|
||||
// access level for the user (granted or denied) as the second return value.
|
||||
// It will retrieve an identifier from the labels object. If an identifier exists, it will check for
|
||||
// an existing resource control associated to it.
|
||||
// Returns a decorated object and authorized access (true) when a resource control is found and the user can access the resource.
|
||||
// Returns the original object and authorized access (true) when no resource control is found.
|
||||
// Returns the original object and denied access (false) when a resource control is found and the user cannot access the resource.
|
||||
func applyResourceAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string,
|
||||
context *restrictedOperationContext) (map[string]interface{}, bool) {
|
||||
|
||||
if labelsObject != nil && labelsObject[labelIdentifier] != nil {
|
||||
resourceIdentifier := labelsObject[labelIdentifier].(string)
|
||||
return applyResourceAccessControl(resourceObject, resourceIdentifier, context)
|
||||
}
|
||||
return resourceObject, true
|
||||
}
|
||||
|
||||
// applyResourceAccessControl returns an optionally decorated object as the first return value and the
|
||||
// access level for the user (granted or denied) as the second return value.
|
||||
// Returns a decorated object and authorized access (true) when a resource control is found to the specified resource
|
||||
// identifier and the user can access the resource.
|
||||
// Returns the original object and authorized access (true) when no resource control is found for the specified
|
||||
// resource identifier.
|
||||
// Returns the original object and denied access (false) when a resource control is associated to the resource
|
||||
// and the user cannot access the resource.
|
||||
func applyResourceAccessControl(resourceObject map[string]interface{}, resourceIdentifier string,
|
||||
context *restrictedOperationContext) (map[string]interface{}, bool) {
|
||||
|
||||
authorizedAccess := true
|
||||
|
||||
resourceControl := getResourceControlByResourceID(resourceIdentifier, context.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if context.isAdmin || canUserAccessResource(context.userID, context.userTeamIDs, resourceControl) {
|
||||
resourceObject = decorateObject(resourceObject, resourceControl)
|
||||
} else {
|
||||
authorizedAccess = false
|
||||
}
|
||||
}
|
||||
|
||||
return resourceObject, authorizedAccess
|
||||
}
|
||||
|
||||
// decorateResourceWithAccessControlFromLabel will retrieve an identifier from the labels object. If an identifier exists,
|
||||
// it will check for an existing resource control associated to it. If a resource control is found, the resource object will be
|
||||
// decorated. If no identifier can be found in the labels or no resource control is associated to the identifier, the resource
|
||||
// object will not be changed.
|
||||
func decorateResourceWithAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string,
|
||||
resourceControls []portainer.ResourceControl) map[string]interface{} {
|
||||
|
||||
if labelsObject != nil && labelsObject[labelIdentifier] != nil {
|
||||
resourceIdentifier := labelsObject[labelIdentifier].(string)
|
||||
resourceObject = decorateResourceWithAccessControl(resourceObject, resourceIdentifier, resourceControls)
|
||||
}
|
||||
|
||||
return resourceObject
|
||||
}
|
||||
|
||||
// decorateResourceWithAccessControl will check if a resource control is associated to the specified resource identifier.
|
||||
// If a resource control is found, the resource object will be decorated, otherwise it will not be changed.
|
||||
func decorateResourceWithAccessControl(resourceObject map[string]interface{}, resourceIdentifier string,
|
||||
resourceControls []portainer.ResourceControl) map[string]interface{} {
|
||||
|
||||
resourceControl := getResourceControlByResourceID(resourceIdentifier, resourceControls)
|
||||
if resourceControl != nil {
|
||||
return decorateObject(resourceObject, resourceControl)
|
||||
}
|
||||
return resourceObject
|
||||
}
|
||||
|
||||
func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool {
|
||||
for _, authorizedUserAccess := range resourceControl.UserAccesses {
|
||||
if userID == authorizedUserAccess.UserID {
|
||||
@@ -19,3 +96,63 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
|
||||
metadata := make(map[string]interface{})
|
||||
metadata["ResourceControl"] = resourceControl
|
||||
object["Portainer"] = metadata
|
||||
return object
|
||||
}
|
||||
|
||||
func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
|
||||
for _, resourceControl := range resourceControls {
|
||||
if resourceID == resourceControl.ResourceID {
|
||||
return &resourceControl
|
||||
}
|
||||
for _, subResourceID := range resourceControl.SubResourceIDs {
|
||||
if resourceID == subResourceID {
|
||||
return &resourceControl
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanAccessStack checks if a user can access a stack
|
||||
func CanAccessStack(stack *portainer.Stack, resourceControl *portainer.ResourceControl, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
userTeamIDs := make([]portainer.TeamID, 0)
|
||||
for _, membership := range memberships {
|
||||
userTeamIDs = append(userTeamIDs, membership.TeamID)
|
||||
}
|
||||
|
||||
if canUserAccessResource(userID, userTeamIDs, resourceControl) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterStacks filters stacks based on user role and resource controls.
|
||||
func FilterStacks(stacks []portainer.Stack, resourceControls []portainer.ResourceControl, isAdmin bool,
|
||||
userID portainer.UserID, memberships []portainer.TeamMembership) []ExtendedStack {
|
||||
|
||||
filteredStacks := make([]ExtendedStack, 0)
|
||||
|
||||
userTeamIDs := make([]portainer.TeamID, 0)
|
||||
for _, membership := range memberships {
|
||||
userTeamIDs = append(userTeamIDs, membership.TeamID)
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
extendedStack := ExtendedStack{stack, portainer.ResourceControl{}}
|
||||
resourceControl := getResourceControlByResourceID(stack.Name, resourceControls)
|
||||
if resourceControl == nil {
|
||||
filteredStacks = append(filteredStacks, extendedStack)
|
||||
} else if resourceControl != nil && (isAdmin || canUserAccessResource(userID, userTeamIDs, resourceControl)) {
|
||||
extendedStack.ResourceControl = *resourceControl
|
||||
filteredStacks = append(filteredStacks, extendedStack)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredStacks
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerConfigIdentifierNotFound defines an error raised when Portainer is unable to find a config identifier
|
||||
ErrDockerConfigIdentifierNotFound = portainer.Error("Docker config identifier not found")
|
||||
configIdentifier = "ID"
|
||||
)
|
||||
|
||||
// configListOperation extracts the response as a JSON object, loop through the configs array
|
||||
// decorate and/or filter the configs based on resource controls before rewriting the response
|
||||
func configListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
|
||||
// ConfigList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
|
||||
responseArray, err := getResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if executor.operationContext.isAdmin {
|
||||
responseArray, err = decorateConfigList(responseArray, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
responseArray, err = filterConfigList(responseArray, executor.operationContext)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// configInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the config based on resource control (check are done based on the configID and optional Swarm service ID)
|
||||
// and either rewrite an access denied response or a decorated config.
|
||||
func configInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// ConfigInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject[configIdentifier] == nil {
|
||||
return ErrDockerConfigIdentifierNotFound
|
||||
}
|
||||
|
||||
configID := responseObject[configIdentifier].(string)
|
||||
responseObject, access := applyResourceAccessControl(responseObject, configID, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
// decorateConfigList loops through all configs and decorates any config with an existing resource control.
|
||||
// Resource controls checks are based on: resource identifier.
|
||||
// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
|
||||
func decorateConfigList(configData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedConfigData := make([]interface{}, 0)
|
||||
|
||||
for _, config := range configData {
|
||||
|
||||
configObject := config.(map[string]interface{})
|
||||
if configObject[configIdentifier] == nil {
|
||||
return nil, ErrDockerConfigIdentifierNotFound
|
||||
}
|
||||
|
||||
configID := configObject[configIdentifier].(string)
|
||||
configObject = decorateResourceWithAccessControl(configObject, configID, resourceControls)
|
||||
|
||||
decoratedConfigData = append(decoratedConfigData, configObject)
|
||||
}
|
||||
|
||||
return decoratedConfigData, nil
|
||||
}
|
||||
|
||||
// filterConfigList loops through all configs and filters public configs (no associated resource control)
|
||||
// as well as authorized configs (access granted to the user based on existing resource control).
|
||||
// Authorized configs are decorated during the process.
|
||||
// Resource controls checks are based on: resource identifier.
|
||||
// Config object schema reference: https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
|
||||
func filterConfigList(configData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
|
||||
filteredConfigData := make([]interface{}, 0)
|
||||
|
||||
for _, config := range configData {
|
||||
configObject := config.(map[string]interface{})
|
||||
if configObject[configIdentifier] == nil {
|
||||
return nil, ErrDockerConfigIdentifierNotFound
|
||||
}
|
||||
|
||||
configID := configObject[configIdentifier].(string)
|
||||
configObject, access := applyResourceAccessControl(configObject, configID, context)
|
||||
if access {
|
||||
filteredConfigData = append(filteredConfigData, configObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredConfigData, nil
|
||||
}
|
||||
+107
-22
@@ -11,6 +11,7 @@ const (
|
||||
ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found")
|
||||
containerIdentifier = "Id"
|
||||
containerLabelForServiceIdentifier = "com.docker.swarm.service.id"
|
||||
containerLabelForStackIdentifier = "com.docker.stack.namespace"
|
||||
)
|
||||
|
||||
// containerListOperation extracts the response as a JSON object, loop through the containers array
|
||||
@@ -27,8 +28,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec
|
||||
if executor.operationContext.isAdmin {
|
||||
responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
responseArray, err = filterContainerList(responseArray, executor.operationContext.resourceControls,
|
||||
executor.operationContext.userID, executor.operationContext.userTeamIDs)
|
||||
responseArray, err = filterContainerList(responseArray, executor.operationContext)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -58,30 +58,22 @@ func containerInspectOperation(request *http.Request, response *http.Response, e
|
||||
if responseObject[containerIdentifier] == nil {
|
||||
return ErrDockerContainerIdentifierNotFound
|
||||
}
|
||||
containerID := responseObject[containerIdentifier].(string)
|
||||
|
||||
resourceControl := getResourceControlByResourceID(containerID, executor.operationContext.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
|
||||
executor.operationContext.userTeamIDs, resourceControl) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
} else {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
containerID := responseObject[containerIdentifier].(string)
|
||||
responseObject, access := applyResourceAccessControl(responseObject, containerID, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject)
|
||||
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
|
||||
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
|
||||
executor.operationContext.userTeamIDs, resourceControl) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
} else {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
}
|
||||
responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForServiceIdentifier, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForStackIdentifier, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
@@ -106,3 +98,96 @@ func extractContainerLabelsFromContainerListObject(responseObject map[string]int
|
||||
containerLabelsObject := extractJSONField(responseObject, "Labels")
|
||||
return containerLabelsObject
|
||||
}
|
||||
|
||||
// decorateContainerList loops through all containers and decorates any container with an existing resource control.
|
||||
// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label).
|
||||
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
|
||||
containerObject := container.(map[string]interface{})
|
||||
if containerObject[containerIdentifier] == nil {
|
||||
return nil, ErrDockerContainerIdentifierNotFound
|
||||
}
|
||||
|
||||
containerID := containerObject[containerIdentifier].(string)
|
||||
containerObject = decorateResourceWithAccessControl(containerObject, containerID, resourceControls)
|
||||
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, resourceControls)
|
||||
containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, resourceControls)
|
||||
|
||||
decoratedContainerData = append(decoratedContainerData, containerObject)
|
||||
}
|
||||
|
||||
return decoratedContainerData, nil
|
||||
}
|
||||
|
||||
// filterContainerList loops through all containers and filters public containers (no associated resource control)
|
||||
// as well as authorized containers (access granted to the user based on existing resource control).
|
||||
// Authorized containers are decorated during the process.
|
||||
// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label).
|
||||
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
func filterContainerList(containerData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
|
||||
filteredContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
containerObject := container.(map[string]interface{})
|
||||
if containerObject[containerIdentifier] == nil {
|
||||
return nil, ErrDockerContainerIdentifierNotFound
|
||||
}
|
||||
|
||||
containerID := containerObject[containerIdentifier].(string)
|
||||
containerObject, access := applyResourceAccessControl(containerObject, containerID, context)
|
||||
if access {
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context)
|
||||
if access {
|
||||
containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, context)
|
||||
if access {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredContainerData, nil
|
||||
}
|
||||
|
||||
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
|
||||
// any labels in the labels black list.
|
||||
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
|
||||
filteredContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
containerObject := container.(map[string]interface{})
|
||||
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
if containerLabels != nil {
|
||||
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
} else {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredContainerData, nil
|
||||
}
|
||||
|
||||
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
|
||||
for key, value := range containerLabels {
|
||||
labelName := key
|
||||
labelValue := value.(string)
|
||||
|
||||
for _, blackListedLabel := range labelBlackList {
|
||||
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
// decorateVolumeList loops through all volumes and will decorate any volume with an existing resource control.
|
||||
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedVolumeData := make([]interface{}, 0)
|
||||
|
||||
for _, volume := range volumeData {
|
||||
|
||||
volumeObject := volume.(map[string]interface{})
|
||||
if volumeObject[volumeIdentifier] == nil {
|
||||
return nil, ErrDockerVolumeIdentifierNotFound
|
||||
}
|
||||
|
||||
volumeID := volumeObject[volumeIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
|
||||
if resourceControl != nil {
|
||||
volumeObject = decorateObject(volumeObject, resourceControl)
|
||||
}
|
||||
decoratedVolumeData = append(decoratedVolumeData, volumeObject)
|
||||
}
|
||||
|
||||
return decoratedVolumeData, nil
|
||||
}
|
||||
|
||||
// decorateContainerList loops through all containers and will decorate any container with an existing resource control.
|
||||
// Check is based on the container ID and optional Swarm service ID.
|
||||
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
|
||||
containerObject := container.(map[string]interface{})
|
||||
if containerObject[containerIdentifier] == nil {
|
||||
return nil, ErrDockerContainerIdentifierNotFound
|
||||
}
|
||||
|
||||
containerID := containerObject[containerIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
|
||||
if resourceControl != nil {
|
||||
containerObject = decorateObject(containerObject, resourceControl)
|
||||
}
|
||||
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
|
||||
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
|
||||
if resourceControl != nil {
|
||||
containerObject = decorateObject(containerObject, resourceControl)
|
||||
}
|
||||
}
|
||||
|
||||
decoratedContainerData = append(decoratedContainerData, containerObject)
|
||||
}
|
||||
|
||||
return decoratedContainerData, nil
|
||||
}
|
||||
|
||||
// decorateServiceList loops through all services and will decorate any service with an existing resource control.
|
||||
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedServiceData := make([]interface{}, 0)
|
||||
|
||||
for _, service := range serviceData {
|
||||
|
||||
serviceObject := service.(map[string]interface{})
|
||||
if serviceObject[serviceIdentifier] == nil {
|
||||
return nil, ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
|
||||
serviceID := serviceObject[serviceIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
|
||||
if resourceControl != nil {
|
||||
serviceObject = decorateObject(serviceObject, resourceControl)
|
||||
}
|
||||
decoratedServiceData = append(decoratedServiceData, serviceObject)
|
||||
}
|
||||
|
||||
return decoratedServiceData, nil
|
||||
}
|
||||
|
||||
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
|
||||
metadata := make(map[string]interface{})
|
||||
metadata["ResourceControl"] = resourceControl
|
||||
object["Portainer"] = metadata
|
||||
return object
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -24,7 +25,7 @@ func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
|
||||
func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
u.Scheme = "https"
|
||||
proxy := factory.createReverseProxy(u)
|
||||
config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
|
||||
config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -56,3 +57,15 @@ func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReversePro
|
||||
proxy.Transport = transport
|
||||
return proxy
|
||||
}
|
||||
|
||||
func newSocketTransport(socketPath string) *http.Transport {
|
||||
return &http.Transport{
|
||||
Dial: func(proto, addr string) (conn net.Conn, err error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPTransport() *http.Transport {
|
||||
return &http.Transport{}
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
// filterVolumeList loops through all volumes, filters volumes without any resource control (public resources) or with
|
||||
// any resource control giving access to the user (these volumes will be decorated).
|
||||
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
func filterVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
|
||||
filteredVolumeData := make([]interface{}, 0)
|
||||
|
||||
for _, volume := range volumeData {
|
||||
volumeObject := volume.(map[string]interface{})
|
||||
if volumeObject[volumeIdentifier] == nil {
|
||||
return nil, ErrDockerVolumeIdentifierNotFound
|
||||
}
|
||||
|
||||
volumeID := volumeObject[volumeIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
|
||||
if resourceControl == nil {
|
||||
filteredVolumeData = append(filteredVolumeData, volumeObject)
|
||||
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
|
||||
volumeObject = decorateObject(volumeObject, resourceControl)
|
||||
filteredVolumeData = append(filteredVolumeData, volumeObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredVolumeData, nil
|
||||
}
|
||||
|
||||
// filterContainerList loops through all containers, filters containers without any resource control (public resources) or with
|
||||
// any resource control giving access to the user (check on container ID and optional Swarm service ID, these containers will be decorated).
|
||||
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
func filterContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
|
||||
filteredContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
containerObject := container.(map[string]interface{})
|
||||
if containerObject[containerIdentifier] == nil {
|
||||
return nil, ErrDockerContainerIdentifierNotFound
|
||||
}
|
||||
|
||||
containerID := containerObject[containerIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
|
||||
if resourceControl == nil {
|
||||
// check if container is part of a Swarm service
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
|
||||
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
|
||||
serviceResourceControl := getResourceControlByResourceID(serviceID, resourceControls)
|
||||
if serviceResourceControl == nil {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
} else if serviceResourceControl != nil && canUserAccessResource(userID, userTeamIDs, serviceResourceControl) {
|
||||
containerObject = decorateObject(containerObject, serviceResourceControl)
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
} else {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
|
||||
containerObject = decorateObject(containerObject, resourceControl)
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredContainerData, nil
|
||||
}
|
||||
|
||||
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
|
||||
// any labels in the labels black list.
|
||||
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
|
||||
filteredContainerData := make([]interface{}, 0)
|
||||
|
||||
for _, container := range containerData {
|
||||
containerObject := container.(map[string]interface{})
|
||||
|
||||
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
|
||||
if containerLabels != nil {
|
||||
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
} else {
|
||||
filteredContainerData = append(filteredContainerData, containerObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredContainerData, nil
|
||||
}
|
||||
|
||||
// filterServiceList loops through all services, filters services without any resource control (public resources) or with
|
||||
// any resource control giving access to the user (these services will be decorated).
|
||||
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
func filterServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
|
||||
filteredServiceData := make([]interface{}, 0)
|
||||
|
||||
for _, service := range serviceData {
|
||||
serviceObject := service.(map[string]interface{})
|
||||
if serviceObject[serviceIdentifier] == nil {
|
||||
return nil, ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
|
||||
serviceID := serviceObject[serviceIdentifier].(string)
|
||||
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
|
||||
if resourceControl == nil {
|
||||
filteredServiceData = append(filteredServiceData, serviceObject)
|
||||
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
|
||||
serviceObject = decorateObject(serviceObject, resourceControl)
|
||||
filteredServiceData = append(filteredServiceData, serviceObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredServiceData, nil
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
|
||||
}
|
||||
|
||||
if endpointURL.Scheme == "tcp" {
|
||||
if endpoint.TLS {
|
||||
if endpoint.TLSConfig.TLS {
|
||||
proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier
|
||||
ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found")
|
||||
networkIdentifier = "Id"
|
||||
networkLabelForStackIdentifier = "com.docker.stack.namespace"
|
||||
)
|
||||
|
||||
// networkListOperation extracts the response as a JSON object, loop through the networks array
|
||||
// decorate and/or filter the networks based on resource controls before rewriting the response
|
||||
func networkListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// NetworkList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
|
||||
responseArray, err := getResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if executor.operationContext.isAdmin {
|
||||
responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
responseArray, err = filterNetworkList(responseArray, executor.operationContext)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// networkInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the network based on resource control and either rewrite an access denied response
|
||||
// or a decorated network.
|
||||
func networkInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// NetworkInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject[networkIdentifier] == nil {
|
||||
return ErrDockerNetworkIdentifierNotFound
|
||||
}
|
||||
|
||||
networkID := responseObject[networkIdentifier].(string)
|
||||
responseObject, access := applyResourceAccessControl(responseObject, networkID, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
networkLabels := extractNetworkLabelsFromNetworkInspectObject(responseObject)
|
||||
responseObject, access = applyResourceAccessControlFromLabel(networkLabels, responseObject, networkLabelForStackIdentifier, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
// extractNetworkLabelsFromNetworkInspectObject retrieve the Labels of the network if present.
|
||||
// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
|
||||
func extractNetworkLabelsFromNetworkInspectObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Labels
|
||||
return extractJSONField(responseObject, "Labels")
|
||||
}
|
||||
|
||||
// extractNetworkLabelsFromNetworkListObject retrieve the Labels of the network if present.
|
||||
// Network schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
|
||||
func extractNetworkLabelsFromNetworkListObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Labels
|
||||
return extractJSONField(responseObject, "Labels")
|
||||
}
|
||||
|
||||
// decorateNetworkList loops through all networks and decorates any network with an existing resource control.
|
||||
// Resource controls checks are based on: resource identifier, stack identifier (from label).
|
||||
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
|
||||
func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedNetworkData := make([]interface{}, 0)
|
||||
|
||||
for _, network := range networkData {
|
||||
|
||||
networkObject := network.(map[string]interface{})
|
||||
if networkObject[networkIdentifier] == nil {
|
||||
return nil, ErrDockerNetworkIdentifierNotFound
|
||||
}
|
||||
|
||||
networkID := networkObject[networkIdentifier].(string)
|
||||
networkObject = decorateResourceWithAccessControl(networkObject, networkID, resourceControls)
|
||||
|
||||
networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject)
|
||||
networkObject = decorateResourceWithAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, resourceControls)
|
||||
|
||||
decoratedNetworkData = append(decoratedNetworkData, networkObject)
|
||||
}
|
||||
|
||||
return decoratedNetworkData, nil
|
||||
}
|
||||
|
||||
// filterNetworkList loops through all networks and filters public networks (no associated resource control)
|
||||
// as well as authorized networks (access granted to the user based on existing resource control).
|
||||
// Authorized networks are decorated during the process.
|
||||
// Resource controls checks are based on: resource identifier, stack identifier (from label).
|
||||
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
|
||||
func filterNetworkList(networkData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
|
||||
filteredNetworkData := make([]interface{}, 0)
|
||||
|
||||
for _, network := range networkData {
|
||||
networkObject := network.(map[string]interface{})
|
||||
if networkObject[networkIdentifier] == nil {
|
||||
return nil, ErrDockerNetworkIdentifierNotFound
|
||||
}
|
||||
|
||||
networkID := networkObject[networkIdentifier].(string)
|
||||
networkObject, access := applyResourceAccessControl(networkObject, networkID, context)
|
||||
if access {
|
||||
networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject)
|
||||
networkObject, access = applyResourceAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, context)
|
||||
if access {
|
||||
filteredNetworkData = append(filteredNetworkData, networkObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredNetworkData, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerSecretIdentifierNotFound defines an error raised when Portainer is unable to find a secret identifier
|
||||
ErrDockerSecretIdentifierNotFound = portainer.Error("Docker secret identifier not found")
|
||||
secretIdentifier = "ID"
|
||||
)
|
||||
|
||||
// secretListOperation extracts the response as a JSON object, loop through the secrets array
|
||||
// decorate and/or filter the secrets based on resource controls before rewriting the response
|
||||
func secretListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
|
||||
// SecretList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/SecretList
|
||||
responseArray, err := getResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if executor.operationContext.isAdmin {
|
||||
responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
responseArray, err = filterSecretList(responseArray, executor.operationContext)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// secretInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the secret based on resource control (check are done based on the secretID and optional Swarm service ID)
|
||||
// and either rewrite an access denied response or a decorated secret.
|
||||
func secretInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// SecretInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject[secretIdentifier] == nil {
|
||||
return ErrDockerSecretIdentifierNotFound
|
||||
}
|
||||
|
||||
secretID := responseObject[secretIdentifier].(string)
|
||||
responseObject, access := applyResourceAccessControl(responseObject, secretID, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
// decorateSecretList loops through all secrets and decorates any secret with an existing resource control.
|
||||
// Resource controls checks are based on: resource identifier.
|
||||
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
|
||||
func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedSecretData := make([]interface{}, 0)
|
||||
|
||||
for _, secret := range secretData {
|
||||
|
||||
secretObject := secret.(map[string]interface{})
|
||||
if secretObject[secretIdentifier] == nil {
|
||||
return nil, ErrDockerSecretIdentifierNotFound
|
||||
}
|
||||
|
||||
secretID := secretObject[secretIdentifier].(string)
|
||||
secretObject = decorateResourceWithAccessControl(secretObject, secretID, resourceControls)
|
||||
|
||||
decoratedSecretData = append(decoratedSecretData, secretObject)
|
||||
}
|
||||
|
||||
return decoratedSecretData, nil
|
||||
}
|
||||
|
||||
// filterSecretList loops through all secrets and filters public secrets (no associated resource control)
|
||||
// as well as authorized secrets (access granted to the user based on existing resource control).
|
||||
// Authorized secrets are decorated during the process.
|
||||
// Resource controls checks are based on: resource identifier.
|
||||
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
|
||||
func filterSecretList(secretData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
|
||||
filteredSecretData := make([]interface{}, 0)
|
||||
|
||||
for _, secret := range secretData {
|
||||
secretObject := secret.(map[string]interface{})
|
||||
if secretObject[secretIdentifier] == nil {
|
||||
return nil, ErrDockerSecretIdentifierNotFound
|
||||
}
|
||||
|
||||
secretID := secretObject[secretIdentifier].(string)
|
||||
secretObject, access := applyResourceAccessControl(secretObject, secretID, context)
|
||||
if access {
|
||||
filteredSecretData = append(filteredSecretData, secretObject)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredSecretData, nil
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier
|
||||
ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found")
|
||||
serviceIdentifier = "ID"
|
||||
)
|
||||
|
||||
// serviceListOperation extracts the response as a JSON array, loop through the service array
|
||||
// decorate and/or filter the services based on resource controls before rewriting the response
|
||||
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// ServiceList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
responseArray, err := getResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if executor.operationContext.isAdmin {
|
||||
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
responseArray, err = filterServiceList(responseArray, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// serviceInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the service based on resource control and either rewrite an access denied response
|
||||
// or a decorated service.
|
||||
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// ServiceInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject[serviceIdentifier] == nil {
|
||||
return ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
serviceID := responseObject[serviceIdentifier].(string)
|
||||
|
||||
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
} else {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier
|
||||
ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found")
|
||||
serviceIdentifier = "ID"
|
||||
serviceLabelForStackIdentifier = "com.docker.stack.namespace"
|
||||
)
|
||||
|
||||
// serviceListOperation extracts the response as a JSON array, loop through the service array
|
||||
// decorate and/or filter the services based on resource controls before rewriting the response
|
||||
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
// ServiceList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
responseArray, err := getResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if executor.operationContext.isAdmin {
|
||||
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
responseArray, err = filterServiceList(responseArray, executor.operationContext)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// serviceInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the service based on resource control and either rewrite an access denied response
|
||||
// or a decorated service.
|
||||
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// ServiceInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
||||
responseObject, err := getResponseAsJSONOBject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject[serviceIdentifier] == nil {
|
||||
return ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
|
||||
serviceID := responseObject[serviceIdentifier].(string)
|
||||
responseObject, access := applyResourceAccessControl(responseObject, serviceID, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
serviceLabels := extractServiceLabelsFromServiceInspectObject(responseObject)
|
||||
responseObject, access = applyResourceAccessControlFromLabel(serviceLabels, responseObject, serviceLabelForStackIdentifier, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
// extractServiceLabelsFromServiceInspectObject retrieve the Labels of the service if present.
|
||||
// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
||||
func extractServiceLabelsFromServiceInspectObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Spec.Labels
|
||||
serviceSpecObject := extractJSONField(responseObject, "Spec")
|
||||
if serviceSpecObject != nil {
|
||||
return extractJSONField(serviceSpecObject, "Labels")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractServiceLabelsFromServiceListObject retrieve the Labels of the service if present.
|
||||
// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
func extractServiceLabelsFromServiceListObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Spec.Labels
|
||||
serviceSpecObject := extractJSONField(responseObject, "Spec")
|
||||
if serviceSpecObject != nil {
|
||||
return extractJSONField(serviceSpecObject, "Labels")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decorateServiceList loops through all services and decorates any service with an existing resource control.
|
||||
// Resource controls checks are based on: resource identifier, stack identifier (from label).
|
||||
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedServiceData := make([]interface{}, 0)
|
||||
|
||||
for _, service := range serviceData {
|
||||
|
||||
serviceObject := service.(map[string]interface{})
|
||||
if serviceObject[serviceIdentifier] == nil {
|
||||
return nil, ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
|
||||
serviceID := serviceObject[serviceIdentifier].(string)
|
||||
serviceObject = decorateResourceWithAccessControl(serviceObject, serviceID, resourceControls)
|
||||
|
||||
serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject)
|
||||
serviceObject = decorateResourceWithAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, resourceControls)
|
||||
|
||||
decoratedServiceData = append(decoratedServiceData, serviceObject)
|
||||
}
|
||||
|
||||
return decoratedServiceData, nil
|
||||
}
|
||||
|
||||
// filterServiceList loops through all services and filters public services (no associated resource control)
|
||||
// as well as authorized services (access granted to the user based on existing resource control).
|
||||
// Authorized services are decorated during the process.
|
||||
// Resource controls checks are based on: resource identifier, stack identifier (from label).
|
||||
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
func filterServiceList(serviceData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
|
||||
filteredServiceData := make([]interface{}, 0)
|
||||
|
||||
for _, service := range serviceData {
|
||||
serviceObject := service.(map[string]interface{})
|
||||
if serviceObject[serviceIdentifier] == nil {
|
||||
return nil, ErrDockerServiceIdentifierNotFound
|
||||
}
|
||||
|
||||
serviceID := serviceObject[serviceIdentifier].(string)
|
||||
serviceObject, access := applyResourceAccessControl(serviceObject, serviceID, context)
|
||||
if access {
|
||||
serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject)
|
||||
serviceObject, access = applyResourceAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, context)
|
||||
if access {
|
||||
filteredServiceData = append(filteredServiceData, serviceObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredServiceData, nil
|
||||
}
|
||||
@@ -34,6 +34,9 @@ func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(res.StatusCode)
|
||||
|
||||
if _, err := io.Copy(w, res.Body); err != nil {
|
||||
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task
|
||||
ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found")
|
||||
taskServiceIdentifier = "ServiceID"
|
||||
taskLabelForStackIdentifier = "com.docker.stack.namespace"
|
||||
)
|
||||
|
||||
// taskListOperation extracts the response as a JSON object, loop through the tasks array
|
||||
// and filter the tasks based on resource controls before rewriting the response
|
||||
func taskListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
var err error
|
||||
|
||||
// TaskList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/TaskList
|
||||
responseArray, err := getResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !executor.operationContext.isAdmin {
|
||||
responseArray, err = filterTaskList(responseArray, executor.operationContext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// extractTaskLabelsFromTaskListObject retrieve the Labels of the task if present.
|
||||
// Task schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
|
||||
func extractTaskLabelsFromTaskListObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Spec.ContainerSpec.Labels
|
||||
taskSpecObject := extractJSONField(responseObject, "Spec")
|
||||
if taskSpecObject != nil {
|
||||
containerSpecObject := extractJSONField(taskSpecObject, "ContainerSpec")
|
||||
if containerSpecObject != nil {
|
||||
return extractJSONField(containerSpecObject, "Labels")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// filterTaskList loops through all tasks and filters public tasks (no associated resource control)
|
||||
// as well as authorized tasks (access granted to the user based on existing resource control).
|
||||
// Resource controls checks are based on: service identifier, stack identifier (from label).
|
||||
// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
|
||||
// any resource control giving access to the user based on the associated service identifier.
|
||||
func filterTaskList(taskData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
|
||||
filteredTaskData := make([]interface{}, 0)
|
||||
|
||||
for _, task := range taskData {
|
||||
taskObject := task.(map[string]interface{})
|
||||
if taskObject[taskServiceIdentifier] == nil {
|
||||
return nil, ErrDockerTaskServiceIdentifierNotFound
|
||||
}
|
||||
|
||||
serviceID := taskObject[taskServiceIdentifier].(string)
|
||||
taskObject, access := applyResourceAccessControl(taskObject, serviceID, context)
|
||||
if access {
|
||||
taskLabels := extractTaskLabelsFromTaskListObject(taskObject)
|
||||
taskObject, access = applyResourceAccessControlFromLabel(taskLabels, taskObject, taskLabelForStackIdentifier, context)
|
||||
if access {
|
||||
filteredTaskData = append(filteredTaskData, taskObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredTaskData, nil
|
||||
}
|
||||
+98
-19
@@ -1,7 +1,6 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -30,18 +29,6 @@ type (
|
||||
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
|
||||
)
|
||||
|
||||
func newSocketTransport(socketPath string) *http.Transport {
|
||||
return &http.Transport{
|
||||
Dial: func(proto, addr string) (conn net.Conn, err error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPTransport() *http.Transport {
|
||||
return &http.Transport{}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return p.proxyDockerRequest(request)
|
||||
}
|
||||
@@ -53,17 +40,46 @@ func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Resp
|
||||
func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) {
|
||||
path := request.URL.Path
|
||||
|
||||
if strings.HasPrefix(path, "/containers") {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/configs"):
|
||||
return p.proxyConfigRequest(request)
|
||||
case strings.HasPrefix(path, "/containers"):
|
||||
return p.proxyContainerRequest(request)
|
||||
} else if strings.HasPrefix(path, "/services") {
|
||||
case strings.HasPrefix(path, "/services"):
|
||||
return p.proxyServiceRequest(request)
|
||||
} else if strings.HasPrefix(path, "/volumes") {
|
||||
case strings.HasPrefix(path, "/volumes"):
|
||||
return p.proxyVolumeRequest(request)
|
||||
} else if strings.HasPrefix(path, "/swarm") {
|
||||
case strings.HasPrefix(path, "/networks"):
|
||||
return p.proxyNetworkRequest(request)
|
||||
case strings.HasPrefix(path, "/secrets"):
|
||||
return p.proxySecretRequest(request)
|
||||
case strings.HasPrefix(path, "/swarm"):
|
||||
return p.proxySwarmRequest(request)
|
||||
case strings.HasPrefix(path, "/nodes"):
|
||||
return p.proxyNodeRequest(request)
|
||||
case strings.HasPrefix(path, "/tasks"):
|
||||
return p.proxyTaskRequest(request)
|
||||
default:
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
return p.executeDockerRequest(request)
|
||||
func (p *proxyTransport) proxyConfigRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/configs/create":
|
||||
return p.executeDockerRequest(request)
|
||||
|
||||
case "/configs":
|
||||
return p.rewriteOperation(request, configListOperation)
|
||||
|
||||
default:
|
||||
// assume /configs/{id}
|
||||
if request.Method == http.MethodGet {
|
||||
return p.rewriteOperation(request, configInspectOperation)
|
||||
}
|
||||
configID := path.Base(requestPath)
|
||||
return p.restrictedOperation(request, configID)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyContainerRequest(request *http.Request) (*http.Response, error) {
|
||||
@@ -145,8 +161,71 @@ func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Respon
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyNetworkRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/networks/create":
|
||||
return p.executeDockerRequest(request)
|
||||
|
||||
case "/networks":
|
||||
return p.rewriteOperation(request, networkListOperation)
|
||||
|
||||
default:
|
||||
// assume /networks/{id}
|
||||
if request.Method == http.MethodGet {
|
||||
return p.rewriteOperation(request, networkInspectOperation)
|
||||
}
|
||||
networkID := path.Base(requestPath)
|
||||
return p.restrictedOperation(request, networkID)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxySecretRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/secrets/create":
|
||||
return p.executeDockerRequest(request)
|
||||
|
||||
case "/secrets":
|
||||
return p.rewriteOperation(request, secretListOperation)
|
||||
|
||||
default:
|
||||
// assume /secrets/{id}
|
||||
if request.Method == http.MethodGet {
|
||||
return p.rewriteOperation(request, secretInspectOperation)
|
||||
}
|
||||
secretID := path.Base(requestPath)
|
||||
return p.restrictedOperation(request, secretID)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response, error) {
|
||||
requestPath := request.URL.Path
|
||||
|
||||
// assume /nodes/{id}
|
||||
if path.Base(requestPath) != "nodes" {
|
||||
return p.administratorOperation(request)
|
||||
}
|
||||
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
|
||||
return p.administratorOperation(request)
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/swarm":
|
||||
return p.executeDockerRequest(request)
|
||||
default:
|
||||
// assume /swarm/{action}
|
||||
return p.administratorOperation(request)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/tasks":
|
||||
return p.rewriteOperation(request, taskListOperation)
|
||||
default:
|
||||
// assume /tasks/{id}
|
||||
return p.executeDockerRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
// restrictedOperation ensures that the current user has the required authorizations
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import "github.com/portainer/portainer"
|
||||
|
||||
func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
|
||||
for _, resourceControl := range resourceControls {
|
||||
if resourceID == resourceControl.ResourceID {
|
||||
return &resourceControl
|
||||
}
|
||||
for _, subResourceID := range resourceControl.SubResourceIDs {
|
||||
if resourceID == subResourceID {
|
||||
return &resourceControl
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
|
||||
for key, value := range containerLabels {
|
||||
labelName := key
|
||||
labelValue := value.(string)
|
||||
|
||||
for _, blackListedLabel := range labelBlackList {
|
||||
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
+80
-10
@@ -10,6 +10,7 @@ const (
|
||||
// ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier
|
||||
ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found")
|
||||
volumeIdentifier = "Name"
|
||||
volumeLabelForStackIdentifier = "com.docker.stack.namespace"
|
||||
)
|
||||
|
||||
// volumeListOperation extracts the response as a JSON object, loop through the volume array
|
||||
@@ -31,7 +32,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
|
||||
if executor.operationContext.isAdmin {
|
||||
volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls)
|
||||
} else {
|
||||
volumeData, err = filterVolumeList(volumeData, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
|
||||
volumeData, err = filterVolumeList(volumeData, executor.operationContext)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -45,7 +46,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
|
||||
}
|
||||
|
||||
// volumeInspectOperation extracts the response as a JSON object, verify that the user
|
||||
// has access to the volume based on resource control and either rewrite an access denied response
|
||||
// has access to the volume based on any existing resource control and either rewrite an access denied response
|
||||
// or a decorated volume.
|
||||
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
|
||||
// VolumeInspect response is a JSON object
|
||||
@@ -58,16 +59,85 @@ func volumeInspectOperation(request *http.Request, response *http.Response, exec
|
||||
if responseObject[volumeIdentifier] == nil {
|
||||
return ErrDockerVolumeIdentifierNotFound
|
||||
}
|
||||
volumeID := responseObject[volumeIdentifier].(string)
|
||||
|
||||
resourceControl := getResourceControlByResourceID(volumeID, executor.operationContext.resourceControls)
|
||||
if resourceControl != nil {
|
||||
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
} else {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
volumeID := responseObject[volumeIdentifier].(string)
|
||||
responseObject, access := applyResourceAccessControl(responseObject, volumeID, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
volumeLabels := extractVolumeLabelsFromVolumeInspectObject(responseObject)
|
||||
responseObject, access = applyResourceAccessControlFromLabel(volumeLabels, responseObject, volumeLabelForStackIdentifier, executor.operationContext)
|
||||
if !access {
|
||||
return rewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
// extractVolumeLabelsFromVolumeInspectObject retrieve the Labels of the volume if present.
|
||||
// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
|
||||
func extractVolumeLabelsFromVolumeInspectObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Labels
|
||||
return extractJSONField(responseObject, "Labels")
|
||||
}
|
||||
|
||||
// extractVolumeLabelsFromVolumeListObject retrieve the Labels of the volume if present.
|
||||
// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
func extractVolumeLabelsFromVolumeListObject(responseObject map[string]interface{}) map[string]interface{} {
|
||||
// Labels are stored under Labels
|
||||
return extractJSONField(responseObject, "Labels")
|
||||
}
|
||||
|
||||
// decorateVolumeList loops through all volumes and decorates any volume with an existing resource control.
|
||||
// Resource controls checks are based on: resource identifier, stack identifier (from label).
|
||||
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
|
||||
decoratedVolumeData := make([]interface{}, 0)
|
||||
|
||||
for _, volume := range volumeData {
|
||||
|
||||
volumeObject := volume.(map[string]interface{})
|
||||
if volumeObject[volumeIdentifier] == nil {
|
||||
return nil, ErrDockerVolumeIdentifierNotFound
|
||||
}
|
||||
|
||||
volumeID := volumeObject[volumeIdentifier].(string)
|
||||
volumeObject = decorateResourceWithAccessControl(volumeObject, volumeID, resourceControls)
|
||||
|
||||
volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject)
|
||||
volumeObject = decorateResourceWithAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, resourceControls)
|
||||
|
||||
decoratedVolumeData = append(decoratedVolumeData, volumeObject)
|
||||
}
|
||||
|
||||
return decoratedVolumeData, nil
|
||||
}
|
||||
|
||||
// filterVolumeList loops through all volumes and filters public volumes (no associated resource control)
|
||||
// as well as authorized volumes (access granted to the user based on existing resource control).
|
||||
// Authorized volumes are decorated during the process.
|
||||
// Resource controls checks are based on: resource identifier, stack identifier (from label).
|
||||
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
func filterVolumeList(volumeData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
|
||||
filteredVolumeData := make([]interface{}, 0)
|
||||
|
||||
for _, volume := range volumeData {
|
||||
volumeObject := volume.(map[string]interface{})
|
||||
if volumeObject[volumeIdentifier] == nil {
|
||||
return nil, ErrDockerVolumeIdentifierNotFound
|
||||
}
|
||||
|
||||
volumeID := volumeObject[volumeIdentifier].(string)
|
||||
volumeObject, access := applyResourceAccessControl(volumeObject, volumeID, context)
|
||||
if access {
|
||||
volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject)
|
||||
volumeObject, access = applyResourceAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, context)
|
||||
if access {
|
||||
filteredVolumeData = append(filteredVolumeData, volumeObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredVolumeData, nil
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po
|
||||
}
|
||||
|
||||
// FilterRegistries filters registries based on user role and team memberships.
|
||||
// Non administrator users only have access to authorized endpoints.
|
||||
// Non administrator users only have access to authorized registries.
|
||||
func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) ([]portainer.Registry, error) {
|
||||
|
||||
filteredRegistries := registries
|
||||
|
||||
+15
-1
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Server implements the portainer.Server interface
|
||||
@@ -27,7 +28,10 @@ type Server struct {
|
||||
FileService portainer.FileService
|
||||
RegistryService portainer.RegistryService
|
||||
DockerHubService portainer.DockerHubService
|
||||
StackService portainer.StackService
|
||||
StackManager portainer.StackManager
|
||||
LDAPService portainer.LDAPService
|
||||
GitService portainer.GitService
|
||||
Handler *handler.Handler
|
||||
SSL bool
|
||||
SSLCert string
|
||||
@@ -39,6 +43,7 @@ func (server *Server) Start() error {
|
||||
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
|
||||
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
|
||||
|
||||
var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public"))
|
||||
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
|
||||
authHandler.UserService = server.UserService
|
||||
authHandler.CryptoService = server.CryptoService
|
||||
@@ -82,7 +87,15 @@ func (server *Server) Start() error {
|
||||
resourceHandler.ResourceControlService = server.ResourceControlService
|
||||
var uploadHandler = handler.NewUploadHandler(requestBouncer)
|
||||
uploadHandler.FileService = server.FileService
|
||||
var fileHandler = handler.NewFileHandler(server.AssetsPath)
|
||||
var stackHandler = handler.NewStackHandler(requestBouncer)
|
||||
stackHandler.FileService = server.FileService
|
||||
stackHandler.StackService = server.StackService
|
||||
stackHandler.EndpointService = server.EndpointService
|
||||
stackHandler.ResourceControlService = server.ResourceControlService
|
||||
stackHandler.StackManager = server.StackManager
|
||||
stackHandler.GitService = server.GitService
|
||||
stackHandler.RegistryService = server.RegistryService
|
||||
stackHandler.DockerHubService = server.DockerHubService
|
||||
|
||||
server.Handler = &handler.Handler{
|
||||
AuthHandler: authHandler,
|
||||
@@ -95,6 +108,7 @@ func (server *Server) Start() error {
|
||||
ResourceHandler: resourceHandler,
|
||||
SettingsHandler: settingsHandler,
|
||||
StatusHandler: statusHandler,
|
||||
StackHandler: stackHandler,
|
||||
TemplatesHandler: templatesHandler,
|
||||
DockerHandler: dockerHandler,
|
||||
WebSocketHandler: websocketHandler,
|
||||
|
||||
+5
-2
@@ -33,7 +33,10 @@ func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearc
|
||||
|
||||
// Deliberately skip errors on the search request so that we can jump to other search settings
|
||||
// if any issue arise with the current one.
|
||||
sr, _ := conn.Search(searchRequest)
|
||||
sr, err := conn.Search(searchRequest)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(sr.Entries) == 1 {
|
||||
found = true
|
||||
@@ -52,7 +55,7 @@ func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearc
|
||||
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
|
||||
|
||||
if settings.TLSConfig.TLS || settings.StartTLS {
|
||||
config, err := crypto.CreateTLSConfiguration(settings.TLSConfig.TLSCACertPath, "", "", settings.TLSConfig.TLSSkipVerify)
|
||||
config, err := crypto.CreateTLSConfiguration(&settings.TLSConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+86
-31
@@ -27,6 +27,7 @@ type (
|
||||
SSLCert *string
|
||||
SSLKey *string
|
||||
AdminPassword *string
|
||||
AdminPasswordFile *string
|
||||
// Deprecated fields
|
||||
Logo *string
|
||||
Templates *string
|
||||
@@ -69,12 +70,14 @@ type (
|
||||
|
||||
// Settings represents the application settings.
|
||||
Settings struct {
|
||||
TemplatesURL string `json:"TemplatesURL"`
|
||||
LogoURL string `json:"LogoURL"`
|
||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
||||
TemplatesURL string `json:"TemplatesURL"`
|
||||
LogoURL string `json:"LogoURL"`
|
||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||
}
|
||||
|
||||
// User represents a user account.
|
||||
@@ -125,6 +128,19 @@ type (
|
||||
Role UserRole
|
||||
}
|
||||
|
||||
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier).
|
||||
StackID string
|
||||
|
||||
// Stack represents a Docker stack created via docker stack deploy.
|
||||
Stack struct {
|
||||
ID StackID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
EntryPoint string `json:"EntryPoint"`
|
||||
SwarmID string `json:"SwarmId"`
|
||||
ProjectPath string
|
||||
Env []Pair `json:"Env"`
|
||||
}
|
||||
|
||||
// RegistryID represents a registry identifier.
|
||||
RegistryID int
|
||||
|
||||
@@ -155,16 +171,20 @@ type (
|
||||
// Endpoint represents a Docker endpoint with all the info required
|
||||
// to connect to it.
|
||||
Endpoint struct {
|
||||
ID EndpointID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
URL string `json:"URL"`
|
||||
PublicURL string `json:"PublicURL"`
|
||||
TLS bool `json:"TLS"`
|
||||
TLSCACertPath string `json:"TLSCACert,omitempty"`
|
||||
TLSCertPath string `json:"TLSCert,omitempty"`
|
||||
TLSKeyPath string `json:"TLSKey,omitempty"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
ID EndpointID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
URL string `json:"URL"`
|
||||
PublicURL string `json:"PublicURL"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated in DBVersion == 4
|
||||
TLS bool `json:"TLS,omitempty"`
|
||||
TLSCACertPath string `json:"TLSCACert,omitempty"`
|
||||
TLSCertPath string `json:"TLSCert,omitempty"`
|
||||
TLSKeyPath string `json:"TLSKey,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceControlID represents a resource control identifier.
|
||||
@@ -172,23 +192,21 @@ type (
|
||||
|
||||
// ResourceControl represent a reference to a Docker resource with specific access controls
|
||||
ResourceControl struct {
|
||||
ID ResourceControlID `json:"Id"`
|
||||
ResourceID string `json:"ResourceId"`
|
||||
SubResourceIDs []string `json:"SubResourceIds"`
|
||||
Type ResourceControlType `json:"Type"`
|
||||
AdministratorsOnly bool `json:"AdministratorsOnly"`
|
||||
|
||||
UserAccesses []UserResourceAccess `json:"UserAccesses"`
|
||||
TeamAccesses []TeamResourceAccess `json:"TeamAccesses"`
|
||||
ID ResourceControlID `json:"Id"`
|
||||
ResourceID string `json:"ResourceId"`
|
||||
SubResourceIDs []string `json:"SubResourceIds"`
|
||||
Type ResourceControlType `json:"Type"`
|
||||
AdministratorsOnly bool `json:"AdministratorsOnly"`
|
||||
UserAccesses []UserResourceAccess `json:"UserAccesses"`
|
||||
TeamAccesses []TeamResourceAccess `json:"TeamAccesses"`
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated: OwnerID field is deprecated in DBVersion == 2
|
||||
OwnerID UserID `json:"OwnerId"`
|
||||
// Deprecated: AccessLevel field is deprecated in DBVersion == 2
|
||||
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
|
||||
// Deprecated in DBVersion == 2
|
||||
OwnerID UserID `json:"OwnerId,omitempty"`
|
||||
AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"`
|
||||
}
|
||||
|
||||
// ResourceControlType represents the type of resource associated to the resource control (volume, container, service).
|
||||
// ResourceControlType represents the type of resource associated to the resource control (volume, container, service...).
|
||||
ResourceControlType int
|
||||
|
||||
// UserResourceAccess represents the level of control on a resource for a specific user.
|
||||
@@ -281,6 +299,16 @@ type (
|
||||
DeleteRegistry(ID RegistryID) error
|
||||
}
|
||||
|
||||
// StackService represents a service for managing stack data.
|
||||
StackService interface {
|
||||
Stack(ID StackID) (*Stack, error)
|
||||
Stacks() ([]Stack, error)
|
||||
StacksBySwarmID(ID string) ([]Stack, error)
|
||||
CreateStack(stack *Stack) error
|
||||
UpdateStack(ID StackID, stack *Stack) error
|
||||
DeleteStack(ID StackID) error
|
||||
}
|
||||
|
||||
// DockerHubService represents a service for managing the DockerHub object.
|
||||
DockerHubService interface {
|
||||
DockerHub() (*DockerHub, error)
|
||||
@@ -323,9 +351,20 @@ type (
|
||||
|
||||
// FileService represents a service for managing files.
|
||||
FileService interface {
|
||||
GetFileContent(filePath string) (string, error)
|
||||
RemoveDirectory(directoryPath string) error
|
||||
StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error
|
||||
GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
|
||||
DeleteTLSFile(folder string, fileType TLSFileType) error
|
||||
DeleteTLSFiles(folder string) error
|
||||
GetStackProjectPath(stackIdentifier string) string
|
||||
StoreStackFileFromString(stackIdentifier string, stackFileContent string) (string, error)
|
||||
StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error)
|
||||
}
|
||||
|
||||
// GitService represents a service for managing Git.
|
||||
GitService interface {
|
||||
CloneRepository(url, destination string) error
|
||||
}
|
||||
|
||||
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
|
||||
@@ -338,13 +377,21 @@ type (
|
||||
AuthenticateUser(username, password string, settings *LDAPSettings) error
|
||||
TestConnectivity(settings *LDAPSettings) error
|
||||
}
|
||||
|
||||
// StackManager represents a service to manage stacks.
|
||||
StackManager interface {
|
||||
Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) error
|
||||
Logout(endpoint *Endpoint) error
|
||||
Deploy(stack *Stack, endpoint *Endpoint) error
|
||||
Remove(stack *Stack, endpoint *Endpoint) error
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API.
|
||||
APIVersion = "1.14.0"
|
||||
APIVersion = "1.15.2"
|
||||
// DBVersion is the version number of the Portainer database.
|
||||
DBVersion = 3
|
||||
DBVersion = 6
|
||||
// DefaultTemplatesURL represents the default URL for the templates definitions.
|
||||
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
|
||||
)
|
||||
@@ -396,4 +443,12 @@ const (
|
||||
ServiceResourceControl
|
||||
// VolumeResourceControl represents a resource control associated to a Docker volume
|
||||
VolumeResourceControl
|
||||
// NetworkResourceControl represents a resource control associated to a Docker network
|
||||
NetworkResourceControl
|
||||
// SecretResourceControl represents a resource control associated to a Docker secret
|
||||
SecretResourceControl
|
||||
// StackResourceControl represents a resource control associated to a stack composed of Docker services
|
||||
StackResourceControl
|
||||
// ConfigResourceControl represents a resource control associated to a Docker config
|
||||
ConfigResourceControl
|
||||
)
|
||||
|
||||
+272
-141
@@ -1,27 +1,62 @@
|
||||
---
|
||||
swagger: "2.0"
|
||||
info:
|
||||
description: "Portainer API is an HTTP API served by Portainer. It is used by the\
|
||||
\ Portainer UI and everything you can do with the UI can be done using the HTTP\
|
||||
\ API.\nYou can find out more about Portainer at [http://portainer.io](http://portainer.io)\
|
||||
\ and get some support on [Slack](http://portainer.io/slack/).\n\n# Authentication\n\
|
||||
\nMost of the API endpoints require to be authenticated as well as some level\
|
||||
\ of authorization to be used.\nPortainer API uses JSON Web Token to manage authentication\
|
||||
\ and thus requires you to provide a token in the **Authorization** header of\
|
||||
\ each request\nwith the **Bearer** authentication mechanism.\n\nExample:\n```\n\
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE\n\
|
||||
```\n\n# Security\n\nEach API endpoint has an associated access policy, it is\
|
||||
\ documented in the description of each endpoint.\n\nDifferent access policies\
|
||||
\ are available:\n* Public access\n* Authenticated access\n* Restricted access\n\
|
||||
* Administrator access\n\n### Public access\n\nNo authentication is required to\
|
||||
\ access the endpoints with this access policy.\n\n### Authenticated access\n\n\
|
||||
Authentication is required to access the endpoints with this access policy.\n\n\
|
||||
### Restricted access\n\nAuthentication is required to access the endpoints with\
|
||||
\ this access policy.\nExtra-checks might be added to ensure access to the resource\
|
||||
\ is granted. Returned data might also be filtered.\n\n### Administrator access\n\
|
||||
\nAuthentication as well as an administrator role are required to access the endpoints\
|
||||
\ with this access policy.\n"
|
||||
version: "1.14.0"
|
||||
description: |
|
||||
Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API.
|
||||
Examples are available at https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8
|
||||
You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/).
|
||||
|
||||
# Authentication
|
||||
|
||||
Most of the API endpoints require to be authenticated as well as some level of authorization to be used.
|
||||
Portainer API uses JSON Web Token to manage authentication and thus requires you to provide a token in the **Authorization** header of each request
|
||||
with the **Bearer** authentication mechanism.
|
||||
|
||||
Example:
|
||||
```
|
||||
Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsInJvbGUiOjEsImV4cCI6MTQ5OTM3NjE1NH0.NJ6vE8FY1WG6jsRQzfMqeatJ4vh2TWAeeYfDhP71YEE
|
||||
```
|
||||
|
||||
# Security
|
||||
|
||||
Each API endpoint has an associated access policy, it is documented in the description of each endpoint.
|
||||
|
||||
Different access policies are available:
|
||||
* Public access
|
||||
* Authenticated access
|
||||
* Restricted access
|
||||
* Administrator access
|
||||
|
||||
### Public access
|
||||
|
||||
No authentication is required to access the endpoints with this access policy.
|
||||
|
||||
### Authenticated access
|
||||
|
||||
Authentication is required to access the endpoints with this access policy.
|
||||
|
||||
### Restricted access
|
||||
|
||||
Authentication is required to access the endpoints with this access policy.
|
||||
Extra-checks might be added to ensure access to the resource is granted. Returned data might also be filtered.
|
||||
|
||||
### Administrator access
|
||||
|
||||
Authentication as well as an administrator role are required to access the endpoints with this access policy.
|
||||
|
||||
# Execute Docker requests
|
||||
|
||||
Portainer **DO NOT** expose specific endpoints to manage your Docker resources (create a container, remove a volume, etc...).
|
||||
|
||||
Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API.
|
||||
|
||||
To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This
|
||||
endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the
|
||||
Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API).
|
||||
|
||||
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
|
||||
|
||||
version: "1.15.2"
|
||||
title: "Portainer API"
|
||||
contact:
|
||||
email: "info@portainer.io"
|
||||
@@ -63,8 +98,9 @@ paths:
|
||||
tags:
|
||||
- "auth"
|
||||
summary: "Authenticate a user"
|
||||
description: "Use this endpoint to authenticate against Portainer using a username\
|
||||
\ and password. \n**Access policy**: public\n"
|
||||
description: |
|
||||
Use this endpoint to authenticate against Portainer using a username and password.
|
||||
**Access policy**: public
|
||||
operationId: "AuthenticateUser"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -105,8 +141,9 @@ paths:
|
||||
tags:
|
||||
- "dockerhub"
|
||||
summary: "Retrieve DockerHub information"
|
||||
description: "Use this endpoint to retrieve the information used to connect\
|
||||
\ to the DockerHub \n**Access policy**: authenticated\n"
|
||||
description: |
|
||||
Use this endpoint to retrieve the information used to connect to the DockerHub
|
||||
**Access policy**: authenticated
|
||||
operationId: "DockerHubInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -124,8 +161,9 @@ paths:
|
||||
tags:
|
||||
- "dockerhub"
|
||||
summary: "Update DockerHub information"
|
||||
description: "Use this endpoint to update the information used to connect to\
|
||||
\ the DockerHub \n**Access policy**: administrator\n"
|
||||
description: |
|
||||
Use this endpoint to update the information used to connect to the DockerHub
|
||||
**Access policy**: administrator
|
||||
operationId: "DockerHubUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -157,9 +195,11 @@ paths:
|
||||
tags:
|
||||
- "endpoints"
|
||||
summary: "List endpoints"
|
||||
description: "List all endpoints based on the current user authorizations. Will\n\
|
||||
return all endpoints if using an administrator account otherwise it will\n\
|
||||
only return authorized endpoints. \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
List all endpoints based on the current user authorizations. Will
|
||||
return all endpoints if using an administrator account otherwise it will
|
||||
only return authorized endpoints.
|
||||
**Access policy**: restricted
|
||||
operationId: "EndpointList"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -177,8 +217,9 @@ paths:
|
||||
tags:
|
||||
- "endpoints"
|
||||
summary: "Create a new endpoint"
|
||||
description: "Create a new endpoint that will be used to manage a Docker environment.\
|
||||
\ \n**Access policy**: administrator\n"
|
||||
description: |
|
||||
Create a new endpoint that will be used to manage a Docker environment.
|
||||
**Access policy**: administrator
|
||||
operationId: "EndpointCreate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -219,8 +260,9 @@ paths:
|
||||
tags:
|
||||
- "endpoints"
|
||||
summary: "Inspect an endpoint"
|
||||
description: "Retrieve details abount an endpoint. \n**Access policy**: administrator\
|
||||
\ \n"
|
||||
description: |
|
||||
Retrieve details abount an endpoint.
|
||||
**Access policy**: administrator
|
||||
operationId: "EndpointInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -257,7 +299,9 @@ paths:
|
||||
tags:
|
||||
- "endpoints"
|
||||
summary: "Update an endpoint"
|
||||
description: "Update an endpoint. \n**Access policy**: administrator\n"
|
||||
description: |
|
||||
Update an endpoint.
|
||||
**Access policy**: administrator
|
||||
operationId: "EndpointUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -307,7 +351,9 @@ paths:
|
||||
tags:
|
||||
- "endpoints"
|
||||
summary: "Remove an endpoint"
|
||||
description: "Remove an endpoint. \n**Access policy**: administrator \n"
|
||||
description: |
|
||||
Remove an endpoint.
|
||||
**Access policy**: administrator
|
||||
operationId: "EndpointDelete"
|
||||
parameters:
|
||||
- name: "id"
|
||||
@@ -348,8 +394,9 @@ paths:
|
||||
tags:
|
||||
- "endpoints"
|
||||
summary: "Manage accesses to an endpoint"
|
||||
description: "Manage user and team accesses to an endpoint. \n**Access policy**:\
|
||||
\ administrator \n"
|
||||
description: |
|
||||
Manage user and team accesses to an endpoint.
|
||||
**Access policy**: administrator
|
||||
operationId: "EndpointAccessUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -388,15 +435,17 @@ paths:
|
||||
description: "Server error"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
|
||||
/registries:
|
||||
get:
|
||||
tags:
|
||||
- "registries"
|
||||
summary: "List registries"
|
||||
description: "List all registries based on the current user authorizations.\n\
|
||||
Will return all registries if using an administrator account otherwise it\n\
|
||||
will only return authorized registries. \n**Access policy**: restricted \
|
||||
\ \n"
|
||||
description: |
|
||||
List all registries based on the current user authorizations.
|
||||
Will return all registries if using an administrator account otherwise it
|
||||
will only return authorized registries.
|
||||
**Access policy**: restricted
|
||||
operationId: "RegistryList"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -414,8 +463,9 @@ paths:
|
||||
tags:
|
||||
- "registries"
|
||||
summary: "Create a new registry"
|
||||
description: "Create a new registry. \n**Access policy**: administrator \
|
||||
\ \n"
|
||||
description: |
|
||||
Create a new registry.
|
||||
**Access policy**: administrator
|
||||
operationId: "RegistryCreate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -456,8 +506,9 @@ paths:
|
||||
tags:
|
||||
- "registries"
|
||||
summary: "Inspect a registry"
|
||||
description: "Retrieve details about a registry. \n**Access policy**: administrator\
|
||||
\ \n"
|
||||
description: |
|
||||
Retrieve details about a registry.
|
||||
**Access policy**: administrator
|
||||
operationId: "RegistryInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -494,7 +545,9 @@ paths:
|
||||
tags:
|
||||
- "registries"
|
||||
summary: "Update a registry"
|
||||
description: "Update a registry. \n**Access policy**: administrator \n"
|
||||
description: |
|
||||
Update a registry.
|
||||
**Access policy**: administrator
|
||||
operationId: "RegistryUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -551,8 +604,9 @@ paths:
|
||||
tags:
|
||||
- "registries"
|
||||
summary: "Remove a registry"
|
||||
description: "Remove a registry. \n**Access policy**: administrator \
|
||||
\ \n"
|
||||
description: |
|
||||
Remove a registry.
|
||||
**Access policy**: administrator
|
||||
operationId: "RegistryDelete"
|
||||
parameters:
|
||||
- name: "id"
|
||||
@@ -586,8 +640,9 @@ paths:
|
||||
tags:
|
||||
- "registries"
|
||||
summary: "Manage accesses to a registry"
|
||||
description: "Manage user and team accesses to a registry. \n**Access policy**:\
|
||||
\ administrator \n"
|
||||
description: |
|
||||
Manage user and team accesses to a registry.
|
||||
**Access policy**: administrator
|
||||
operationId: "RegistryAccessUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -631,8 +686,9 @@ paths:
|
||||
tags:
|
||||
- "resource_controls"
|
||||
summary: "Create a new resource control"
|
||||
description: "Create a new resource control to restrict access to a Docker resource.\
|
||||
\ \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
Create a new resource control to restrict access to a Docker resource.
|
||||
**Access policy**: restricted
|
||||
operationId: "ResourceControlCreate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -678,8 +734,9 @@ paths:
|
||||
tags:
|
||||
- "resource_controls"
|
||||
summary: "Update a resource control"
|
||||
description: "Update a resource control. \n**Access policy**: restricted \
|
||||
\ \n"
|
||||
description: |
|
||||
Update a resource control.
|
||||
**Access policy**: restricted
|
||||
operationId: "ResourceControlUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -729,8 +786,9 @@ paths:
|
||||
tags:
|
||||
- "resource_controls"
|
||||
summary: "Remove a resource control"
|
||||
description: "Remove a resource control. \n**Access policy**: restricted \
|
||||
\ \n"
|
||||
description: |
|
||||
Remove a resource control.
|
||||
**Access policy**: restricted
|
||||
operationId: "ResourceControlDelete"
|
||||
parameters:
|
||||
- name: "id"
|
||||
@@ -771,8 +829,9 @@ paths:
|
||||
tags:
|
||||
- "settings"
|
||||
summary: "Retrieve Portainer settings"
|
||||
description: "Retrieve Portainer settings. \n**Access policy**: administrator\
|
||||
\ \n"
|
||||
description: |
|
||||
Retrieve Portainer settings.
|
||||
**Access policy**: administrator
|
||||
operationId: "SettingsInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -790,8 +849,9 @@ paths:
|
||||
tags:
|
||||
- "settings"
|
||||
summary: "Update Portainer settings"
|
||||
description: "Update Portainer settings. \n**Access policy**: administrator\
|
||||
\ \n"
|
||||
description: |
|
||||
Update Portainer settings.
|
||||
**Access policy**: administrator
|
||||
operationId: "SettingsUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -823,9 +883,9 @@ paths:
|
||||
tags:
|
||||
- "settings"
|
||||
summary: "Retrieve Portainer public settings"
|
||||
description: "Retrieve public settings. Returns a small set of settings that\
|
||||
\ are not reserved to administrators only. \n**Access policy**: public \
|
||||
\ \n"
|
||||
description: |
|
||||
Retrieve public settings. Returns a small set of settings that are not reserved to administrators only.
|
||||
**Access policy**: public
|
||||
operationId: "PublicSettingsInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -844,8 +904,9 @@ paths:
|
||||
tags:
|
||||
- "settings"
|
||||
summary: "Test LDAP connectivity"
|
||||
description: "Test LDAP connectivity using LDAP details. \n**Access policy**:\
|
||||
\ administrator \n"
|
||||
description: |
|
||||
Test LDAP connectivity using LDAP details.
|
||||
**Access policy**: administrator
|
||||
operationId: "SettingsLDAPCheck"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -877,8 +938,9 @@ paths:
|
||||
tags:
|
||||
- "status"
|
||||
summary: "Check Portainer status"
|
||||
description: "Retrieve Portainer status. \n**Access policy**: public \
|
||||
\ \n"
|
||||
description: |
|
||||
Retrieve Portainer status.
|
||||
**Access policy**: public
|
||||
operationId: "StatusInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -897,9 +959,9 @@ paths:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "List users"
|
||||
description: "List Portainer users. Non-administrator users will only be able\
|
||||
\ to list other non-administrator user accounts. \n**Access policy**: restricted\
|
||||
\ \n"
|
||||
description: |
|
||||
List Portainer users. Non-administrator users will only be able to list other non-administrator user accounts.
|
||||
**Access policy**: restricted
|
||||
operationId: "UserList"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -917,9 +979,10 @@ paths:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "Create a new user"
|
||||
description: "Create a new Portainer user. Only team leaders and administrators\
|
||||
\ can create users. Only administrators can\ncreate an administrator user\
|
||||
\ account. \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
Create a new Portainer user. Only team leaders and administrators can create users. Only administrators can
|
||||
create an administrator user account.
|
||||
**Access policy**: restricted
|
||||
operationId: "UserCreate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -967,8 +1030,9 @@ paths:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "Inspect a user"
|
||||
description: "Retrieve details about a user. \n**Access policy**: administrator\
|
||||
\ \n"
|
||||
description: |
|
||||
Retrieve details about a user.
|
||||
**Access policy**: administrator
|
||||
operationId: "UserInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -1005,8 +1069,9 @@ paths:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "Update a user"
|
||||
description: "Update user details. A regular user account can only update his\
|
||||
\ details. \n**Access policy**: authenticated \n"
|
||||
description: |
|
||||
Update user details. A regular user account can only update his details.
|
||||
**Access policy**: authenticated
|
||||
operationId: "UserUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -1056,7 +1121,9 @@ paths:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "Remove a user"
|
||||
description: "Remove a user. \n**Access policy**: administrator \n"
|
||||
description: |
|
||||
Remove a user.
|
||||
**Access policy**: administrator
|
||||
operationId: "UserDelete"
|
||||
parameters:
|
||||
- name: "id"
|
||||
@@ -1090,8 +1157,9 @@ paths:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "Inspect a user memberships"
|
||||
description: "Inspect a user memberships. \n**Access policy**: authenticated\
|
||||
\ \n"
|
||||
description: |
|
||||
Inspect a user memberships.
|
||||
**Access policy**: authenticated
|
||||
operationId: "UserMembershipsInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -1124,13 +1192,15 @@ paths:
|
||||
description: "Server error"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
|
||||
/users/{id}/passwd:
|
||||
post:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "Check password validity for a user"
|
||||
description: "Check if the submitted password is valid for the specified user.\
|
||||
\ \n**Access policy**: authenticated \n"
|
||||
description: |
|
||||
Check if the submitted password is valid for the specified user.
|
||||
**Access policy**: authenticated
|
||||
operationId: "UserPasswordCheck"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -1171,13 +1241,15 @@ paths:
|
||||
description: "Server error"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
|
||||
/users/admin/check:
|
||||
get:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "Check administrator account existence"
|
||||
description: "Check if an administrator account exists in the database.\n**Access\
|
||||
\ policy**: public \n"
|
||||
description: |
|
||||
Check if an administrator account exists in the database.
|
||||
**Access policy**: public
|
||||
operationId: "UserAdminCheck"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -1198,13 +1270,15 @@ paths:
|
||||
description: "Server error"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
|
||||
/users/admin/init:
|
||||
post:
|
||||
tags:
|
||||
- "users"
|
||||
summary: "Initialize administrator account"
|
||||
description: "Initialize the 'admin' user account.\n**Access policy**: public\
|
||||
\ \n"
|
||||
description: |
|
||||
Initialize the 'admin' user account.
|
||||
**Access policy**: public
|
||||
operationId: "UserAdminInit"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -1238,34 +1312,35 @@ paths:
|
||||
description: "Server error"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
|
||||
/upload/tls/{certificate}:
|
||||
post:
|
||||
tags:
|
||||
- "upload"
|
||||
summary: "Upload TLS files"
|
||||
description: "Use this endpoint to upload TLS files. \n**Access policy**: administrator\n"
|
||||
description: |
|
||||
Use this endpoint to upload TLS files.
|
||||
**Access policy**: administrator
|
||||
operationId: "UploadTLS"
|
||||
consumes:
|
||||
- "multipart/form-data"
|
||||
- multipart/form-data
|
||||
produces:
|
||||
- "application/json"
|
||||
parameters:
|
||||
- name: "certificate"
|
||||
in: "path"
|
||||
- in: "path"
|
||||
name: "certificate"
|
||||
description: "TLS file type. Valid values are 'ca', 'cert' or 'key'."
|
||||
required: true
|
||||
type: "string"
|
||||
- name: "folder"
|
||||
in: "query"
|
||||
description: "Folder where the TLS file will be stored. Will be created if\
|
||||
\ not existing."
|
||||
- in: "query"
|
||||
name: "folder"
|
||||
description: "Folder where the TLS file will be stored. Will be created if not existing."
|
||||
required: true
|
||||
type: "string"
|
||||
- name: "file"
|
||||
in: "formData"
|
||||
description: "The file to upload."
|
||||
required: false
|
||||
- in: "formData"
|
||||
name: "file"
|
||||
type: "file"
|
||||
description: "The file to upload."
|
||||
responses:
|
||||
200:
|
||||
description: "Success"
|
||||
@@ -1280,13 +1355,15 @@ paths:
|
||||
description: "Server error"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
|
||||
/teams:
|
||||
get:
|
||||
tags:
|
||||
- "teams"
|
||||
summary: "List teams"
|
||||
description: "List teams. For non-administrator users, will only list the teams\
|
||||
\ they are member of. \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
List teams. For non-administrator users, will only list the teams they are member of.
|
||||
**Access policy**: restricted
|
||||
operationId: "TeamList"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -1304,8 +1381,9 @@ paths:
|
||||
tags:
|
||||
- "teams"
|
||||
summary: "Create a new team"
|
||||
description: "Create a new team. \n**Access policy**: administrator \
|
||||
\ \n"
|
||||
description: |
|
||||
Create a new team.
|
||||
**Access policy**: administrator
|
||||
operationId: "TeamCreate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -1353,8 +1431,9 @@ paths:
|
||||
tags:
|
||||
- "teams"
|
||||
summary: "Inspect a team"
|
||||
description: "Retrieve details about a team. Access is only available for administrator\
|
||||
\ and leaders of that team. \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
Retrieve details about a team. Access is only available for administrator and leaders of that team.
|
||||
**Access policy**: restricted
|
||||
operationId: "TeamInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -1398,8 +1477,9 @@ paths:
|
||||
tags:
|
||||
- "teams"
|
||||
summary: "Update a team"
|
||||
description: "Update a team. \n**Access policy**: administrator \
|
||||
\ \n"
|
||||
description: |
|
||||
Update a team.
|
||||
**Access policy**: administrator
|
||||
operationId: "TeamUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -1442,7 +1522,9 @@ paths:
|
||||
tags:
|
||||
- "teams"
|
||||
summary: "Remove a team"
|
||||
description: "Remove a team. \n**Access policy**: administrator \n"
|
||||
description: |
|
||||
Remove a team.
|
||||
**Access policy**: administrator
|
||||
operationId: "TeamDelete"
|
||||
parameters:
|
||||
- name: "id"
|
||||
@@ -1471,13 +1553,15 @@ paths:
|
||||
description: "Server error"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
|
||||
/teams/{id}/memberships:
|
||||
get:
|
||||
tags:
|
||||
- "teams"
|
||||
summary: "Inspect a team memberships"
|
||||
description: "Inspect a team memberships. Access is only available for administrator\
|
||||
\ and leaders of that team. \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
Inspect a team memberships. Access is only available for administrator and leaders of that team.
|
||||
**Access policy**: restricted
|
||||
operationId: "TeamMembershipsInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -1510,13 +1594,15 @@ paths:
|
||||
description: "Server error"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
|
||||
/team_memberships:
|
||||
get:
|
||||
tags:
|
||||
- "team_memberships"
|
||||
summary: "List team memberships"
|
||||
description: "List team memberships. Access is only available to administrators\
|
||||
\ and team leaders. \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
List team memberships. Access is only available to administrators and team leaders.
|
||||
**Access policy**: restricted
|
||||
operationId: "TeamMembershipList"
|
||||
produces:
|
||||
- "application/json"
|
||||
@@ -1541,8 +1627,9 @@ paths:
|
||||
tags:
|
||||
- "team_memberships"
|
||||
summary: "Create a new team membership"
|
||||
description: "Create a new team memberships. Access is only available to administrators\
|
||||
\ leaders of the associated team. \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
Create a new team memberships. Access is only available to administrators leaders of the associated team.
|
||||
**Access policy**: restricted
|
||||
operationId: "TeamMembershipCreate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -1590,9 +1677,9 @@ paths:
|
||||
tags:
|
||||
- "team_memberships"
|
||||
summary: "Update a team membership"
|
||||
description: "Update a team membership. Access is only available to administrators\
|
||||
\ leaders of the associated team. \n**Access policy**: restricted \
|
||||
\ \n"
|
||||
description: |
|
||||
Update a team membership. Access is only available to administrators leaders of the associated team.
|
||||
**Access policy**: restricted
|
||||
operationId: "TeamMembershipUpdate"
|
||||
consumes:
|
||||
- "application/json"
|
||||
@@ -1642,8 +1729,9 @@ paths:
|
||||
tags:
|
||||
- "team_memberships"
|
||||
summary: "Remove a team membership"
|
||||
description: "Remove a team membership. Access is only available to administrators\
|
||||
\ leaders of the associated team. \n**Access policy**: restricted \n"
|
||||
description: |
|
||||
Remove a team membership. Access is only available to administrators leaders of the associated team.
|
||||
**Access policy**: restricted
|
||||
operationId: "TeamMembershipDelete"
|
||||
parameters:
|
||||
- name: "id"
|
||||
@@ -1684,17 +1772,18 @@ paths:
|
||||
tags:
|
||||
- "templates"
|
||||
summary: "Retrieve App templates"
|
||||
description: "Retrieve App templates. \nYou can find more information about\
|
||||
\ the format at http://portainer.readthedocs.io/en/stable/templates.html \
|
||||
\ \n**Access policy**: authenticated \n"
|
||||
description: |
|
||||
Retrieve App templates.
|
||||
You can find more information about the format at http://portainer.readthedocs.io/en/stable/templates.html
|
||||
**Access policy**: authenticated
|
||||
operationId: "TemplateList"
|
||||
produces:
|
||||
- "application/json"
|
||||
parameters:
|
||||
- name: "key"
|
||||
in: "query"
|
||||
description: "Templates key. Valid values are 'container' or 'linuxserver.io'."
|
||||
required: true
|
||||
description: "Templates key. Valid values are 'container' or 'linuxserver.io'."
|
||||
type: "string"
|
||||
responses:
|
||||
200:
|
||||
@@ -1780,7 +1869,7 @@ definitions:
|
||||
description: "Is analytics enabled"
|
||||
Version:
|
||||
type: "string"
|
||||
example: "1.14.0"
|
||||
example: "1.15.2"
|
||||
description: "Portainer API version"
|
||||
PublicSettingsInspectResponse:
|
||||
type: "object"
|
||||
@@ -1799,8 +1888,15 @@ definitions:
|
||||
AuthenticationMethod:
|
||||
type: "integer"
|
||||
example: 1
|
||||
description: "Active authentication method for the Portainer instance. Valid\
|
||||
\ values are: 1 for managed or 2 for LDAP."
|
||||
description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP."
|
||||
AllowBindMountsForRegularUsers:
|
||||
type: "boolean"
|
||||
example: false
|
||||
description: "Whether non-administrator should be able to use bind mounts when creating containers"
|
||||
AllowPrivilegedModeForRegularUsers:
|
||||
type: "boolean"
|
||||
example: true
|
||||
description: "Whether non-administrator should be able to use privileged mode when creating containers"
|
||||
TLSConfiguration:
|
||||
type: "object"
|
||||
properties:
|
||||
@@ -1824,14 +1920,14 @@ definitions:
|
||||
type: "string"
|
||||
example: "/data/tls/key.pem"
|
||||
description: "Path to the TLS client key file"
|
||||
|
||||
LDAPSearchSettings:
|
||||
type: "object"
|
||||
properties:
|
||||
BaseDN:
|
||||
type: "string"
|
||||
example: "dc=ldap,dc=domain,dc=tld"
|
||||
description: "The distinguished name of the element from which the LDAP server\
|
||||
\ will search for users"
|
||||
description: "The distinguished name of the element from which the LDAP server will search for users"
|
||||
Filter:
|
||||
type: "string"
|
||||
example: "(objectClass=account)"
|
||||
@@ -1840,6 +1936,7 @@ definitions:
|
||||
type: "string"
|
||||
example: "uid"
|
||||
description: "LDAP attribute which denotes the username"
|
||||
|
||||
LDAPSettings:
|
||||
type: "object"
|
||||
properties:
|
||||
@@ -1865,6 +1962,7 @@ definitions:
|
||||
type: "array"
|
||||
items:
|
||||
$ref: "#/definitions/LDAPSearchSettings"
|
||||
|
||||
Settings:
|
||||
type: "object"
|
||||
properties:
|
||||
@@ -1893,10 +1991,17 @@ definitions:
|
||||
AuthenticationMethod:
|
||||
type: "integer"
|
||||
example: 1
|
||||
description: "Active authentication method for the Portainer instance. Valid\
|
||||
\ values are: 1 for managed or 2 for LDAP."
|
||||
description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP."
|
||||
LDAPSettings:
|
||||
$ref: "#/definitions/LDAPSettings"
|
||||
AllowBindMountsForRegularUsers:
|
||||
type: "boolean"
|
||||
example: false
|
||||
description: "Whether non-administrator should be able to use bind mounts when creating containers"
|
||||
AllowPrivilegedModeForRegularUsers:
|
||||
type: "boolean"
|
||||
example: true
|
||||
description: "Whether non-administrator should be able to use privileged mode when creating containers"
|
||||
Settings_BlackListedLabels:
|
||||
properties:
|
||||
name:
|
||||
@@ -2060,6 +2165,14 @@ definitions:
|
||||
type: "boolean"
|
||||
example: true
|
||||
description: "Require TLS to connect against this endpoint"
|
||||
TLSSkipVerify:
|
||||
type: "boolean"
|
||||
example: false
|
||||
description: "Skip server verification when using TLS"
|
||||
TLSSkipClientVerify:
|
||||
type: "boolean"
|
||||
example: false
|
||||
description: "Skip client verification when using TLS"
|
||||
EndpointCreateResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
@@ -2091,6 +2204,14 @@ definitions:
|
||||
type: "boolean"
|
||||
example: true
|
||||
description: "Require TLS to connect against this endpoint"
|
||||
TLSSkipVerify:
|
||||
type: "boolean"
|
||||
example: false
|
||||
description: "Skip server verification when using TLS"
|
||||
TLSSkipClientVerify:
|
||||
type: "boolean"
|
||||
example: false
|
||||
description: "Skip client verification when using TLS"
|
||||
EndpointAccessUpdateRequest:
|
||||
type: "object"
|
||||
properties:
|
||||
@@ -2257,8 +2378,8 @@ definitions:
|
||||
SettingsUpdateRequest:
|
||||
type: "object"
|
||||
required:
|
||||
- "AuthenticationMethod"
|
||||
- "TemplatesURL"
|
||||
- "AuthenticationMethod"
|
||||
properties:
|
||||
TemplatesURL:
|
||||
type: "string"
|
||||
@@ -2285,10 +2406,17 @@ definitions:
|
||||
AuthenticationMethod:
|
||||
type: "integer"
|
||||
example: 1
|
||||
description: "Active authentication method for the Portainer instance. Valid\
|
||||
\ values are: 1 for managed or 2 for LDAP."
|
||||
description: "Active authentication method for the Portainer instance. Valid values are: 1 for managed or 2 for LDAP."
|
||||
LDAPSettings:
|
||||
$ref: "#/definitions/LDAPSettings"
|
||||
AllowBindMountsForRegularUsers:
|
||||
type: "boolean"
|
||||
example: true
|
||||
description: "Whether non-administrator users should be able to use bind mounts when creating containers"
|
||||
AllowPrivilegedModeForRegularUsers:
|
||||
type: "boolean"
|
||||
example: true
|
||||
description: "Whether non-administrator users should be able to use privileged mode when creating containers"
|
||||
UserCreateRequest:
|
||||
type: "object"
|
||||
required:
|
||||
@@ -2383,12 +2511,13 @@ definitions:
|
||||
type: "array"
|
||||
items:
|
||||
$ref: "#/definitions/TeamMembership"
|
||||
|
||||
TeamMembershipCreateRequest:
|
||||
type: "object"
|
||||
required:
|
||||
- "Role"
|
||||
- "TeamID"
|
||||
- "UserID"
|
||||
- "TeamID"
|
||||
- "Role"
|
||||
properties:
|
||||
UserID:
|
||||
type: "integer"
|
||||
@@ -2401,8 +2530,7 @@ definitions:
|
||||
Role:
|
||||
type: "integer"
|
||||
example: 1
|
||||
description: "Role for the user inside the team (1 for leader and 2 for regular\
|
||||
\ member)"
|
||||
description: "Role for the user inside the team (1 for leader and 2 for regular member)"
|
||||
TeamMembershipCreateResponse:
|
||||
type: "object"
|
||||
properties:
|
||||
@@ -2417,9 +2545,9 @@ definitions:
|
||||
TeamMembershipUpdateRequest:
|
||||
type: "object"
|
||||
required:
|
||||
- "Role"
|
||||
- "TeamID"
|
||||
- "UserID"
|
||||
- "TeamID"
|
||||
- "Role"
|
||||
properties:
|
||||
UserID:
|
||||
type: "integer"
|
||||
@@ -2432,8 +2560,7 @@ definitions:
|
||||
Role:
|
||||
type: "integer"
|
||||
example: 1
|
||||
description: "Role for the user inside the team (1 for leader and 2 for regular\
|
||||
\ member)"
|
||||
description: "Role for the user inside the team (1 for leader and 2 for regular member)"
|
||||
SettingsLDAPCheckRequest:
|
||||
type: "object"
|
||||
properties:
|
||||
@@ -2442,6 +2569,10 @@ definitions:
|
||||
UserAdminInitRequest:
|
||||
type: "object"
|
||||
properties:
|
||||
Username:
|
||||
type: "string"
|
||||
example: "admin"
|
||||
description: "Username for the admin user"
|
||||
Password:
|
||||
type: "string"
|
||||
example: "admin-password"
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
angular.module('portainer', [
|
||||
'ui.bootstrap',
|
||||
'ui.router',
|
||||
'isteven-multi-select',
|
||||
'ngCookies',
|
||||
'ngSanitize',
|
||||
'ngFileUpload',
|
||||
'angularUtils.directives.dirPagination',
|
||||
'LocalStorageModule',
|
||||
'angular-jwt',
|
||||
'angular-google-analytics',
|
||||
'angular-loading-bar',
|
||||
'portainer.templates',
|
||||
'portainer.filters',
|
||||
'portainer.rest',
|
||||
'portainer.helpers',
|
||||
'portainer.services',
|
||||
'auth',
|
||||
'dashboard',
|
||||
'config',
|
||||
'configs',
|
||||
'container',
|
||||
'containerConsole',
|
||||
'containerLogs',
|
||||
'containerStats',
|
||||
'containerInspect',
|
||||
'serviceLogs',
|
||||
'containers',
|
||||
'createConfig',
|
||||
'createContainer',
|
||||
'createNetwork',
|
||||
'createRegistry',
|
||||
'createSecret',
|
||||
'createService',
|
||||
'createVolume',
|
||||
'createStack',
|
||||
'engine',
|
||||
'endpoint',
|
||||
'endpointAccess',
|
||||
'endpoints',
|
||||
'events',
|
||||
'image',
|
||||
'images',
|
||||
'initAdmin',
|
||||
'initEndpoint',
|
||||
'main',
|
||||
'network',
|
||||
'networks',
|
||||
'node',
|
||||
'registries',
|
||||
'registry',
|
||||
'registryAccess',
|
||||
'secrets',
|
||||
'secret',
|
||||
'service',
|
||||
'services',
|
||||
'settings',
|
||||
'settingsAuthentication',
|
||||
'sidebar',
|
||||
'stack',
|
||||
'stacks',
|
||||
'swarm',
|
||||
'swarmVisualizer',
|
||||
'task',
|
||||
'team',
|
||||
'teams',
|
||||
'templates',
|
||||
'user',
|
||||
'users',
|
||||
'userSettings',
|
||||
'volume',
|
||||
'volumes',
|
||||
'rzModule']);
|
||||
+43
-766
@@ -1,773 +1,50 @@
|
||||
angular.module('portainer.filters', []);
|
||||
angular.module('portainer.rest', ['ngResource']);
|
||||
angular.module('portainer.services', []);
|
||||
angular.module('portainer.helpers', []);
|
||||
angular.module('portainer', [
|
||||
'ui.bootstrap',
|
||||
'ui.router',
|
||||
'isteven-multi-select',
|
||||
'ngCookies',
|
||||
'ngSanitize',
|
||||
'ngFileUpload',
|
||||
'angularUtils.directives.dirPagination',
|
||||
'LocalStorageModule',
|
||||
'angular-jwt',
|
||||
'angular-google-analytics',
|
||||
'portainer.templates',
|
||||
'portainer.filters',
|
||||
'portainer.rest',
|
||||
'portainer.helpers',
|
||||
'portainer.services',
|
||||
'auth',
|
||||
'dashboard',
|
||||
'container',
|
||||
'containerConsole',
|
||||
'containerLogs',
|
||||
'serviceLogs',
|
||||
'containers',
|
||||
'createContainer',
|
||||
'createNetwork',
|
||||
'createRegistry',
|
||||
'createSecret',
|
||||
'createService',
|
||||
'createVolume',
|
||||
'docker',
|
||||
'endpoint',
|
||||
'endpointAccess',
|
||||
'endpointInit',
|
||||
'endpoints',
|
||||
'events',
|
||||
'image',
|
||||
'images',
|
||||
'main',
|
||||
'network',
|
||||
'networks',
|
||||
'node',
|
||||
'registries',
|
||||
'registry',
|
||||
'registryAccess',
|
||||
'secrets',
|
||||
'secret',
|
||||
'service',
|
||||
'services',
|
||||
'settings',
|
||||
'settingsAuthentication',
|
||||
'sidebar',
|
||||
'stats',
|
||||
'swarm',
|
||||
'task',
|
||||
'team',
|
||||
'teams',
|
||||
'templates',
|
||||
'user',
|
||||
'users',
|
||||
'userSettings',
|
||||
'volume',
|
||||
'volumes'])
|
||||
.config(['$stateProvider', '$urlRouterProvider', '$httpProvider', 'localStorageServiceProvider', 'jwtOptionsProvider', 'AnalyticsProvider', '$uibTooltipProvider', '$compileProvider', function ($stateProvider, $urlRouterProvider, $httpProvider, localStorageServiceProvider, jwtOptionsProvider, AnalyticsProvider, $uibTooltipProvider, $compileProvider) {
|
||||
'use strict';
|
||||
angular.module('portainer')
|
||||
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar) {
|
||||
'use strict';
|
||||
|
||||
var environment = '@@ENVIRONMENT';
|
||||
if (environment === 'production') {
|
||||
$compileProvider.debugInfoEnabled(false);
|
||||
EndpointProvider.initialize();
|
||||
|
||||
StateManager.initialize()
|
||||
.then(function success(state) {
|
||||
if (state.application.authentication) {
|
||||
initAuthentication(authManager, Authentication, $rootScope);
|
||||
}
|
||||
if (state.application.analytics) {
|
||||
initAnalytics(Analytics, $rootScope);
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||
});
|
||||
|
||||
localStorageServiceProvider
|
||||
.setPrefix('portainer');
|
||||
$rootScope.$state = $state;
|
||||
|
||||
jwtOptionsProvider.config({
|
||||
tokenGetter: ['LocalStorage', function(LocalStorage) {
|
||||
return LocalStorage.getJWT();
|
||||
}],
|
||||
unauthenticatedRedirector: ['$state', function($state) {
|
||||
$state.go('auth', {error: 'Your session has expired'});
|
||||
}]
|
||||
});
|
||||
$httpProvider.interceptors.push('jwtInterceptor');
|
||||
// Workaround to prevent the loading bar from going backward
|
||||
// https://github.com/chieffancypants/angular-loading-bar/issues/273
|
||||
var originalSet = cfpLoadingBar.set;
|
||||
cfpLoadingBar.set = function overrideSet(n) {
|
||||
if (n > cfpLoadingBar.status()) {
|
||||
originalSet.apply(cfpLoadingBar, arguments);
|
||||
}
|
||||
};
|
||||
}]);
|
||||
|
||||
AnalyticsProvider.setAccount('@@CONFIG_GA_ID');
|
||||
AnalyticsProvider.startOffline(true);
|
||||
|
||||
$urlRouterProvider.otherwise('/auth');
|
||||
function initAuthentication(authManager, Authentication, $rootScope) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
authManager.redirectWhenUnauthenticated();
|
||||
Authentication.init();
|
||||
$rootScope.$on('tokenHasExpired', function() {
|
||||
$state.go('auth', {error: 'Your session has expired'});
|
||||
});
|
||||
}
|
||||
|
||||
toastr.options.timeOut = 3000;
|
||||
|
||||
$uibTooltipProvider.setTriggers({
|
||||
'mouseenter': 'mouseleave',
|
||||
'click': 'click',
|
||||
'focus': 'blur',
|
||||
'outsideClick': 'outsideClick'
|
||||
});
|
||||
|
||||
$stateProvider
|
||||
.state('root', {
|
||||
abstract: true,
|
||||
resolve: {
|
||||
requiresLogin: ['StateManager', function (StateManager) {
|
||||
var applicationState = StateManager.getState();
|
||||
return applicationState.application.authentication;
|
||||
}]
|
||||
}
|
||||
})
|
||||
.state('auth', {
|
||||
parent: 'root',
|
||||
url: '/auth',
|
||||
params: {
|
||||
logout: false,
|
||||
error: ''
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/auth/auth.html',
|
||||
controller: 'AuthenticationController'
|
||||
}
|
||||
},
|
||||
data: {
|
||||
requiresLogin: false
|
||||
}
|
||||
})
|
||||
.state('containers', {
|
||||
parent: 'root',
|
||||
url: '/containers/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/containers/containers.html',
|
||||
controller: 'ContainersController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('container', {
|
||||
url: '^/containers/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/container/container.html',
|
||||
controller: 'ContainerController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('stats', {
|
||||
url: '^/containers/:id/stats',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/stats/stats.html',
|
||||
controller: 'StatsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('containerlogs', {
|
||||
url: '^/containers/:id/logs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/containerLogs/containerlogs.html',
|
||||
controller: 'ContainerLogsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('servicelogs', {
|
||||
url: '^/services/:id/logs',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/serviceLogs/servicelogs.html',
|
||||
controller: 'ServiceLogsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('console', {
|
||||
url: '^/containers/:id/console',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/containerConsole/containerConsole.html',
|
||||
controller: 'ContainerConsoleController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('dashboard', {
|
||||
parent: 'root',
|
||||
url: '/dashboard',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/dashboard/dashboard.html',
|
||||
controller: 'DashboardController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions', {
|
||||
abstract: true,
|
||||
url: '/actions',
|
||||
views: {
|
||||
'content@': {
|
||||
template: '<div ui-view="content@"></div>'
|
||||
},
|
||||
'sidebar@': {
|
||||
template: '<div ui-view="sidebar@"></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create', {
|
||||
abstract: true,
|
||||
url: '/create',
|
||||
views: {
|
||||
'content@': {
|
||||
template: '<div ui-view="content@"></div>'
|
||||
},
|
||||
'sidebar@': {
|
||||
template: '<div ui-view="sidebar@"></div>'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.container', {
|
||||
url: '/container/:from',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createContainer/createcontainer.html',
|
||||
controller: 'CreateContainerController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.network', {
|
||||
url: '/network',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createNetwork/createnetwork.html',
|
||||
controller: 'CreateNetworkController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.registry', {
|
||||
url: '/registry',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createRegistry/createregistry.html',
|
||||
controller: 'CreateRegistryController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.secret', {
|
||||
url: '/secret',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createSecret/createsecret.html',
|
||||
controller: 'CreateSecretController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.service', {
|
||||
url: '/service',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createService/createservice.html',
|
||||
controller: 'CreateServiceController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('actions.create.volume', {
|
||||
url: '/volume',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/createVolume/createvolume.html',
|
||||
controller: 'CreateVolumeController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('docker', {
|
||||
url: '/docker/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/docker/docker.html',
|
||||
controller: 'DockerController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('endpoints', {
|
||||
url: '/endpoints/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/endpoints/endpoints.html',
|
||||
controller: 'EndpointsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('endpoint', {
|
||||
url: '^/endpoints/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/endpoint/endpoint.html',
|
||||
controller: 'EndpointController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('endpoint.access', {
|
||||
url: '^/endpoints/:id/access',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/endpointAccess/endpointAccess.html',
|
||||
controller: 'EndpointAccessController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('endpointInit', {
|
||||
url: '/init/endpoint',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/endpointInit/endpointInit.html',
|
||||
controller: 'EndpointInitController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('events', {
|
||||
url: '/events/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/events/events.html',
|
||||
controller: 'EventsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('images', {
|
||||
url: '/images/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/images/images.html',
|
||||
controller: 'ImagesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('image', {
|
||||
url: '^/images/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/image/image.html',
|
||||
controller: 'ImageController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('networks', {
|
||||
url: '/networks/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/networks/networks.html',
|
||||
controller: 'NetworksController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('network', {
|
||||
url: '^/networks/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/network/network.html',
|
||||
controller: 'NetworkController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('node', {
|
||||
url: '^/nodes/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/node/node.html',
|
||||
controller: 'NodeController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('registries', {
|
||||
url: '/registries/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/registries/registries.html',
|
||||
controller: 'RegistriesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('registry', {
|
||||
url: '^/registries/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/registry/registry.html',
|
||||
controller: 'RegistryController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('registry.access', {
|
||||
url: '^/registries/:id/access',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/registryAccess/registryAccess.html',
|
||||
controller: 'RegistryAccessController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('secrets', {
|
||||
url: '^/secrets/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/secrets/secrets.html',
|
||||
controller: 'SecretsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('secret', {
|
||||
url: '^/secret/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/secret/secret.html',
|
||||
controller: 'SecretController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('services', {
|
||||
url: '/services/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/services/services.html',
|
||||
controller: 'ServicesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('service', {
|
||||
url: '^/service/:id/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/service/service.html',
|
||||
controller: 'ServiceController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('settings', {
|
||||
url: '/settings/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/settings/settings.html',
|
||||
controller: 'SettingsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('settings_authentication', {
|
||||
url: '^/settings/authentication',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/settingsAuthentication/settingsAuthentication.html',
|
||||
controller: 'SettingsAuthenticationController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('task', {
|
||||
url: '^/task/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/task/task.html',
|
||||
controller: 'TaskController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('templates', {
|
||||
url: '/templates/',
|
||||
params: {
|
||||
key: 'containers',
|
||||
hide_descriptions: false
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/templates/templates.html',
|
||||
controller: 'TemplatesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('templates_linuxserver', {
|
||||
url: '^/templates/linuxserver.io',
|
||||
params: {
|
||||
key: 'linuxserver.io',
|
||||
hide_descriptions: true
|
||||
},
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/templates/templates.html',
|
||||
controller: 'TemplatesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('volumes', {
|
||||
url: '/volumes/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/volumes/volumes.html',
|
||||
controller: 'VolumesController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('volume', {
|
||||
url: '^/volumes/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/volume/volume.html',
|
||||
controller: 'VolumeController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('users', {
|
||||
url: '/users/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/users/users.html',
|
||||
controller: 'UsersController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('user', {
|
||||
url: '^/users/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/user/user.html',
|
||||
controller: 'UserController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('userSettings', {
|
||||
url: '/userSettings/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/userSettings/userSettings.html',
|
||||
controller: 'UserSettingsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('teams', {
|
||||
url: '/teams/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/teams/teams.html',
|
||||
controller: 'TeamsController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('team', {
|
||||
url: '^/teams/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/team/team.html',
|
||||
controller: 'TeamController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
})
|
||||
.state('swarm', {
|
||||
url: '/swarm/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/components/swarm/swarm.html',
|
||||
controller: 'SwarmController'
|
||||
},
|
||||
'sidebar@': {
|
||||
templateUrl: 'app/components/sidebar/sidebar.html',
|
||||
controller: 'SidebarController'
|
||||
}
|
||||
}
|
||||
});
|
||||
}])
|
||||
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics) {
|
||||
EndpointProvider.initialize();
|
||||
StateManager.initialize().then(function success(state) {
|
||||
if (state.application.authentication) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
authManager.redirectWhenUnauthenticated();
|
||||
Authentication.init();
|
||||
$rootScope.$on('tokenHasExpired', function($state) {
|
||||
$state.go('auth', {error: 'Your session has expired'});
|
||||
});
|
||||
}
|
||||
if (state.application.analytics) {
|
||||
Analytics.offline(false);
|
||||
Analytics.registerScriptTags();
|
||||
Analytics.registerTrackers();
|
||||
$rootScope.$on('$stateChangeSuccess', function (event, toState, toParams, fromState, fromParams) {
|
||||
Analytics.trackPage(toState.url);
|
||||
Analytics.pageView();
|
||||
});
|
||||
}
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||
});
|
||||
|
||||
$rootScope.$state = $state;
|
||||
}])
|
||||
// This is your docker url that the api will use to make requests
|
||||
// You need to set this to the api endpoint without the port i.e. http://192.168.1.9
|
||||
// .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243
|
||||
.constant('API_ENDPOINT_AUTH', 'api/auth')
|
||||
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
|
||||
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
|
||||
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
||||
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
||||
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
|
||||
.constant('API_ENDPOINT_STATUS', 'api/status')
|
||||
.constant('API_ENDPOINT_USERS', 'api/users')
|
||||
.constant('API_ENDPOINT_TEAMS', 'api/teams')
|
||||
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')
|
||||
.constant('API_ENDPOINT_TEMPLATES', 'api/templates')
|
||||
.constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json')
|
||||
.constant('PAGINATION_MAX_ITEMS', 10);
|
||||
function initAnalytics(Analytics, $rootScope) {
|
||||
Analytics.offline(false);
|
||||
Analytics.registerScriptTags();
|
||||
Analytics.registerTrackers();
|
||||
$rootScope.$on('$stateChangeSuccess', function (event, toState, toParams, fromState, fromParams) {
|
||||
Analytics.trackPage(toState.url);
|
||||
Analytics.pageView();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,92 +1,38 @@
|
||||
<div class="page-wrapper">
|
||||
<!-- login box -->
|
||||
<div class="container simple-box">
|
||||
<div class="col-md-6 col-md-offset-3 col-sm-6 col-sm-offset-3">
|
||||
<div class="col-sm-6 col-sm-offset-3">
|
||||
<!-- login box logo -->
|
||||
<div class="row">
|
||||
<img ng-if="logo" ng-src="{{ logo }}" class="simple-box-logo">
|
||||
<img ng-if="!logo" src="images/logo_alt.png" class="simple-box-logo" alt="Portainer">
|
||||
<img ng-if="logo" ng-src="{{ logo }}" class="simple-box-logo">
|
||||
</div>
|
||||
<!-- !login box logo -->
|
||||
<!-- init password panel -->
|
||||
<div class="panel panel-default" ng-if="initPassword">
|
||||
<div class="panel-body">
|
||||
<!-- init password form -->
|
||||
<form class="login-form form-horizontal" enctype="multipart/form-data" method="POST">
|
||||
<!-- comment -->
|
||||
<div class="input-group">
|
||||
<p style="margin: 5px;">
|
||||
Please specify a password for the <b>admin</b> user account.
|
||||
</p>
|
||||
</div>
|
||||
<!-- !comment input -->
|
||||
<!-- comment -->
|
||||
<div class="input-group">
|
||||
<p style="margin: 5px;">
|
||||
<i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[initPasswordData.password.length >= 8]" aria-hidden="true"></i>
|
||||
Your password must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
<!-- !comment input -->
|
||||
<!-- password input -->
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||
<input id="admin_password" type="password" class="form-control" name="password" ng-model="initPasswordData.password" autofocus>
|
||||
</div>
|
||||
<!-- !password input -->
|
||||
<!-- comment -->
|
||||
<div class="input-group">
|
||||
<p style="margin: 5px;">
|
||||
<i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[initPasswordData.password !== '' && initPasswordData.password === initPasswordData.password_confirmation]" aria-hidden="true"></i>
|
||||
Confirm your password
|
||||
</p>
|
||||
</div>
|
||||
<!-- !comment input -->
|
||||
<!-- password confirmation input -->
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||
<input id="password_confirmation" type="password" class="form-control" name="password" ng-model="initPasswordData.password_confirmation">
|
||||
</div>
|
||||
<!-- !password confirmation input -->
|
||||
<!-- validate button -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 controls">
|
||||
<p class="pull-left text-danger" ng-if="initPasswordData.error" style="margin: 5px;">
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> Unable to create default user
|
||||
</p>
|
||||
<button type="submit" class="btn btn-primary pull-right" ng-disabled="initPasswordData.password.length < 8 || initPasswordData.password !== initPasswordData.password_confirmation" ng-click="createAdminUser()"><i class="fa fa-key" aria-hidden="true"></i> Validate</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !validate button -->
|
||||
</form>
|
||||
<!-- !init password form -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !init password panel -->
|
||||
<!-- login panel -->
|
||||
<div class="panel panel-default" ng-if="!initPassword">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<!-- login form -->
|
||||
<form class="login-form form-horizontal" enctype="multipart/form-data" method="POST">
|
||||
<form class="simple-box-form form-horizontal">
|
||||
<!-- username input -->
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-user" aria-hidden="true"></i></span>
|
||||
<input id="username" type="text" class="form-control" name="username" ng-model="authData.username" placeholder="Username">
|
||||
<input id="username" type="text" class="form-control" name="username" ng-model="formValues.Username" auto-focus>
|
||||
</div>
|
||||
<!-- !username input -->
|
||||
<!-- password input -->
|
||||
<div class="input-group">
|
||||
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
|
||||
<input id="password" type="password" class="form-control" name="password" ng-model="authData.password" autofocus>
|
||||
<input id="password" type="password" class="form-control" name="password" ng-model="formValues.Password">
|
||||
</div>
|
||||
<!-- !password input -->
|
||||
<!-- login button -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 controls">
|
||||
<p class="pull-left text-danger" ng-if="authData.error" style="margin: 5px;">
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ authData.error }}
|
||||
</p>
|
||||
<button type="submit" class="btn btn-primary pull-right" ng-click="authenticateUser()"><i class="fa fa-sign-in" aria-hidden="true"></i> Login</button>
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" class="btn btn-primary btn-sm pull-right" ng-click="authenticateUser()"><i class="fa fa-sign-in" aria-hidden="true"></i> Login</button>
|
||||
<span class="pull-left" style="margin: 5px;" ng-if="state.AuthenticationError">
|
||||
<i class="fa fa-exclamation-triangle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
<span class="small text-danger">{{ state.AuthenticationError }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !login button -->
|
||||
|
||||
@@ -1,113 +1,110 @@
|
||||
angular.module('auth', [])
|
||||
.controller('AuthenticationController', ['$scope', '$state', '$stateParams', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications',
|
||||
function ($scope, $state, $stateParams, $window, $timeout, $sanitize, Authentication, Users, EndpointService, StateManager, EndpointProvider, Notifications) {
|
||||
|
||||
$scope.authData = {
|
||||
username: 'admin',
|
||||
password: '',
|
||||
error: ''
|
||||
};
|
||||
$scope.initPasswordData = {
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
error: false
|
||||
};
|
||||
.controller('AuthenticationController', ['$scope', '$state', '$transition$', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'UserService', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'SettingsService',
|
||||
function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentication, Users, UserService, EndpointService, StateManager, EndpointProvider, Notifications, SettingsService) {
|
||||
|
||||
$scope.logo = StateManager.getState().application.logo;
|
||||
|
||||
if (!$scope.applicationState.application.authentication) {
|
||||
EndpointService.endpoints()
|
||||
.then(function success(data) {
|
||||
if (data.length > 0) {
|
||||
endpointID = EndpointProvider.endpointID();
|
||||
if (!endpointID) {
|
||||
endpointID = data[0].Id;
|
||||
EndpointProvider.setEndpointID(endpointID);
|
||||
}
|
||||
StateManager.updateEndpointState(true)
|
||||
.then(function success() {
|
||||
$state.go('dashboard');
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
|
||||
});
|
||||
}
|
||||
else {
|
||||
$state.go('endpointInit');
|
||||
}
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
|
||||
});
|
||||
} else {
|
||||
Users.checkAdminUser({}, function () {},
|
||||
function (e) {
|
||||
if (e.status === 404) {
|
||||
$scope.initPassword = true;
|
||||
} else {
|
||||
Notifications.error('Failure', e, 'Unable to verify administrator account existence');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($stateParams.logout) {
|
||||
Authentication.logout();
|
||||
}
|
||||
|
||||
if ($stateParams.error) {
|
||||
$scope.authData.error = $stateParams.error;
|
||||
Authentication.logout();
|
||||
}
|
||||
|
||||
if (Authentication.isAuthenticated()) {
|
||||
$state.go('dashboard');
|
||||
}
|
||||
|
||||
$scope.createAdminUser = function() {
|
||||
var password = $sanitize($scope.initPasswordData.password);
|
||||
Users.initAdminUser({password: password}, function (d) {
|
||||
$scope.initPassword = false;
|
||||
$timeout(function() {
|
||||
var element = $window.document.getElementById('password');
|
||||
if(element) {
|
||||
element.focus();
|
||||
}
|
||||
});
|
||||
}, function (e) {
|
||||
$scope.initPassword.error = true;
|
||||
});
|
||||
$scope.formValues = {
|
||||
Username: '',
|
||||
Password: ''
|
||||
};
|
||||
|
||||
$scope.authenticateUser = function() {
|
||||
$scope.authenticationError = false;
|
||||
var username = $sanitize($scope.authData.username);
|
||||
var password = $sanitize($scope.authData.password);
|
||||
Authentication.login(username, password)
|
||||
$scope.state = {
|
||||
AuthenticationError: ''
|
||||
};
|
||||
|
||||
function setActiveEndpointAndRedirectToDashboard(endpoint) {
|
||||
var endpointID = EndpointProvider.endpointID();
|
||||
if (!endpointID) {
|
||||
EndpointProvider.setEndpointID(endpoint.Id);
|
||||
}
|
||||
StateManager.updateEndpointState(true)
|
||||
.then(function success(data) {
|
||||
return EndpointService.endpoints();
|
||||
$state.go('dashboard');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
|
||||
});
|
||||
}
|
||||
|
||||
function unauthenticatedFlow() {
|
||||
EndpointService.endpoints()
|
||||
.then(function success(data) {
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
if (data.length > 0) {
|
||||
endpointID = EndpointProvider.endpointID();
|
||||
if (!endpointID) {
|
||||
endpointID = data[0].Id;
|
||||
EndpointProvider.setEndpointID(endpointID);
|
||||
}
|
||||
StateManager.updateEndpointState(true)
|
||||
.then(function success() {
|
||||
$state.go('dashboard');
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
|
||||
});
|
||||
}
|
||||
else if (data.length === 0 && userDetails.role === 1) {
|
||||
$state.go('endpointInit');
|
||||
} else if (data.length === 0 && userDetails.role === 2) {
|
||||
Authentication.logout();
|
||||
$scope.authData.error = 'User not allowed. Please contact your administrator.';
|
||||
var endpoints = data;
|
||||
if (endpoints.length > 0) {
|
||||
setActiveEndpointAndRedirectToDashboard(endpoints[0]);
|
||||
} else {
|
||||
$state.go('init.endpoint');
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
$scope.authData.error = 'Authentication error';
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
|
||||
});
|
||||
}
|
||||
|
||||
function authenticatedFlow() {
|
||||
UserService.administratorExists()
|
||||
.then(function success(exists) {
|
||||
if (!exists) {
|
||||
$state.go('init.admin');
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to verify administrator account existence');
|
||||
});
|
||||
}
|
||||
|
||||
$scope.authenticateUser = function() {
|
||||
var username = $scope.formValues.Username;
|
||||
var password = $scope.formValues.Password;
|
||||
|
||||
SettingsService.publicSettings()
|
||||
.then(function success(data) {
|
||||
var settings = data;
|
||||
if (settings.AuthenticationMethod === 1) {
|
||||
username = $sanitize(username);
|
||||
password = $sanitize(password);
|
||||
}
|
||||
return Authentication.login(username, password);
|
||||
})
|
||||
.then(function success() {
|
||||
return EndpointService.endpoints();
|
||||
})
|
||||
.then(function success(data) {
|
||||
var endpoints = data;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
if (endpoints.length > 0) {
|
||||
setActiveEndpointAndRedirectToDashboard(endpoints[0]);
|
||||
} else if (endpoints.length === 0 && userDetails.role === 1) {
|
||||
$state.go('init.endpoint');
|
||||
} else if (endpoints.length === 0 && userDetails.role === 2) {
|
||||
Authentication.logout();
|
||||
$scope.state.AuthenticationError = 'User not allowed. Please contact your administrator.';
|
||||
}
|
||||
})
|
||||
.catch(function error() {
|
||||
$scope.state.AuthenticationError = 'Invalid credentials';
|
||||
});
|
||||
};
|
||||
|
||||
function initView() {
|
||||
if ($transition$.params().logout || $transition$.params().error) {
|
||||
Authentication.logout();
|
||||
$scope.state.AuthenticationError = $transition$.params().error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Authentication.isAuthenticated()) {
|
||||
$state.go('dashboard');
|
||||
}
|
||||
|
||||
var authenticationEnabled = $scope.applicationState.application.authentication;
|
||||
if (!authenticationEnabled) {
|
||||
unauthenticatedFlow();
|
||||
} else {
|
||||
authenticatedFlow();
|
||||
}
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Config details">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="config({id: config.Id})" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="configs">Configs</a> > <a ui-sref="config({id: config.Id})">{{ config.Name }}</a>
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-file-code-o" title="Config details"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td>{{ config.Name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>
|
||||
{{ config.Id }}
|
||||
<button class="btn btn-xs btn-danger" ng-click="removeConfig(config.Id)"><i class="fa fa-trash space-right" aria-hidden="true"></i>Delete this config</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td>{{ config.CreatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last updated</td>
|
||||
<td>{{ config.UpdatedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!(config.Labels | emptyobject)">
|
||||
<td>Labels</td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr ng-repeat="(k, v) in config.Labels">
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ v }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel
|
||||
ng-if="config && applicationState.application.authentication"
|
||||
resource-id="config.Id"
|
||||
resource-control="config.ResourceControl"
|
||||
resource-type="'config'">
|
||||
</por-access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row" ng-if="config">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-file-code-o" title="Config content"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<textarea id="config-editor" ng-model="config.Data" class="form-control"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
angular.module('config', [])
|
||||
.controller('ConfigController', ['$scope', '$transition$', '$state', '$document', 'ConfigService', 'Notifications', 'CodeMirrorService',
|
||||
function ($scope, $transition$, $state, $document, ConfigService, Notifications, CodeMirrorService) {
|
||||
|
||||
$scope.removeConfig = function removeConfig(configId) {
|
||||
ConfigService.remove(configId)
|
||||
.then(function success(data) {
|
||||
Notifications.success('Config successfully removed');
|
||||
$state.go('configs', {});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove config');
|
||||
});
|
||||
};
|
||||
|
||||
function initEditor() {
|
||||
$document.ready(function() {
|
||||
var webEditorElement = $document[0].getElementById('config-editor');
|
||||
if (webEditorElement) {
|
||||
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initView() {
|
||||
ConfigService.config($transition$.params().id)
|
||||
.then(function success(data) {
|
||||
$scope.config = data;
|
||||
initEditor();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve config details');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
@@ -0,0 +1,80 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Configs list">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="configs" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Configs</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-file-code-o" title="Configs">
|
||||
</rd-widget-header>
|
||||
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
|
||||
<div class="pull-left">
|
||||
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
|
||||
<a class="btn btn-primary" type="button" ui-sref="actions.create.config"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add config</a>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
|
||||
</div>
|
||||
</rd-widget-taskbar>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<th>
|
||||
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('Name')">
|
||||
Name
|
||||
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('CreatedAt')">
|
||||
Created at
|
||||
<span ng-show="sortType == 'CreatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'CreatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="applicationState.application.authentication">
|
||||
<a ng-click="order('ResourceControl.Ownership')">
|
||||
Ownership
|
||||
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr dir-paginate="config in (state.filteredConfigs = ( configs | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: pagination_count))">
|
||||
<td><input type="checkbox" ng-model="config.Checked" ng-change="selectItem(config)"/></td>
|
||||
<td><a ui-sref="config({id: config.Id})">{{ config.Name }}</a></td>
|
||||
<td>{{ config.CreatedAt | getisodate }}</td>
|
||||
<td ng-if="applicationState.application.authentication">
|
||||
<span>
|
||||
<i ng-class="config.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
||||
{{ config.ResourceControl.Ownership ? config.ResourceControl.Ownership : config.ResourceControl.Ownership = 'public' }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!configs">
|
||||
<td colspan="4" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="configs.length == 0">
|
||||
<td colspan="4" class="text-center text-muted">No configs available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div ng-if="configs" class="pull-left pagination-controls">
|
||||
<dir-pagination-controls></dir-pagination-controls>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
<rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
angular.module('configs', [])
|
||||
.controller('ConfigsController', ['$scope', '$stateParams', '$state', 'ConfigService', 'Notifications', 'Pagination',
|
||||
function ($scope, $stateParams, $state, ConfigService, Notifications, Pagination) {
|
||||
$scope.state = {};
|
||||
$scope.state.selectedItemCount = 0;
|
||||
$scope.state.pagination_count = Pagination.getPaginationCount('configs');
|
||||
$scope.sortType = 'Name';
|
||||
$scope.sortReverse = false;
|
||||
|
||||
$scope.order = function (sortType) {
|
||||
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
|
||||
$scope.sortType = sortType;
|
||||
};
|
||||
|
||||
$scope.selectItems = function (allSelected) {
|
||||
angular.forEach($scope.state.filteredConfigs, function (config) {
|
||||
if (config.Checked !== allSelected) {
|
||||
config.Checked = allSelected;
|
||||
$scope.selectItem(config);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.selectItem = function (item) {
|
||||
if (item.Checked) {
|
||||
$scope.state.selectedItemCount++;
|
||||
} else {
|
||||
$scope.state.selectedItemCount--;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.removeAction = function () {
|
||||
angular.forEach($scope.configs, function (config) {
|
||||
if (config.Checked) {
|
||||
ConfigService.remove(config.Id)
|
||||
.then(function success() {
|
||||
Notifications.success('Config deleted', config.Id);
|
||||
var index = $scope.configs.indexOf(config);
|
||||
$scope.configs.splice(index, 1);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove config');
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function initView() {
|
||||
ConfigService.configs()
|
||||
.then(function success(data) {
|
||||
$scope.configs = data;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
$scope.configs = [];
|
||||
Notifications.error('Failure', err, 'Unable to retrieve configs');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
@@ -1,328 +1,334 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Container details">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a>
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-cogs" title="Actions"></rd-widget-header>
|
||||
<rd-widget-body classes="padding">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
<button class="btn btn-success" ng-click="start()" ng-disabled="container.State.Running"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
|
||||
<button class="btn btn-danger" ng-click="stop()" ng-disabled="!container.State.Running"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
|
||||
<button class="btn btn-danger" ng-click="kill()" ng-disabled="!container.State.Running"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
|
||||
<button class="btn btn-primary" ng-click="restart()" ng-disabled="!container.State.Running"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
|
||||
<button class="btn btn-primary" ng-click="pause()" ng-disabled="!container.State.Running || container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
|
||||
<button class="btn btn-primary" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
|
||||
<button class="btn btn-danger" ng-click="confirmRemove()"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
|
||||
<button class="btn btn-danger" ng-click="recreate()"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Recreate</button>
|
||||
<button class="btn btn-primary" ng-click="duplicate()"><i class="fa fa-files-o space-right" aria-hidden="true"></i>Duplicate/Edit</button>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="Container status"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>{{ container.Id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td ng-if="!container.edit">
|
||||
{{ container.Name|trimcontainername }}
|
||||
<a href="" data-toggle="tooltip" title="Edit container name" ng-click="container.edit = true;"><i class="fa fa-edit"></i></a>
|
||||
</td>
|
||||
<td ng-if="container.edit">
|
||||
<form ng-submit="renameContainer()">
|
||||
<input type="text" class="containerNameInput" ng-model="container.newContainerName">
|
||||
<a href="" ng-click="container.edit = false;"><i class="fa fa-times"></i></a>
|
||||
<a href="" ng-click="renameContainer()"><i class="fa fa-check-square-o"></i></a>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="container.NetworkSettings.IPAddress">
|
||||
<td>IP address</td>
|
||||
<td>{{ container.NetworkSettings.IPAddress }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
<i class="fa fa-heartbeat space-right green-icon" ng-if="container.State.Running"></i>
|
||||
<i class="fa fa-heartbeat space-right red-icon" ng-if="!container.State.Running && container.State.Status !== 'created'"></i>
|
||||
{{ container.State|getstatetext }} since {{ activityTime }}<span ng-if="!container.State.Running && container.State.Status !== 'created'"> with exit code {{ container.State.ExitCode }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td>{{ container.Created|getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="container.State.Running">
|
||||
<td>Start time</td>
|
||||
<td>{{ container.State.StartedAt|getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!container.State.Running && container.State.Status !== 'created'">
|
||||
<td>Finished</td>
|
||||
<td>{{ container.State.FinishedAt|getisodate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="stats({id: container.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i>Stats</a>
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="containerlogs({id: container.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Logs</a>
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="console({id: container.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i>Console</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel
|
||||
ng-if="container && applicationState.application.authentication"
|
||||
resource-id="container.Id"
|
||||
resource-control="container.ResourceControl"
|
||||
resource-type="'container'">
|
||||
</por-access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div ng-if="container.State.Health" class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="Container health"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
<i ng-class="{'healthy': 'fa fa-heartbeat space-right green-icon', 'unhealthy': 'fa fa-heartbeat space-right red-icon', 'starting': 'fa fa-heartbeat space-right orange-icon'}[container.State.Health.Status]"></i>
|
||||
{{ container.State.Health.Status }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Failure count</td>
|
||||
<td>{{ container.State.Health.FailingStreak }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last output</td>
|
||||
<td>{{ container.State.Health.Log[container.State.Health.Log.length - 1].Output }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-clone" title="Create image"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- tag-description -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
You can create an image from this container, this allows you to backup important data or save
|
||||
helpful configurations. You'll be able to spin up another container based on this image afterward.
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-cogs" title="Actions"></rd-widget-header>
|
||||
<rd-widget-body classes="padding">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
<button class="btn btn-success" ng-click="start()" ng-disabled="container.State.Running"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
|
||||
<button class="btn btn-danger" ng-click="stop()" ng-disabled="!container.State.Running"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
|
||||
<button class="btn btn-danger" ng-click="kill()" ng-disabled="!container.State.Running"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
|
||||
<button class="btn btn-primary" ng-click="restart()" ng-disabled="!container.State.Running"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
|
||||
<button class="btn btn-primary" ng-click="pause()" ng-disabled="!container.State.Running || container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
|
||||
<button class="btn btn-primary" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
|
||||
<button class="btn btn-danger" ng-click="confirmRemove()"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
|
||||
<button class="btn btn-danger" ng-click="recreate()" ng-if="!container.Config.Labels['com.docker.swarm.service.id']"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Recreate</button>
|
||||
<button class="btn btn-primary" ng-click="duplicate()" ng-if="!container.Config.Labels['com.docker.swarm.service.id']"><i class="fa fa-files-o space-right" aria-hidden="true"></i>Duplicate/Edit</button>
|
||||
</div>
|
||||
<!-- !tag-description -->
|
||||
<!-- image-and-registry -->
|
||||
<div class="form-group">
|
||||
<por-image-registry image="config.Image" registry="config.Registry"></por-image-registry>
|
||||
</div>
|
||||
<!-- !image-and-registry -->
|
||||
<!-- tag-note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">Note: if you don't specify the tag in the image name, <span class="label label-default">latest</span> will be used.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tag-note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="commit()">Create</button>
|
||||
<i id="createImageSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="Container details"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td><a ui-sref="image({id: container.Image})">{{ container.Image }}</a></td>
|
||||
</tr>
|
||||
<tr ng-if="portBindings.length > 0">
|
||||
<td>Port configuration</td>
|
||||
<td>
|
||||
<div ng-repeat="portMapping in portBindings">
|
||||
{{ portMapping.container }} <i class="fa fa-long-arrow-right"></i> {{ portMapping.host }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CMD</td>
|
||||
<td><code>{{ container.Config.Cmd|command }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ENV</td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr ng-repeat="var in container.Config.Env track by $index">
|
||||
<td>{{ var|key: '=' }}</td>
|
||||
<td>{{ var|value: '=' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!(container.Config.Labels | emptyobject)">
|
||||
<td>Labels</td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr ng-repeat="(k, v) in container.Config.Labels">
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ v }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="container.HostConfig.RestartPolicy.Name !== 'no'">
|
||||
<td>Restart policies</td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr>
|
||||
<td class="col-md-3">Name</td>
|
||||
<td>{{ container.HostConfig.RestartPolicy.Name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-md-3">MaximumRetryCount</td>
|
||||
<td>
|
||||
{{ container.HostConfig.RestartPolicy.MaximumRetryCount }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="Container status"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>{{ container.Id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Name</td>
|
||||
<td ng-if="!container.edit">
|
||||
{{ container.Name|trimcontainername }}
|
||||
<a href="" data-toggle="tooltip" title="Edit container name" ng-click="container.edit = true;"><i class="fa fa-edit"></i></a>
|
||||
</td>
|
||||
<td ng-if="container.edit">
|
||||
<form ng-submit="renameContainer()">
|
||||
<input type="text" class="containerNameInput" ng-model="container.newContainerName">
|
||||
<a href="" ng-click="container.edit = false;"><i class="fa fa-times"></i></a>
|
||||
<a href="" ng-click="renameContainer()"><i class="fa fa-check-square-o"></i></a>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="container.NetworkSettings.IPAddress">
|
||||
<td>IP address</td>
|
||||
<td>{{ container.NetworkSettings.IPAddress }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
<i class="fa fa-heartbeat space-right green-icon" ng-if="container.State.Running"></i>
|
||||
<i class="fa fa-heartbeat space-right red-icon" ng-if="!container.State.Running && container.State.Status !== 'created'"></i>
|
||||
{{ container.State|getstatetext }} since {{ activityTime }}<span ng-if="!container.State.Running && container.State.Status !== 'created'"> with exit code {{ container.State.ExitCode }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td>{{ container.Created|getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="container.State.Running">
|
||||
<td>Start time</td>
|
||||
<td>{{ container.State.StartedAt|getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!container.State.Running && container.State.Status !== 'created'">
|
||||
<td>Finished</td>
|
||||
<td>{{ container.State.FinishedAt|getisodate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="stats({id: container.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i>Stats</a>
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="containerlogs({id: container.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Logs</a>
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="console({id: container.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i>Console</a>
|
||||
<a class="btn btn-outline-secondary" type="button" ui-sref="inspect({id: container.Id})"><i class="fa fa-info-circle space-right" aria-hidden="true"></i>Inspect</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="container.HostConfig.Binds.length > 0">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-cubes" title="Volumes"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Container</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="vol in container.HostConfig.Binds">
|
||||
<td>{{ vol|key: ':' }}</td>
|
||||
<td>{{ vol|value: ':' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel
|
||||
ng-if="container && applicationState.application.authentication"
|
||||
resource-id="container.Id"
|
||||
resource-control="container.ResourceControl"
|
||||
resource-type="'container'">
|
||||
</por-access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div ng-if="container.State.Health" class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="Container health"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
<i ng-class="{'healthy': 'fa fa-heartbeat space-right green-icon', 'unhealthy': 'fa fa-heartbeat space-right red-icon', 'starting': 'fa fa-heartbeat space-right orange-icon'}[container.State.Health.Status]"></i>
|
||||
{{ container.State.Health.Status }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Failure count</td>
|
||||
<td>{{ container.State.Health.FailingStreak }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last output</td>
|
||||
<td>{{ container.State.Health.Log[container.State.Health.Log.length - 1].Output }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-sitemap" title="Connected networks">
|
||||
<div class="pull-right">
|
||||
Items per page:
|
||||
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
|
||||
<option value="0">All</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<th>Network Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Gateway</th>
|
||||
<th>MacAddress</th>
|
||||
<th>Actions</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr dir-paginate="(key, value) in container.NetworkSettings.Networks | itemsPerPage: state.pagination_count">
|
||||
<td><a ui-sref="network({id: value.NetworkID})">{{ key }}</a></td>
|
||||
<td>{{ value.IPAddress || '-' }}</td>
|
||||
<td>{{ value.Gateway || '-' }}</td>
|
||||
<td>{{ value.MacAddress || '-' }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(container, value.NetworkID)"><i class="fa fa-trash space-right" aria-hidden="true"></i>Leave Network</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="(container.NetworkSettings.Networks | emptyobject)">
|
||||
<td colspan="5" class="text-center text-muted">No networks connected.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination-controls">
|
||||
<dir-pagination-controls></dir-pagination-controls>
|
||||
</div>
|
||||
<hr />
|
||||
<form class="form-horizontal">
|
||||
<!-- network-input -->
|
||||
<div class="row">
|
||||
<label for="container_network" class="col-sm-3 col-lg-2 control-label text-left">Join a Network</label>
|
||||
<div class="col-sm-5 col-lg-4">
|
||||
<select class="form-control" ng-model="selectedNetwork" id="container_network">
|
||||
<option selected disabled hidden value="">Select a network</option>
|
||||
<option ng-repeat="net in availableNetworks" ng-value="net.Id">{{ net.Name }}</option>
|
||||
</select>
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-clone" title="Create image"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- tag-description -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
You can create an image from this container, this allows you to backup important data or save
|
||||
helpful configurations. You'll be able to spin up another container based on this image afterward.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!selectedNetwork" ng-click="containerJoinNetwork(container, selectedNetwork)">Join Network</button>
|
||||
<!-- !tag-description -->
|
||||
<!-- image-and-registry -->
|
||||
<div class="form-group">
|
||||
<por-image-registry image="config.Image" registry="config.Registry"></por-image-registry>
|
||||
</div>
|
||||
<!-- !image-and-registry -->
|
||||
<!-- tag-note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">Note: if you don't specify the tag in the image name, <span class="label label-default">latest</span> will be used.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tag-note -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="commit()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-server" title="Container details"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td><a ui-sref="image({id: container.Image})">{{ container.Image }}</a></td>
|
||||
</tr>
|
||||
<tr ng-if="portBindings.length > 0">
|
||||
<td>Port configuration</td>
|
||||
<td>
|
||||
<div ng-repeat="portMapping in portBindings">
|
||||
{{ portMapping.container }} <i class="fa fa-long-arrow-right"></i> {{ portMapping.host }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>CMD</td>
|
||||
<td><code>{{ container.Config.Cmd|command }}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ENV</td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr ng-repeat="var in container.Config.Env track by $index">
|
||||
<td>{{ var|key: '=' }}</td>
|
||||
<td>{{ var|value: '=' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!(container.Config.Labels | emptyobject)">
|
||||
<td>Labels</td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr ng-repeat="(k, v) in container.Config.Labels">
|
||||
<td>{{ k }}</td>
|
||||
<td>{{ v }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="container.HostConfig.RestartPolicy.Name !== 'no'">
|
||||
<td>Restart policies</td>
|
||||
<td>
|
||||
<table class="table table-bordered table-condensed">
|
||||
<tr>
|
||||
<td class="col-md-3">Name</td>
|
||||
<td>{{ container.HostConfig.RestartPolicy.Name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="col-md-3">MaximumRetryCount</td>
|
||||
<td>
|
||||
{{ container.HostConfig.RestartPolicy.MaximumRetryCount }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="container.Mounts.length > 0">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-cubes" title="Volumes"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host/volume</th>
|
||||
<th>Path in container</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr ng-repeat="vol in container.Mounts">
|
||||
<td ng-if="vol.Type === 'bind'">{{ vol.Source }}</td>
|
||||
<td ng-if="vol.Type === 'volume'"><a ui-sref="volume({id: vol.Name})">{{ vol.Name }}</a></td>
|
||||
<td>{{ vol.Destination }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-sitemap" title="Connected networks">
|
||||
<div class="pull-right">
|
||||
Items per page:
|
||||
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
|
||||
<option value="0">All</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<th>Network Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Gateway</th>
|
||||
<th>MacAddress</th>
|
||||
<th>Actions</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr dir-paginate="(key, value) in container.NetworkSettings.Networks | itemsPerPage: state.pagination_count">
|
||||
<td><a ui-sref="network({id: value.NetworkID})">{{ key }}</a></td>
|
||||
<td>{{ value.IPAddress || '-' }}</td>
|
||||
<td>{{ value.Gateway || '-' }}</td>
|
||||
<td>{{ value.MacAddress || '-' }}</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-xs btn-danger" ng-disabled="state.leaveNetworkInProgress" button-spinner="state.leaveNetworkInProgress" ng-click="containerLeaveNetwork(container, value.NetworkID)">
|
||||
<span ng-hide="state.leaveNetworkInProgress"><i class="fa fa-trash space-right" aria-hidden="true"></i> Leave network</span>
|
||||
<span ng-show="state.leaveNetworkInProgress">Leaving network...</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="(container.NetworkSettings.Networks | emptyobject)">
|
||||
<td colspan="5" class="text-center text-muted">No networks connected.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination-controls">
|
||||
<dir-pagination-controls></dir-pagination-controls>
|
||||
</div>
|
||||
<hr />
|
||||
<form class="form-horizontal">
|
||||
<!-- network-input -->
|
||||
<div class="row">
|
||||
<label for="container_network" class="col-sm-3 col-lg-2 control-label text-left">Join a Network</label>
|
||||
<div class="col-sm-5 col-lg-4">
|
||||
<select class="form-control" ng-model="selectedNetwork" id="container_network">
|
||||
<option selected disabled hidden value="">Select a network</option>
|
||||
<option ng-repeat="net in availableNetworks" ng-value="net.Id">{{ net.Name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.joinNetworkInProgress || !selectedNetwork" ng-click="containerJoinNetwork(container, selectedNetwork)" button-spinner="state.joinNetworkInProgress">
|
||||
<span ng-hide="state.joinNetworkInProgress">Join network</span>
|
||||
<span ng-show="state.joinNetworkInProgress">Joining network...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
angular.module('container', [])
|
||||
.controller('ContainerController', ['$q', '$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
|
||||
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) {
|
||||
.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
|
||||
function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) {
|
||||
$scope.activityTime = 0;
|
||||
$scope.portBindings = [];
|
||||
$scope.config = {
|
||||
Image: '',
|
||||
Registry: ''
|
||||
};
|
||||
$scope.state = {};
|
||||
$scope.state.pagination_count = Pagination.getPaginationCount('container_networks');
|
||||
$scope.state = {
|
||||
joinNetworkInProgress: false,
|
||||
leaveNetworkInProgress: false,
|
||||
pagination_count: Pagination.getPaginationCount('container_networks')
|
||||
};
|
||||
|
||||
$scope.changePaginationCount = function() {
|
||||
Pagination.setPaginationCount('container_networks', $scope.state.pagination_count);
|
||||
};
|
||||
|
||||
var update = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.get({id: $stateParams.id}, function (d) {
|
||||
Container.get({id: $transition$.params().id}, function (d) {
|
||||
var container = new ContainerDetailsViewModel(d);
|
||||
$scope.container = container;
|
||||
$scope.container.edit = false;
|
||||
@@ -41,18 +43,15 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
}
|
||||
});
|
||||
}
|
||||
$('#loadingViewSpinner').hide();
|
||||
}, function (e) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to retrieve container info');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.start = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.start({id: $scope.container.Id}, {}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container started', $stateParams.id);
|
||||
Notifications.success('Container started', $transition$.params().id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to start container');
|
||||
@@ -60,10 +59,9 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
};
|
||||
|
||||
$scope.stop = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.stop({id: $stateParams.id}, function (d) {
|
||||
Container.stop({id: $transition$.params().id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container stopped', $stateParams.id);
|
||||
Notifications.success('Container stopped', $transition$.params().id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to stop container');
|
||||
@@ -71,10 +69,9 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
};
|
||||
|
||||
$scope.kill = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.kill({id: $stateParams.id}, function (d) {
|
||||
Container.kill({id: $transition$.params().id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container killed', $stateParams.id);
|
||||
Notifications.success('Container killed', $transition$.params().id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to kill container');
|
||||
@@ -82,26 +79,22 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
};
|
||||
|
||||
$scope.commit = function () {
|
||||
$('#createImageSpinner').show();
|
||||
var image = $scope.config.Image;
|
||||
var registry = $scope.config.Registry;
|
||||
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL);
|
||||
ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
|
||||
$('#createImageSpinner').hide();
|
||||
ContainerCommit.commit({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container commited', $stateParams.id);
|
||||
Notifications.success('Container commited', $transition$.params().id);
|
||||
}, function (e) {
|
||||
$('#createImageSpinner').hide();
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to commit container');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.pause = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.pause({id: $stateParams.id}, function (d) {
|
||||
Container.pause({id: $transition$.params().id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container paused', $stateParams.id);
|
||||
Notifications.success('Container paused', $transition$.params().id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to pause container');
|
||||
@@ -109,10 +102,9 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
};
|
||||
|
||||
$scope.unpause = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.unpause({id: $stateParams.id}, function (d) {
|
||||
Container.unpause({id: $transition$.params().id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container unpaused', $stateParams.id);
|
||||
Notifications.success('Container unpaused', $transition$.params().id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to unpause container');
|
||||
@@ -138,7 +130,6 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
};
|
||||
|
||||
$scope.remove = function(cleanAssociatedVolumes) {
|
||||
$('#loadingViewSpinner').show();
|
||||
ContainerService.remove($scope.container, cleanAssociatedVolumes)
|
||||
.then(function success() {
|
||||
Notifications.success('Container successfully removed');
|
||||
@@ -146,17 +137,13 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.restart = function () {
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.restart({id: $stateParams.id}, function (d) {
|
||||
Container.restart({id: $transition$.params().id}, function (d) {
|
||||
update();
|
||||
Notifications.success('Container restarted', $stateParams.id);
|
||||
Notifications.success('Container restarted', $transition$.params().id);
|
||||
}, function (e) {
|
||||
update();
|
||||
Notifications.error('Failure', e, 'Unable to restart container');
|
||||
@@ -165,7 +152,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
|
||||
$scope.renameContainer = function () {
|
||||
var container = $scope.container;
|
||||
Container.rename({id: $stateParams.id, 'name': container.newContainerName}, function (d) {
|
||||
Container.rename({id: $transition$.params().id, 'name': container.newContainerName}, function (d) {
|
||||
if (d.message) {
|
||||
container.newContainerName = container.Name;
|
||||
Notifications.error('Unable to rename container', {}, d.message);
|
||||
@@ -180,27 +167,23 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
};
|
||||
|
||||
$scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) {
|
||||
$('#loadingViewSpinner').show();
|
||||
Network.disconnect({id: networkId}, { Container: $stateParams.id, Force: false }, function (d) {
|
||||
$scope.state.leaveNetworkInProgress = true;
|
||||
Network.disconnect({id: networkId}, { Container: $transition$.params().id, Force: false }, function (d) {
|
||||
if (container.message) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Error', d, 'Unable to disconnect container from network');
|
||||
} else {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.success('Container left network', $stateParams.id);
|
||||
$state.go('container', {id: $stateParams.id}, {reload: true});
|
||||
Notifications.success('Container left network', $transition$.params().id);
|
||||
$state.go('container', {id: $transition$.params().id}, {reload: true});
|
||||
}
|
||||
$scope.state.leaveNetworkInProgress = false;
|
||||
}, function (e) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to disconnect container from network');
|
||||
$scope.state.leaveNetworkInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.duplicate = function() {
|
||||
ModalService.confirmExperimentalFeature(function (experimental) {
|
||||
if(!experimental) { return; }
|
||||
$state.go('actions.create.container', {from: $stateParams.id}, {reload: true});
|
||||
});
|
||||
$state.go('actions.create.container', {from: $transition$.params().id}, {reload: true});
|
||||
};
|
||||
|
||||
$scope.confirmRemove = function () {
|
||||
@@ -222,7 +205,6 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
};
|
||||
|
||||
function recreateContainer(pullImage) {
|
||||
$('#loadingViewSpinner').show();
|
||||
var container = $scope.container;
|
||||
var config = ContainerHelper.configFromContainer(container.Model);
|
||||
ContainerService.remove(container, true)
|
||||
@@ -257,41 +239,33 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit,
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to re-create container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
}
|
||||
|
||||
$scope.recreate = function() {
|
||||
ModalService.confirmExperimentalFeature(function (experimental) {
|
||||
if(!experimental) { return; }
|
||||
|
||||
ModalService.confirmContainerRecreation(function (result) {
|
||||
if(!result) { return; }
|
||||
var pullImage = false;
|
||||
if (result[0]) {
|
||||
pullImage = true;
|
||||
}
|
||||
recreateContainer(pullImage);
|
||||
});
|
||||
ModalService.confirmContainerRecreation(function (result) {
|
||||
if(!result) { return; }
|
||||
var pullImage = false;
|
||||
if (result[0]) {
|
||||
pullImage = true;
|
||||
}
|
||||
recreateContainer(pullImage);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
|
||||
$('#joinNetworkSpinner').show();
|
||||
Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) {
|
||||
$scope.state.joinNetworkInProgress = true;
|
||||
Network.connect({id: networkId}, { Container: $transition$.params().id }, function (d) {
|
||||
if (container.message) {
|
||||
$('#joinNetworkSpinner').hide();
|
||||
Notifications.error('Error', d, 'Unable to connect container to network');
|
||||
} else {
|
||||
$('#joinNetworkSpinner').hide();
|
||||
Notifications.success('Container joined network', $stateParams.id);
|
||||
$state.go('container', {id: $stateParams.id}, {reload: true});
|
||||
Notifications.success('Container joined network', $transition$.params().id);
|
||||
$state.go('container', {id: $transition$.params().id}, {reload: true});
|
||||
}
|
||||
$scope.state.joinNetworkInProgress = false;
|
||||
}, function (e) {
|
||||
$('#joinNetworkSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to connect container to network');
|
||||
$scope.state.joinNetworkInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Container console">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-title title="Container console"></rd-header-title>
|
||||
<rd-header-content ng-if="state.loaded">
|
||||
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Console
|
||||
</rd-header-content>
|
||||
@@ -10,11 +8,7 @@
|
||||
<div class="row" ng-if="state.loaded">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-terminal" title="Console">
|
||||
<div class="pull-right">
|
||||
<i id="loadConsoleSpinner" class="fa fa-cog fa-2x fa-spin" style="margin-top: 5px; display: none;"></i>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-header icon="fa-terminal" title="Console"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div ng-if="!state.connected">
|
||||
@@ -54,8 +48,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-lg-offset-1 col-sm-offset-2 col-lg-11 col-sm-10">
|
||||
<button type="button" class="btn btn-primary" ng-click="connect()">Connect</button>
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary" ng-disabled="state.connected" button-spinner="state.connected" ng-click="connect()">
|
||||
<span ng-hide="state.leaveNetworkInProgress">Connect</span>
|
||||
<span ng-show="state.leaveNetworkInProgress">Connecting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
angular.module('containerConsole', [])
|
||||
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService',
|
||||
function ($scope, $stateParams, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) {
|
||||
$scope.state = {};
|
||||
$scope.state.loaded = false;
|
||||
$scope.state.connected = false;
|
||||
.controller('ContainerConsoleController', ['$scope', '$transition$', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService',
|
||||
function ($scope, $transition$, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) {
|
||||
$scope.state = {
|
||||
loaded: false,
|
||||
connected: false
|
||||
};
|
||||
|
||||
$scope.formValues = {};
|
||||
|
||||
var socket, term;
|
||||
@@ -15,35 +17,30 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification
|
||||
}
|
||||
});
|
||||
|
||||
Container.get({id: $stateParams.id}, function(d) {
|
||||
Container.get({id: $transition$.params().id}, function(d) {
|
||||
$scope.container = d;
|
||||
if (d.message) {
|
||||
Notifications.error('Error', d, 'Unable to retrieve container details');
|
||||
$('#loadingViewSpinner').hide();
|
||||
} else {
|
||||
Image.get({id: d.Image}, function(imgData) {
|
||||
$scope.imageOS = imgData.Os;
|
||||
$scope.formValues.command = imgData.Os === 'windows' ? 'powershell' : 'bash';
|
||||
$scope.state.loaded = true;
|
||||
$('#loadingViewSpinner').hide();
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to retrieve image details');
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
}
|
||||
}, function (e) {
|
||||
Notifications.error('Failure', e, 'Unable to retrieve container details');
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
|
||||
$scope.connect = function() {
|
||||
$('#loadConsoleSpinner').show();
|
||||
var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39);
|
||||
var termHeight = 30;
|
||||
var command = $scope.formValues.isCustomCommand ?
|
||||
$scope.formValues.customCommand : $scope.formValues.command;
|
||||
var execConfig = {
|
||||
id: $stateParams.id,
|
||||
id: $transition$.params().id,
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
@@ -67,9 +64,6 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to exec into container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadConsoleSpinner').hide();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -88,7 +82,6 @@ function ($scope, $stateParams, Container, Image, EndpointProvider, Notification
|
||||
|
||||
$scope.state.connected = true;
|
||||
socket.onopen = function(evt) {
|
||||
$('#loadConsoleSpinner').hide();
|
||||
term = new Terminal();
|
||||
|
||||
term.on('data', function (data) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Container inspect">
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: containerInfo.Id})">{{ containerInfo.Name|trimcontainername }}</a> > Inspect
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-icon-circle" title="Inspect">
|
||||
<span class="btn-group btn-group-sm">
|
||||
<label class="btn btn-primary" ng-model="state.DisplayTextView" uib-btn-radio="false"><i class="fa fa-code space-right" aria-hidden="true"></i>Tree</label>
|
||||
<label class="btn btn-primary" ng-model="state.DisplayTextView" uib-btn-radio="true"><i class="fa fa-file-text-o space-right" aria-hidden="true"></i>Text</label>
|
||||
</span>
|
||||
</rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<pre ng-show="state.DisplayTextView">{{ containerInfo|json:4 }}</pre>
|
||||
<json-tree ng-hide="state.DisplayTextView" object="containerInfo" root-name="containerInfo.Id" start-expanded="true"></json-tree>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
angular.module('containerInspect', ['angular-json-tree'])
|
||||
.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService',
|
||||
function ($scope, $transition$, Notifications, ContainerService) {
|
||||
|
||||
$scope.state = {
|
||||
DisplayTextView: false
|
||||
};
|
||||
$scope.containerInfo = {};
|
||||
|
||||
function initView() {
|
||||
ContainerService.inspect($transition$.params().id)
|
||||
.then(function success(d) {
|
||||
$scope.containerInfo = d;
|
||||
})
|
||||
.catch(function error(e) {
|
||||
Notifications.error('Failure', e, 'Unable to inspect container');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
@@ -1,6 +1,6 @@
|
||||
angular.module('containerLogs', [])
|
||||
.controller('ContainerLogsController', ['$scope', '$stateParams', '$anchorScroll', 'ContainerLogs', 'Container',
|
||||
function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
|
||||
.controller('ContainerLogsController', ['$scope', '$transition$', '$anchorScroll', 'ContainerLogs', 'Container',
|
||||
function ($scope, $transition$, $anchorScroll, ContainerLogs, Container) {
|
||||
$scope.state = {};
|
||||
$scope.state.displayTimestampsOut = false;
|
||||
$scope.state.displayTimestampsErr = false;
|
||||
@@ -8,24 +8,19 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
|
||||
$scope.stderr = '';
|
||||
$scope.tailLines = 2000;
|
||||
|
||||
$('#loadingViewSpinner').show();
|
||||
Container.get({id: $stateParams.id}, function (d) {
|
||||
Container.get({id: $transition$.params().id}, function (d) {
|
||||
$scope.container = d;
|
||||
$('#loadingViewSpinner').hide();
|
||||
}, function (e) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to retrieve container info');
|
||||
});
|
||||
|
||||
function getLogs() {
|
||||
$('#loadingViewSpinner').show();
|
||||
getLogsStdout();
|
||||
getLogsStderr();
|
||||
$('#loadingViewSpinner').hide();
|
||||
}
|
||||
|
||||
function getLogsStderr() {
|
||||
ContainerLogs.get($stateParams.id, {
|
||||
ContainerLogs.get($transition$.params().id, {
|
||||
stdout: 0,
|
||||
stderr: 1,
|
||||
timestamps: $scope.state.displayTimestampsErr,
|
||||
@@ -41,7 +36,7 @@ function ($scope, $stateParams, $anchorScroll, ContainerLogs, Container) {
|
||||
}
|
||||
|
||||
function getLogsStdout() {
|
||||
ContainerLogs.get($stateParams.id, {
|
||||
ContainerLogs.get($transition$.params().id, {
|
||||
stdout: 1,
|
||||
stderr: 0,
|
||||
timestamps: $scope.state.displayTimestampsOut,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Container logs">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-title title="Container logs"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Logs
|
||||
</rd-header-content>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Container statistics"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Stats
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-info-circle" title="About statistics">
|
||||
</rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
This view displays real-time statistics about the container <b>{{ container.Name|trimcontainername }}</b> as well as a list of the running processes
|
||||
inside this container.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left">
|
||||
Refresh rate
|
||||
</label>
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control">
|
||||
<option value="5">5s</option>
|
||||
<option value="10">10s</option>
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<span>
|
||||
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="state.networkStatsUnavailable">
|
||||
<div class="col-sm-12">
|
||||
<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>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</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-4 col-md-6 col-sm-12"> -->
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-area-chart" title="Memory usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative;">
|
||||
<canvas id="memoryChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</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]">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-area-chart" title="CPU usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative;">
|
||||
<canvas id="cpuChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-12 col-sm-12" ng-if="!state.networkStatsUnavailable">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-area-chart" title="Network usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative;">
|
||||
<canvas id="networkChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-sm-12" ng-if="applicationState.endpoint.mode.provider !== 'VMWARE_VIC'">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-tasks" title="Processes">
|
||||
<div class="pull-right">
|
||||
Items per page:
|
||||
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
|
||||
<option value="0">All</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th ng-repeat="title in processInfo.Titles">
|
||||
<a ng-click="order(title)">
|
||||
{{ title }}
|
||||
<span ng-show="sortType == title && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == title && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr dir-paginate="processDetails in state.filteredProcesses = (processInfo.Processes | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count)">
|
||||
<td ng-repeat="procInfo in processDetails track by $index">{{ procInfo }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!processInfo.Processes">
|
||||
<td colspan="processInfo.Titles.length" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="state.filteredProcesses.length === 0">
|
||||
<td colspan="processInfo.Titles.length" class="text-center text-muted">No processes available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div ng-if="processInfo.Processes" class="pagination-controls">
|
||||
<dir-pagination-controls></dir-pagination-controls>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,156 @@
|
||||
angular.module('containerStats', [])
|
||||
.controller('ContainerStatsController', ['$q', '$scope', '$transition$', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', 'Pagination',
|
||||
function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications, Pagination) {
|
||||
|
||||
$scope.state = {
|
||||
refreshRate: '5',
|
||||
networkStatsUnavailable: false
|
||||
};
|
||||
|
||||
$scope.state.pagination_count = Pagination.getPaginationCount('stats_processes');
|
||||
$scope.sortType = 'CMD';
|
||||
$scope.sortReverse = false;
|
||||
|
||||
$scope.order = function (sortType) {
|
||||
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
|
||||
$scope.sortType = sortType;
|
||||
};
|
||||
|
||||
$scope.changePaginationCount = function() {
|
||||
Pagination.setPaginationCount('stats_processes', $scope.state.pagination_count);
|
||||
};
|
||||
|
||||
$scope.$on('$destroy', function() {
|
||||
stopRepeater();
|
||||
});
|
||||
|
||||
function stopRepeater() {
|
||||
var repeater = $scope.repeater;
|
||||
if (angular.isDefined(repeater)) {
|
||||
$interval.cancel(repeater);
|
||||
repeater = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateNetworkChart(stats, chart) {
|
||||
if (stats.Networks.length > 0) {
|
||||
var rx = stats.Networks[0].rx_bytes;
|
||||
var tx = stats.Networks[0].tx_bytes;
|
||||
var label = moment(stats.Date).format('HH:mm:ss');
|
||||
|
||||
ChartService.UpdateNetworkChart(label, rx, tx, chart);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMemoryChart(stats, chart) {
|
||||
var label = moment(stats.Date).format('HH:mm:ss');
|
||||
var value = stats.MemoryUsage;
|
||||
|
||||
ChartService.UpdateMemoryChart(label, value, chart);
|
||||
}
|
||||
|
||||
function updateCPUChart(stats, chart) {
|
||||
var label = moment(stats.Date).format('HH:mm:ss');
|
||||
var value = calculateCPUPercentUnix(stats);
|
||||
|
||||
ChartService.UpdateCPUChart(label, value, chart);
|
||||
}
|
||||
|
||||
function calculateCPUPercentUnix(stats) {
|
||||
var cpuPercent = 0.0;
|
||||
var cpuDelta = stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage;
|
||||
var systemDelta = stats.CurrentCPUSystemUsage - stats.PreviousCPUSystemUsage;
|
||||
|
||||
if (systemDelta > 0.0 && cpuDelta > 0.0) {
|
||||
cpuPercent = (cpuDelta / systemDelta) * stats.CPUCores * 100.0;
|
||||
}
|
||||
|
||||
return cpuPercent;
|
||||
}
|
||||
|
||||
$scope.changeUpdateRepeater = function() {
|
||||
var networkChart = $scope.networkChart;
|
||||
var cpuChart = $scope.cpuChart;
|
||||
var memoryChart = $scope.memoryChart;
|
||||
|
||||
stopRepeater();
|
||||
setUpdateRepeater(networkChart, cpuChart, memoryChart);
|
||||
$('#refreshRateChange').show();
|
||||
$('#refreshRateChange').fadeOut(1500);
|
||||
};
|
||||
|
||||
function startChartUpdate(networkChart, cpuChart, memoryChart) {
|
||||
$q.all({
|
||||
stats: ContainerService.containerStats($transition$.params().id),
|
||||
top: ContainerService.containerTop($transition$.params().id)
|
||||
})
|
||||
.then(function success(data) {
|
||||
var stats = data.stats;
|
||||
$scope.processInfo = data.top;
|
||||
if (stats.Networks.length === 0) {
|
||||
$scope.state.networkStatsUnavailable = true;
|
||||
}
|
||||
updateNetworkChart(stats, networkChart);
|
||||
updateMemoryChart(stats, memoryChart);
|
||||
updateCPUChart(stats, cpuChart);
|
||||
setUpdateRepeater(networkChart, cpuChart, memoryChart);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
stopRepeater();
|
||||
Notifications.error('Failure', err, 'Unable to retrieve container statistics');
|
||||
});
|
||||
}
|
||||
|
||||
function setUpdateRepeater(networkChart, cpuChart, memoryChart) {
|
||||
var refreshRate = $scope.state.refreshRate;
|
||||
$scope.repeater = $interval(function() {
|
||||
$q.all({
|
||||
stats: ContainerService.containerStats($transition$.params().id),
|
||||
top: ContainerService.containerTop($transition$.params().id)
|
||||
})
|
||||
.then(function success(data) {
|
||||
var stats = data.stats;
|
||||
$scope.processInfo = data.top;
|
||||
updateNetworkChart(stats, networkChart);
|
||||
updateMemoryChart(stats, memoryChart);
|
||||
updateCPUChart(stats, cpuChart);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
stopRepeater();
|
||||
Notifications.error('Failure', err, 'Unable to retrieve container statistics');
|
||||
});
|
||||
}, refreshRate * 1000);
|
||||
}
|
||||
|
||||
function initCharts() {
|
||||
var networkChartCtx = $('#networkChart');
|
||||
var networkChart = ChartService.CreateNetworkChart(networkChartCtx);
|
||||
$scope.networkChart = networkChart;
|
||||
|
||||
var cpuChartCtx = $('#cpuChart');
|
||||
var cpuChart = ChartService.CreateCPUChart(cpuChartCtx);
|
||||
$scope.cpuChart = cpuChart;
|
||||
|
||||
var memoryChartCtx = $('#memoryChart');
|
||||
var memoryChart = ChartService.CreateMemoryChart(memoryChartCtx);
|
||||
$scope.memoryChart = memoryChart;
|
||||
|
||||
startChartUpdate(networkChart, cpuChart, memoryChart);
|
||||
}
|
||||
|
||||
function initView() {
|
||||
ContainerService.container($transition$.params().id)
|
||||
.then(function success(data) {
|
||||
$scope.container = data;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve container information');
|
||||
});
|
||||
|
||||
$document.ready(function() {
|
||||
initCharts();
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
@@ -3,7 +3,6 @@
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="containers" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
<i id="loadContainersSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Containers</rd-header-content>
|
||||
</rd-header>
|
||||
@@ -49,49 +48,59 @@
|
||||
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
|
||||
</th>
|
||||
<th>
|
||||
<a ui-sref="containers" ng-click="order('Status')">
|
||||
<a ng-click="order('Status')">
|
||||
State
|
||||
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ui-sref="containers" ng-click="order('Names')">
|
||||
<a ng-click="order('Names')">
|
||||
Name
|
||||
<span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
<a data-toggle="tooltip" title="More" ng-click="truncateMore();" ng-show="showMore">
|
||||
<i class="fa fa-plus-square" aria-hidden="true"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ui-sref="containers" ng-click="order('Image')">
|
||||
<a ng-click="order('StackName')">
|
||||
Stack
|
||||
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="order('Image')">
|
||||
Image
|
||||
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="state.displayIP">
|
||||
<a ui-sref="containers" ng-click="order('IP')">
|
||||
<a ng-click="order('IP')">
|
||||
IP Address
|
||||
<span ng-show="sortType == 'IP' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
|
||||
<a ui-sref="containers" ng-click="order('Host')">
|
||||
<a ng-click="order('Host')">
|
||||
Host IP
|
||||
<span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Host' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ui-sref="containers" ng-click="order('Ports')">
|
||||
<a ng-click="order('Ports')">
|
||||
Published Ports
|
||||
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="applicationState.application.authentication">
|
||||
<a ui-sref="containers" ng-click="order('ResourceControl.Ownership')">
|
||||
<a ng-click="order('ResourceControl.Ownership')">
|
||||
Ownership
|
||||
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
|
||||
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
|
||||
@@ -106,8 +115,9 @@
|
||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(container.Status) !== -1" class="label label-{{ container.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ container.Status }}</span>
|
||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(container.Status) === -1" class="label label-{{ container.Status|containerstatusbadge }}">{{ container.Status }}</span>
|
||||
</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: 40}}</a></td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: 40}}</a></td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: truncate_size}}</a></td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: truncate_size}}</a></td>
|
||||
<td>{{ container.StackName ? container.StackName : '-' }}</td>
|
||||
<td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td>
|
||||
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
|
||||
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td>
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
angular.module('containers', [])
|
||||
.controller('ContainersController', ['$q', '$scope', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'SystemService', 'Notifications', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider',
|
||||
function ($q, $scope, $filter, Container, ContainerService, ContainerHelper, SystemService, Notifications, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider) {
|
||||
.controller('ContainersController', ['$q', '$scope', '$state', '$filter', 'Container', 'ContainerService', 'ContainerHelper', 'SystemService', 'Notifications', 'Pagination', 'EntityListService', 'ModalService', 'ResourceControlService', 'EndpointProvider', 'LocalStorage',
|
||||
function ($q, $scope, $state, $filter, Container, ContainerService, ContainerHelper, SystemService, Notifications, Pagination, EntityListService, ModalService, ResourceControlService, EndpointProvider, LocalStorage) {
|
||||
$scope.state = {};
|
||||
$scope.state.pagination_count = Pagination.getPaginationCount('containers');
|
||||
$scope.state.displayAll = true;
|
||||
$scope.state.displayAll = LocalStorage.getFilterContainerShowAll();
|
||||
$scope.state.displayIP = false;
|
||||
$scope.sortType = 'State';
|
||||
$scope.sortReverse = false;
|
||||
$scope.state.selectedItemCount = 0;
|
||||
$scope.truncate_size = 40;
|
||||
$scope.showMore = true;
|
||||
|
||||
$scope.order = function (sortType) {
|
||||
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
|
||||
$scope.sortType = sortType;
|
||||
@@ -21,7 +24,6 @@ angular.module('containers', [])
|
||||
$scope.cleanAssociatedVolumes = false;
|
||||
|
||||
var update = function (data) {
|
||||
$('#loadContainersSpinner').show();
|
||||
$scope.state.selectedItemCount = 0;
|
||||
Container.query(data, function (d) {
|
||||
var containers = d;
|
||||
@@ -42,21 +44,17 @@ angular.module('containers', [])
|
||||
return model;
|
||||
});
|
||||
updateSelectionFlags();
|
||||
$('#loadContainersSpinner').hide();
|
||||
}, function (e) {
|
||||
$('#loadContainersSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to retrieve containers');
|
||||
$scope.containers = [];
|
||||
});
|
||||
};
|
||||
|
||||
var batch = function (items, action, msg) {
|
||||
$('#loadContainersSpinner').show();
|
||||
var counter = 0;
|
||||
var complete = function () {
|
||||
counter = counter - 1;
|
||||
if (counter === 0) {
|
||||
$('#loadContainersSpinner').hide();
|
||||
update({all: $scope.state.displayAll ? 1 : 0});
|
||||
}
|
||||
};
|
||||
@@ -75,12 +73,13 @@ angular.module('containers', [])
|
||||
else if (action === Container.remove) {
|
||||
ContainerService.remove(c, $scope.cleanAssociatedVolumes)
|
||||
.then(function success() {
|
||||
var index = items.indexOf(c);
|
||||
items.splice(index, 1);
|
||||
Notifications.success('Container successfully removed');
|
||||
complete();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove container');
|
||||
})
|
||||
.finally(function final() {
|
||||
complete();
|
||||
});
|
||||
}
|
||||
@@ -105,13 +104,9 @@ angular.module('containers', [])
|
||||
Notifications.error('Failure', e, 'An error occured');
|
||||
complete();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
});
|
||||
if (counter === 0) {
|
||||
$('#loadContainersSpinner').hide();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.selectItems = function (allSelected) {
|
||||
@@ -130,6 +125,7 @@ angular.module('containers', [])
|
||||
};
|
||||
|
||||
$scope.toggleGetAll = function () {
|
||||
LocalStorage.storeFilterContainerShowAll($scope.state.displayAll);
|
||||
update({all: $scope.state.displayAll ? 1 : 0});
|
||||
};
|
||||
|
||||
@@ -161,6 +157,12 @@ angular.module('containers', [])
|
||||
batch($scope.containers, Container.remove, 'Removed');
|
||||
};
|
||||
|
||||
|
||||
$scope.truncateMore = function(size) {
|
||||
$scope.truncate_size = 80;
|
||||
$scope.showMore = false;
|
||||
};
|
||||
|
||||
$scope.confirmRemoveAction = function () {
|
||||
var isOneContainerRunning = false;
|
||||
angular.forEach($scope.containers, function (c) {
|
||||
@@ -205,7 +207,7 @@ angular.module('containers', [])
|
||||
|
||||
if(container.Status === 'paused') {
|
||||
$scope.state.noPausedItemsSelected = false;
|
||||
} else if(container.Status === 'stopped' ||
|
||||
} else if(container.Status === 'stopped' ||
|
||||
container.Status === 'created') {
|
||||
$scope.state.noStoppedItemsSelected = false;
|
||||
} else if(container.Status === 'running') {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
angular.module('createConfig', [])
|
||||
.controller('CreateConfigController', ['$scope', '$state', '$document', 'Notifications', 'ConfigService', 'Authentication', 'FormValidator', 'ResourceControlService', 'CodeMirrorService',
|
||||
function ($scope, $state, $document, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService, CodeMirrorService) {
|
||||
|
||||
$scope.formValues = {
|
||||
Name: '',
|
||||
Labels: [],
|
||||
AccessControlData: new AccessControlFormData()
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
formValidationError: ''
|
||||
};
|
||||
|
||||
$scope.addLabel = function() {
|
||||
$scope.formValues.Labels.push({ name: '', value: ''});
|
||||
};
|
||||
|
||||
$scope.removeLabel = function(index) {
|
||||
$scope.formValues.Labels.splice(index, 1);
|
||||
};
|
||||
|
||||
function prepareLabelsConfig(config) {
|
||||
var labels = {};
|
||||
$scope.formValues.Labels.forEach(function (label) {
|
||||
if (label.name && label.value) {
|
||||
labels[label.name] = label.value;
|
||||
}
|
||||
});
|
||||
config.Labels = labels;
|
||||
}
|
||||
|
||||
function prepareConfigData(config) {
|
||||
// The codemirror editor does not work with ng-model so we need to retrieve
|
||||
// the value directly from the editor.
|
||||
var configData = $scope.editor.getValue();
|
||||
config.Data = btoa(unescape(encodeURIComponent(configData)));
|
||||
}
|
||||
|
||||
function prepareConfiguration() {
|
||||
var config = {};
|
||||
config.Name = $scope.formValues.Name;
|
||||
prepareConfigData(config);
|
||||
prepareLabelsConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
function validateForm(accessControlData, isAdmin) {
|
||||
$scope.state.formValidationError = '';
|
||||
var error = '';
|
||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
$scope.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$scope.create = function () {
|
||||
var accessControlData = $scope.formValues.AccessControlData;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = userDetails.role === 1 ? true : false;
|
||||
|
||||
if (!validateForm(accessControlData, isAdmin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var config = prepareConfiguration();
|
||||
|
||||
ConfigService.create(config)
|
||||
.then(function success(data) {
|
||||
var configIdentifier = data.ID;
|
||||
var userId = userDetails.ID;
|
||||
return ResourceControlService.applyResourceControl('config', configIdentifier, userId, accessControlData, []);
|
||||
})
|
||||
.then(function success() {
|
||||
Notifications.success('Config successfully created');
|
||||
$state.go('configs', {}, {reload: true});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to create config');
|
||||
});
|
||||
};
|
||||
|
||||
function initView() {
|
||||
$document.ready(function() {
|
||||
var webEditorElement = $document[0].getElementById('config-editor', false);
|
||||
if (webEditorElement) {
|
||||
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
@@ -0,0 +1,72 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Create config"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="configs">Configs</a> > Add config
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="config_name" class="col-sm-1 control-label text-left">Name</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="text" class="form-control" ng-model="formValues.Name" id="config_name" placeholder="e.g. myConfig">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- config-data -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<textarea id="config-editor" class="form-control"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !config-data -->
|
||||
<!-- labels -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Labels</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addLabel()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
|
||||
</span>
|
||||
</div>
|
||||
<!-- labels-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="label in formValues.Labels" 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="label.name" placeholder="e.g. com.example.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="label.value" placeholder="e.g. bar">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !labels-input-list -->
|
||||
</div>
|
||||
<!-- !labels-->
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name" ng-click="create()">Create config</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
|
||||
// See app/components/templates/templatesController.js as a reference.
|
||||
angular.module('createContainer', [])
|
||||
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService',
|
||||
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) {
|
||||
.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService',
|
||||
function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService) {
|
||||
|
||||
$scope.formValues = {
|
||||
alwaysPull: true,
|
||||
@@ -13,11 +13,21 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
ExtraHosts: [],
|
||||
IPv4: '',
|
||||
IPv6: '',
|
||||
AccessControlData: new AccessControlFormData()
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
CpuLimit: 0,
|
||||
MemoryLimit: 0,
|
||||
MemoryReservation: 0
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
formValidationError: ''
|
||||
formValidationError: '',
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.refreshSlider = function () {
|
||||
$timeout(function () {
|
||||
$scope.$broadcast('rzSliderForceRender');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.config = {
|
||||
@@ -221,6 +231,25 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
config.HostConfig.Devices = path;
|
||||
}
|
||||
|
||||
function prepareResources(config) {
|
||||
// Memory Limit - Round to 0.125
|
||||
var memoryLimit = (Math.round($scope.formValues.MemoryLimit * 8) / 8).toFixed(3);
|
||||
memoryLimit *= 1024 * 1024;
|
||||
if (memoryLimit > 0) {
|
||||
config.HostConfig.Memory = memoryLimit;
|
||||
}
|
||||
// Memory Resevation - Round to 0.125
|
||||
var memoryReservation = (Math.round($scope.formValues.MemoryReservation * 8) / 8).toFixed(3);
|
||||
memoryReservation *= 1024 * 1024;
|
||||
if (memoryReservation > 0) {
|
||||
config.HostConfig.MemoryReservation = memoryReservation;
|
||||
}
|
||||
// CPU Limit
|
||||
if ($scope.formValues.CpuLimit > 0) {
|
||||
config.HostConfig.NanoCpus = $scope.formValues.CpuLimit * 1000000000;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareConfiguration() {
|
||||
var config = angular.copy($scope.config);
|
||||
config.Cmd = ContainerHelper.commandStringToArray(config.Cmd);
|
||||
@@ -232,6 +261,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
prepareVolumes(config);
|
||||
prepareLabels(config);
|
||||
prepareDevices(config);
|
||||
prepareResources(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -403,7 +433,6 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
}
|
||||
|
||||
function loadFromContainerImageConfig(d) {
|
||||
// If no registry found, we let default DockerHub and let full image path
|
||||
var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image);
|
||||
RegistryService.retrieveRegistryFromRepository($scope.config.Image)
|
||||
.then(function success(data) {
|
||||
@@ -417,9 +446,21 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
});
|
||||
}
|
||||
|
||||
function loadFromContainerResources(d) {
|
||||
if (d.HostConfig.NanoCpus) {
|
||||
$scope.formValues.CpuLimit = d.HostConfig.NanoCpus / 1000000000;
|
||||
}
|
||||
if (d.HostConfig.Memory) {
|
||||
$scope.formValues.MemoryLimit = d.HostConfig.Memory / 1024 / 1024;
|
||||
}
|
||||
if (d.HostConfig.MemoryReservation) {
|
||||
$scope.formValues.MemoryReservation = d.HostConfig.MemoryReservation / 1024 / 1024;
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromContainerSpec() {
|
||||
// Get container
|
||||
Container.get({ id: $stateParams.from }).$promise
|
||||
Container.get({ id: $transition$.params().from }).$promise
|
||||
.then(function success(d) {
|
||||
var fromContainer = new ContainerDetailsViewModel(d);
|
||||
if (!fromContainer.ResourceControl) {
|
||||
@@ -436,6 +477,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
loadFromContainerConsole(d);
|
||||
loadFromContainerDevices(d);
|
||||
loadFromContainerImageConfig(d);
|
||||
loadFromContainerResources(d);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve container');
|
||||
@@ -473,7 +515,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
Container.query({}, function (d) {
|
||||
var containers = d;
|
||||
$scope.runningContainers = containers;
|
||||
if ($stateParams.from !== '') {
|
||||
if ($transition$.params().from !== '') {
|
||||
loadFromContainerSpec();
|
||||
} else {
|
||||
$scope.fromContainer = {};
|
||||
@@ -483,6 +525,32 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
Notifications.error('Failure', e, 'Unable to retrieve running containers');
|
||||
});
|
||||
|
||||
SystemService.info()
|
||||
.then(function success(data) {
|
||||
$scope.state.sliderMaxCpu = 32;
|
||||
if (data.NCPU) {
|
||||
$scope.state.sliderMaxCpu = data.NCPU;
|
||||
}
|
||||
$scope.state.sliderMaxMemory = 32768;
|
||||
if (data.MemTotal) {
|
||||
$scope.state.sliderMaxMemory = Math.floor(data.MemTotal / 1000 / 1000);
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve engine details');
|
||||
});
|
||||
|
||||
SettingsService.publicSettings()
|
||||
.then(function success(data) {
|
||||
$scope.allowBindMounts = data.AllowBindMountsForRegularUsers;
|
||||
$scope.allowPrivilegedMode = data.AllowPrivilegedModeForRegularUsers;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve application settings');
|
||||
});
|
||||
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
$scope.isAdmin = userDetails.role === 1 ? true : false;
|
||||
}
|
||||
|
||||
function validateForm(accessControlData, isAdmin) {
|
||||
@@ -503,16 +571,16 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
if (!confirm) {
|
||||
return false;
|
||||
}
|
||||
$('#createContainerSpinner').show();
|
||||
|
||||
var accessControlData = $scope.formValues.AccessControlData;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = userDetails.role === 1 ? true : false;
|
||||
|
||||
if (!validateForm(accessControlData, isAdmin)) {
|
||||
$('#createContainerSpinner').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
var config = prepareConfiguration();
|
||||
createContainer(config, accessControlData);
|
||||
})
|
||||
@@ -538,7 +606,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
|
||||
Notifications.error('Failure', err, 'Unable to create container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#createContainerSpinner').hide();
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,24 +21,30 @@
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Image configuration
|
||||
</div>
|
||||
<!-- image-and-registry -->
|
||||
<div class="form-group">
|
||||
<por-image-registry image="config.Image" registry="formValues.Registry" ng-if="formValues.Registry"></por-image-registry>
|
||||
<div ng-if="!formValues.Registry && fromContainer">
|
||||
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i>
|
||||
<span class="small text-danger" style="margin-left: 5px;">The Docker registry for the <code>{{ config.Image }}</code> image is not registered inside Portainer, you will not be able to create a container. Please register that registry first.</span>
|
||||
</div>
|
||||
<!-- !image-and-registry -->
|
||||
<!-- always-pull -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="ownership" class="control-label text-left">
|
||||
Always pull the image
|
||||
<portainer-tooltip position="bottom" message="When enabled, Portainer will automatically try to pull the specified image before creating the container."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="formValues.alwaysPull"><i></i>
|
||||
</label>
|
||||
<div ng-if="formValues.Registry || !fromContainer">
|
||||
<!-- image-and-registry -->
|
||||
<div class="form-group">
|
||||
<por-image-registry image="config.Image" registry="formValues.Registry" ng-if="formValues.Registry"></por-image-registry>
|
||||
</div>
|
||||
<!-- !image-and-registry -->
|
||||
<!-- always-pull -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="ownership" class="control-label text-left">
|
||||
Always pull the image
|
||||
<portainer-tooltip position="bottom" message="When enabled, Portainer will automatically try to pull the specified image before creating the container."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="formValues.alwaysPull"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !always-pull -->
|
||||
</div>
|
||||
<!-- !always-pull -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Ports configuration
|
||||
</div>
|
||||
@@ -106,9 +112,10 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Image" ng-click="create()">Start container</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="containers">Cancel</a>
|
||||
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !config.Image || (!formValues.Registry && fromContainer)" ng-click="create()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Deploy the container</span>
|
||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||
<span ng-if="fromContainerMultipleNetworks" style="margin-left: 10px">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
@@ -135,7 +142,7 @@
|
||||
<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="#restart-policy" data-toggle="tab">Restart policy</a></li>
|
||||
<li class="interactive"><a data-target="#runtime" data-toggle="tab">Runtime</a></li>
|
||||
<li class="interactive"><a data-target="#runtime-resources" ng-click="refreshSlider()" data-toggle="tab">Runtime & Resources</a></li>
|
||||
</ul>
|
||||
<!-- tab-content -->
|
||||
<div class="tab-content">
|
||||
@@ -229,7 +236,7 @@
|
||||
</div>
|
||||
<!-- !container-path -->
|
||||
<!-- volume-type -->
|
||||
<div class="input-group col-sm-5" style="margin-left: 5px;">
|
||||
<div class="input-group col-sm-5" style="margin-left: 5px;" ng-if="isAdmin || allowBindMounts">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
|
||||
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''">Bind</label>
|
||||
@@ -460,17 +467,20 @@
|
||||
</form>
|
||||
</div>
|
||||
<!-- !tab-restart-policy -->
|
||||
<!-- tab-runtime -->
|
||||
<div class="tab-pane" id="runtime">
|
||||
<!-- tab-runtime-resources -->
|
||||
<div class="tab-pane" id="runtime-resources">
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Runtime
|
||||
</div>
|
||||
<!-- privileged-mode -->
|
||||
<div class="form-group">
|
||||
<div class="form-group" ng-if="isAdmin || allowPrivilegedMode">
|
||||
<div class="col-sm-12">
|
||||
<label for="ownership" class="control-label text-left">
|
||||
<label for="privileged_mode" class="control-label text-left">
|
||||
Privileged mode
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="config.HostConfig.Privileged"><i></i>
|
||||
<input type="checkbox" name="privileged_mode" ng-model="config.HostConfig.Privileged"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -504,10 +514,63 @@
|
||||
<!-- !devices-input-list -->
|
||||
</div>
|
||||
<!-- !devices-->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Resources
|
||||
</div>
|
||||
<!-- memory-reservation-input -->
|
||||
<div class="form-group">
|
||||
<label for="memory-reservation" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
|
||||
Memory reservation
|
||||
</label>
|
||||
<div class="col-sm-3">
|
||||
<por-slider model="formValues.MemoryReservation" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryReservation" id="memory-reservation">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<p class="small text-muted" style="margin-top: 7px;">
|
||||
Memory soft limit (<b>MB</b>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !memory-reservation-input -->
|
||||
<!-- memory-limit-input -->
|
||||
<div class="form-group">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
|
||||
Memory limit
|
||||
</label>
|
||||
<div class="col-sm-3">
|
||||
<por-slider model="formValues.MemoryLimit" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryLimit" id="memory-limit">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<p class="small text-muted" style="margin-top: 7px;">
|
||||
Memory limit (<b>MB</b>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !memory-limit-input -->
|
||||
<!-- cpu-limit-input -->
|
||||
<div class="form-group">
|
||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
|
||||
CPU limit
|
||||
</label>
|
||||
<div class="col-sm-5">
|
||||
<por-slider model="formValues.CpuLimit" floor="0" ceil="state.sliderMaxCpu" step="0.25" precision="2" ng-if="state.sliderMaxCpu"></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-4" style="margin-top: 20px;">
|
||||
<p class="small text-muted">
|
||||
Maximum CPU usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<!-- !tab-runtime -->
|
||||
<!-- !tab-runtime-resources -->
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
angular.module('createNetwork', [])
|
||||
.controller('CreateNetworkController', ['$scope', '$state', 'Notifications', 'Network', 'LabelHelper',
|
||||
function ($scope, $state, Notifications, Network, LabelHelper) {
|
||||
.controller('CreateNetworkController', ['$q', '$scope', '$state', 'PluginService', 'Notifications', 'NetworkService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator',
|
||||
function ($q, $scope, $state, PluginService, Notifications, NetworkService, LabelHelper, Authentication, ResourceControlService, FormValidator) {
|
||||
|
||||
$scope.formValues = {
|
||||
DriverOptions: [],
|
||||
Subnet: '',
|
||||
Gateway: '',
|
||||
Labels: []
|
||||
Labels: [],
|
||||
AccessControlData: new AccessControlFormData()
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
formValidationError: '',
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.availableNetworkDrivers = [];
|
||||
|
||||
$scope.config = {
|
||||
Driver: 'bridge',
|
||||
CheckDuplicate: true,
|
||||
@@ -37,23 +46,6 @@ function ($scope, $state, Notifications, Network, LabelHelper) {
|
||||
$scope.formValues.Labels.splice(index, 1);
|
||||
};
|
||||
|
||||
function createNetwork(config) {
|
||||
$('#createNetworkSpinner').show();
|
||||
Network.create(config, function (d) {
|
||||
if (d.message) {
|
||||
$('#createNetworkSpinner').hide();
|
||||
Notifications.error('Unable to create network', {}, d.message);
|
||||
} else {
|
||||
Notifications.success('Network created', d.Id);
|
||||
$('#createNetworkSpinner').hide();
|
||||
$state.go('networks', {}, {reload: true});
|
||||
}
|
||||
}, function (e) {
|
||||
$('#createNetworkSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to create network');
|
||||
});
|
||||
}
|
||||
|
||||
function prepareIPAMConfiguration(config) {
|
||||
if ($scope.formValues.Subnet) {
|
||||
var ipamConfig = {};
|
||||
@@ -85,8 +77,60 @@ function ($scope, $state, Notifications, Network, LabelHelper) {
|
||||
return config;
|
||||
}
|
||||
|
||||
function validateForm(accessControlData, isAdmin) {
|
||||
$scope.state.formValidationError = '';
|
||||
var error = '';
|
||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
$scope.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$scope.create = function () {
|
||||
var config = prepareConfiguration();
|
||||
createNetwork(config);
|
||||
var networkConfiguration = prepareConfiguration();
|
||||
var accessControlData = $scope.formValues.AccessControlData;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = userDetails.role === 1 ? true : false;
|
||||
|
||||
if (!validateForm(accessControlData, isAdmin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
NetworkService.create(networkConfiguration)
|
||||
.then(function success(data) {
|
||||
var networkIdentifier = data.Id;
|
||||
var userId = userDetails.ID;
|
||||
return ResourceControlService.applyResourceControl('network', networkIdentifier, userId, accessControlData, []);
|
||||
})
|
||||
.then(function success() {
|
||||
Notifications.success('Network successfully created');
|
||||
$state.go('networks', {}, {reload: true});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'An error occured during network creation');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
function initView() {
|
||||
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
|
||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||
if(endpointProvider !== 'DOCKER_SWARM') {
|
||||
PluginService.networkPlugins(apiVersion < 1.25)
|
||||
.then(function success(data){
|
||||
$scope.availableNetworkDrivers = data;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve network drivers');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
|
||||
@@ -39,8 +39,11 @@
|
||||
<!-- driver-input -->
|
||||
<div class="form-group">
|
||||
<label for="network_driver" class="col-sm-2 col-lg-1 control-label text-left">Driver</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="config.Driver" id="network_driver" placeholder="e.g. driverName">
|
||||
<div class="col-sm-11">
|
||||
<select class="form-control" ng-options="driver for driver in availableNetworkDrivers" ng-model="config.Driver" ng-if="availableNetworkDrivers.length > 0">
|
||||
<option disabled hidden value="">Select a driver</option>
|
||||
</select>
|
||||
<input type="text" class="form-control" ng-model="config.Driver" id="network_driver" placeholder="e.g. driverName" ng-if="availableNetworkDrivers.length === 0">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !driver-input -->
|
||||
@@ -116,15 +119,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- !internal -->
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!config.Name" ng-click="create()">Create network</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="networks">Cancel</a>
|
||||
<i id="createNetworkSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !config.Name" ng-click="create()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Create the network</span>
|
||||
<span ng-show="state.actionInProgress">Creating network...</span>
|
||||
</button>
|
||||
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
|
||||
@@ -3,7 +3,8 @@ angular.module('createRegistry', [])
|
||||
function ($scope, $state, RegistryService, Notifications) {
|
||||
|
||||
$scope.state = {
|
||||
RegistryType: 'quay'
|
||||
RegistryType: 'quay',
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
@@ -27,13 +28,13 @@ function ($scope, $state, RegistryService, Notifications) {
|
||||
};
|
||||
|
||||
$scope.addRegistry = function() {
|
||||
$('#createRegistrySpinner').show();
|
||||
var registryName = $scope.formValues.Name;
|
||||
var registryURL = $scope.formValues.URL.replace(/^https?\:\/\//i, '');
|
||||
var authentication = $scope.formValues.Authentication;
|
||||
var username = $scope.formValues.Username;
|
||||
var password = $scope.formValues.Password;
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
RegistryService.createRegistry(registryName, registryURL, authentication, username, password)
|
||||
.then(function success(data) {
|
||||
Notifications.success('Registry successfully created');
|
||||
@@ -43,7 +44,7 @@ function ($scope, $state, RegistryService, Notifications) {
|
||||
Notifications.error('Failure', err, 'Unable to create registry');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#createRegistrySpinner').hide();
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
}]);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Create registry">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="display:none"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-title title="Create registry"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="registries">Registries</a> > Add registry
|
||||
</rd-header-content>
|
||||
@@ -104,10 +102,15 @@
|
||||
<!-- !credentials-password -->
|
||||
</div>
|
||||
<!-- !authentication-credentials -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name || !formValues.URL || (formValues.Authentication && (!formValues.Username || !formValues.Password))" ng-click="addRegistry()">Add registry</button>
|
||||
<i id="createRegistrySpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Name || !formValues.URL || (formValues.Authentication && (!formValues.Username || !formValues.Password))" ng-click="addRegistry()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Add registry</span>
|
||||
<span ng-show="state.actionInProgress">Adding registry...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
angular.module('createSecret', [])
|
||||
.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper',
|
||||
function ($scope, $state, Notifications, SecretService, LabelHelper) {
|
||||
.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper', 'Authentication', 'ResourceControlService', 'FormValidator',
|
||||
function ($scope, $state, Notifications, SecretService, LabelHelper, Authentication, ResourceControlService, FormValidator) {
|
||||
|
||||
$scope.formValues = {
|
||||
Name: '',
|
||||
Data: '',
|
||||
Labels: [],
|
||||
encodeSecret: true
|
||||
encodeSecret: true,
|
||||
AccessControlData: new AccessControlFormData()
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
formValidationError: '',
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.addLabel = function() {
|
||||
@@ -36,10 +43,37 @@ function ($scope, $state, Notifications, SecretService, LabelHelper) {
|
||||
return config;
|
||||
}
|
||||
|
||||
function createSecret(config) {
|
||||
$('#createSecretSpinner').show();
|
||||
SecretService.create(config)
|
||||
function validateForm(accessControlData, isAdmin) {
|
||||
$scope.state.formValidationError = '';
|
||||
var error = '';
|
||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
$scope.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$scope.create = function () {
|
||||
|
||||
var accessControlData = $scope.formValues.AccessControlData;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = userDetails.role === 1 ? true : false;
|
||||
|
||||
if (!validateForm(accessControlData, isAdmin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
var secretConfiguration = prepareConfiguration();
|
||||
SecretService.create(secretConfiguration)
|
||||
.then(function success(data) {
|
||||
var secretIdentifier = data.ID;
|
||||
var userId = userDetails.ID;
|
||||
return ResourceControlService.applyResourceControl('secret', secretIdentifier, userId, accessControlData, []);
|
||||
})
|
||||
.then(function success() {
|
||||
Notifications.success('Secret successfully created');
|
||||
$state.go('secrets', {}, {reload: true});
|
||||
})
|
||||
@@ -47,12 +81,7 @@ function ($scope, $state, Notifications, SecretService, LabelHelper) {
|
||||
Notifications.error('Failure', err, 'Unable to create secret');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#createSecretSpinner').hide();
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
$scope.create = function () {
|
||||
var config = prepareConfiguration();
|
||||
createSecret(config);
|
||||
};
|
||||
}]);
|
||||
|
||||
@@ -66,15 +66,20 @@
|
||||
<!-- !labels-input-list -->
|
||||
</div>
|
||||
<!-- !labels-->
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name || !formValues.Data" ng-click="create()">Create secret</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="secrets">Cancel</a>
|
||||
<i id="createSecretSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Name || !formValues.Data" ng-click="create()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Create the secret</span>
|
||||
<span ng-show="state.actionInProgress">Creating secret...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
|
||||
// See app/components/templates/templatesController.js as a reference.
|
||||
angular.module('createService', [])
|
||||
.controller('CreateServiceController', ['$q', '$scope', '$state', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper',
|
||||
function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper) {
|
||||
.controller('CreateServiceController', ['$q', '$scope', '$state', '$timeout', 'Service', 'ServiceHelper', 'ConfigService', 'ConfigHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper', 'NodeService', 'SettingsService',
|
||||
function ($q, $scope, $state, $timeout, Service, ServiceHelper, ConfigService, ConfigHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper, NodeService, SettingsService) {
|
||||
|
||||
$scope.formValues = {
|
||||
Name: '',
|
||||
@@ -28,11 +28,25 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
UpdateOrder: 'stop-first',
|
||||
FailureAction: 'pause',
|
||||
Secrets: [],
|
||||
AccessControlData: new AccessControlFormData()
|
||||
Configs: [],
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
CpuLimit: 0,
|
||||
CpuReservation: 0,
|
||||
MemoryLimit: 0,
|
||||
MemoryReservation: 0,
|
||||
MemoryLimitUnit: 'MB',
|
||||
MemoryReservationUnit: 'MB'
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
formValidationError: ''
|
||||
formValidationError: '',
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.refreshSlider = function () {
|
||||
$timeout(function () {
|
||||
$scope.$broadcast('rzSliderForceRender');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.addPortBinding = function() {
|
||||
@@ -59,6 +73,14 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
$scope.formValues.Volumes.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addConfig = function() {
|
||||
$scope.formValues.Configs.push({});
|
||||
};
|
||||
|
||||
$scope.removeConfig = function(index) {
|
||||
$scope.formValues.Configs.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.addSecret = function() {
|
||||
$scope.formValues.Secrets.push({});
|
||||
};
|
||||
@@ -177,10 +199,32 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(input.ContainerLabels);
|
||||
}
|
||||
|
||||
function createMountObjectFromVolume(volumeObject, target, readonly) {
|
||||
return {
|
||||
Target: target,
|
||||
Source: volumeObject.Id,
|
||||
Type: 'volume',
|
||||
ReadOnly: readonly,
|
||||
VolumeOptions: {
|
||||
Labels: volumeObject.Labels,
|
||||
DriverConfig: {
|
||||
Name: volumeObject.Driver,
|
||||
Options: volumeObject.Options
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function prepareVolumes(config, input) {
|
||||
input.Volumes.forEach(function (volume) {
|
||||
if (volume.Source && volume.Target) {
|
||||
config.TaskTemplate.ContainerSpec.Mounts.push(volume);
|
||||
if (volume.Type !== 'volume') {
|
||||
config.TaskTemplate.ContainerSpec.Mounts.push(volume);
|
||||
} else {
|
||||
var volumeObject = volume.Source;
|
||||
var mount = createMountObjectFromVolume(volumeObject, volume.Target, volume.ReadOnly);
|
||||
config.TaskTemplate.ContainerSpec.Mounts.push(mount);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -210,6 +254,20 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(input.PlacementPreferences);
|
||||
}
|
||||
|
||||
function prepareConfigConfig(config, input) {
|
||||
if (input.Configs) {
|
||||
var configs = [];
|
||||
angular.forEach(input.Configs, function(config) {
|
||||
if (config.model) {
|
||||
var s = ConfigHelper.configConfig(config.model);
|
||||
s.File.Name = config.FileName || s.File.Name;
|
||||
configs.push(s);
|
||||
}
|
||||
});
|
||||
config.TaskTemplate.ContainerSpec.Configs = configs;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareSecretConfig(config, input) {
|
||||
if (input.Secrets) {
|
||||
var secrets = [];
|
||||
@@ -224,6 +282,38 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
}
|
||||
}
|
||||
|
||||
function prepareResourcesCpuConfig(config, input) {
|
||||
// CPU Limit
|
||||
if (input.CpuLimit > 0) {
|
||||
config.TaskTemplate.Resources.Limits.NanoCPUs = input.CpuLimit * 1000000000;
|
||||
}
|
||||
// CPU Reservation
|
||||
if (input.CpuReservation > 0) {
|
||||
config.TaskTemplate.Resources.Reservations.NanoCPUs = input.CpuReservation * 1000000000;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareResourcesMemoryConfig(config, input) {
|
||||
// Memory Limit - Round to 0.125
|
||||
var memoryLimit = (Math.round(input.MemoryLimit * 8) / 8).toFixed(3);
|
||||
memoryLimit *= 1024 * 1024;
|
||||
if (input.MemoryLimitUnit === 'GB') {
|
||||
memoryLimit *= 1024;
|
||||
}
|
||||
if (memoryLimit > 0) {
|
||||
config.TaskTemplate.Resources.Limits.MemoryBytes = memoryLimit;
|
||||
}
|
||||
// Memory Resevation - Round to 0.125
|
||||
var memoryReservation = (Math.round(input.MemoryReservation * 8) / 8).toFixed(3);
|
||||
memoryReservation *= 1024 * 1024;
|
||||
if (input.MemoryReservationUnit === 'GB') {
|
||||
memoryReservation *= 1024;
|
||||
}
|
||||
if (memoryReservation > 0) {
|
||||
config.TaskTemplate.Resources.Reservations.MemoryBytes = memoryReservation;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareConfiguration() {
|
||||
var input = $scope.formValues;
|
||||
var config = {
|
||||
@@ -232,7 +322,11 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
ContainerSpec: {
|
||||
Mounts: []
|
||||
},
|
||||
Placement: {}
|
||||
Placement: {},
|
||||
Resources: {
|
||||
Limits: {},
|
||||
Reservations: {}
|
||||
}
|
||||
},
|
||||
Mode: {},
|
||||
EndpointSpec: {}
|
||||
@@ -246,8 +340,11 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
prepareVolumes(config, input);
|
||||
prepareNetworks(config, input);
|
||||
prepareUpdateConfig(config, input);
|
||||
prepareConfigConfig(config, input);
|
||||
prepareSecretConfig(config, input);
|
||||
preparePlacementConfig(config, input);
|
||||
prepareResourcesCpuConfig(config, input);
|
||||
prepareResourcesMemoryConfig(config, input);
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -270,7 +367,7 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
Notifications.error('Failure', err, 'Unable to create service');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#createServiceSpinner').hide();
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -287,40 +384,69 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
|
||||
}
|
||||
|
||||
$scope.create = function createService() {
|
||||
$('#createServiceSpinner').show();
|
||||
|
||||
var accessControlData = $scope.formValues.AccessControlData;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = userDetails.role === 1 ? true : false;
|
||||
|
||||
if (!validateForm(accessControlData, isAdmin)) {
|
||||
$('#createServiceSpinner').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
var config = prepareConfiguration();
|
||||
createNewService(config, accessControlData);
|
||||
};
|
||||
|
||||
function initSlidersMaxValuesBasedOnNodeData(nodes) {
|
||||
var maxCpus = 0;
|
||||
var maxMemory = 0;
|
||||
for (var n in nodes) {
|
||||
if (nodes[n].CPUs && nodes[n].CPUs > maxCpus) {
|
||||
maxCpus = nodes[n].CPUs;
|
||||
}
|
||||
if (nodes[n].Memory && nodes[n].Memory > maxMemory) {
|
||||
maxMemory = nodes[n].Memory;
|
||||
}
|
||||
}
|
||||
if (maxCpus > 0) {
|
||||
$scope.state.sliderMaxCpu = maxCpus / 1000000000;
|
||||
} else {
|
||||
$scope.state.sliderMaxCpu = 32;
|
||||
}
|
||||
if (maxMemory > 0) {
|
||||
$scope.state.sliderMaxMemory = Math.floor(maxMemory / 1000 / 1000);
|
||||
} else {
|
||||
$scope.state.sliderMaxMemory = 32768;
|
||||
}
|
||||
}
|
||||
|
||||
function initView() {
|
||||
$('#loadingViewSpinner').show();
|
||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||
var provider = $scope.applicationState.endpoint.mode.provider;
|
||||
|
||||
$q.all({
|
||||
volumes: VolumeService.volumes(),
|
||||
networks: NetworkService.networks(true, true, false, false),
|
||||
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
|
||||
networks: NetworkService.networks(true, true, false, false)
|
||||
configs: apiVersion >= 1.30 ? ConfigService.configs() : [],
|
||||
nodes: NodeService.nodes(),
|
||||
settings: SettingsService.publicSettings()
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.availableVolumes = data.volumes;
|
||||
$scope.availableNetworks = data.networks;
|
||||
$scope.availableSecrets = data.secrets;
|
||||
$scope.availableConfigs = data.configs;
|
||||
var nodes = data.nodes;
|
||||
initSlidersMaxValuesBasedOnNodeData(nodes);
|
||||
var settings = data.settings;
|
||||
$scope.allowBindMounts = settings.AllowBindMountsForRegularUsers;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
$scope.isAdmin = userDetails.role === 1 ? true : false;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to initialize view');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Create service">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-title title="Create service"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="services">Services</a> > Add service
|
||||
</rd-header-content>
|
||||
@@ -109,9 +107,10 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Image" ng-click="create()">Create service</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="services">Cancel</a>
|
||||
<i id="createServiceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Image" ng-click="create()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Create the service</span>
|
||||
<span ng-show="state.actionInProgress">Creating service...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,7 +132,8 @@
|
||||
<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</a></li>
|
||||
<li class="interactive" ng-if="applicationState.endpoint.apiVersion >= 1.25"><a data-target="#secrets" data-toggle="tab">Secrets</a></li>
|
||||
<li class="interactive"><a data-target="#placement" data-toggle="tab">Placement</a></li>
|
||||
<li class="interactive"><a data-target="#configs" data-toggle="tab" ng-if="applicationState.endpoint.apiVersion >= 1.30">Configs</a></li>
|
||||
<li class="interactive"><a data-target="#resources-placement" data-toggle="tab" ng-click="refreshSlider()">Resources & Placement</a></li>
|
||||
</ul>
|
||||
<!-- tab-content -->
|
||||
<div class="tab-content">
|
||||
@@ -223,7 +223,7 @@
|
||||
<!-- !container-path -->
|
||||
<!-- volume-type -->
|
||||
<div class="input-group col-sm-5" style="margin-left: 5px;">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<div class="btn-group btn-group-sm" ng-if="isAdmin || allowBindMounts">
|
||||
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
|
||||
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'bind'" ng-click="volume.Id = ''">Bind</label>
|
||||
</div>
|
||||
@@ -240,9 +240,8 @@
|
||||
<!-- volume -->
|
||||
<div class="input-group input-group-sm col-sm-6" ng-if="volume.Type === 'volume'">
|
||||
<span class="input-group-addon">volume</span>
|
||||
<select class="form-control" ng-model="volume.Source">
|
||||
<select class="form-control" ng-model="volume.Source" ng-options="vol.Id|truncate:30 for vol in availableVolumes">
|
||||
<option selected disabled hidden value="">Select a volume</option>
|
||||
<option ng-repeat="vol in availableVolumes" ng-value="vol.Id">{{ vol.Id|truncate:30 }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- !volume -->
|
||||
@@ -442,9 +441,12 @@
|
||||
<!-- tab-secrets -->
|
||||
<div class="tab-pane" id="secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" ng-include="'app/components/createService/includes/secret.html'"></div>
|
||||
<!-- !tab-secrets -->
|
||||
<!-- tab-placement -->
|
||||
<div class="tab-pane" id="placement" ng-include="'app/components/createService/includes/placement.html'"></div>
|
||||
<!-- !tab-placement -->
|
||||
<!-- tab-configs -->
|
||||
<div class="tab-pane" id="configs" ng-if="applicationState.endpoint.apiVersion >= 1.30" ng-include="'app/components/createService/includes/config.html'"></div>
|
||||
<!-- !tab-configs -->
|
||||
<!-- tab-resources-placement -->
|
||||
<div class="tab-pane" id="resources-placement" ng-include="'app/components/createService/includes/resources-placement.html'"></div>
|
||||
<!-- !tab-resources-placement -->
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Configs</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addConfig()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add a config
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="config in formValues.Configs" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">config</span>
|
||||
<select class="form-control" ng-model="config.model" ng-options="config.Name for config in availableConfigs">
|
||||
<option value="" selected="selected">Select a config</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">Path in container</span>
|
||||
<input class="form-control" ng-model="config.FileName" placeholder="e.g. /path/in/container" />
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeConfig($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,57 +0,0 @@
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Placement constraints</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPlacementConstraint()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> placement constraint
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="constraint in formValues.PlacementConstraints" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">name</span>
|
||||
<input type="text" class="form-control" ng-model="constraint.key" placeholder="e.g. node.role">
|
||||
</div>
|
||||
<div class="input-group col-sm-1 input-group-sm">
|
||||
<select name="constraintOperator" class="form-control" ng-model="constraint.operator">
|
||||
<option value="==">==</option>
|
||||
<option value="!=">!=</option>
|
||||
</select>
|
||||
</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="constraint.value" placeholder="e.g. manager">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removePlacementConstraint($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="form-horizontal" style="margin-top: 15px;" ng-if="applicationState.endpoint.apiVersion >= 1.30">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Placement preferences</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPlacementPreference()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> placement preference
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="preference in formValues.PlacementPreferences" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">strategy</span>
|
||||
<input type="text" class="form-control" ng-model="preference.strategy" placeholder="e.g. spread">
|
||||
</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="preference.value" placeholder="e.g. node.labels.datacenter">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removePlacementPreference($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,130 @@
|
||||
<form class="form-horizontal" style="margin-top: 15px;">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Resources
|
||||
</div>
|
||||
<!-- memory-reservation-input -->
|
||||
<div class="form-group">
|
||||
<label for="memory-reservation" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
|
||||
Memory reservation
|
||||
</label>
|
||||
<div class="col-sm-3">
|
||||
<por-slider model="formValues.MemoryReservation" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryReservation">
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<p class="small text-muted" style="margin-top: 7px;">
|
||||
Minimum memory available on a node to run a task (<b>MB</b>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !memory-reservation-input -->
|
||||
<!-- memory-limit-input -->
|
||||
<div class="form-group">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
|
||||
Memory limit
|
||||
</label>
|
||||
<div class="col-sm-3">
|
||||
<por-slider model="formValues.MemoryLimit" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryLimit">
|
||||
</div>
|
||||
<div class="col-sm-4" style="margin-top: 7px;">
|
||||
<p class="small text-muted">
|
||||
Maximum memory usage per task (<b>MB</b>)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !memory-limit-input -->
|
||||
<!-- cpu-reservation-input -->
|
||||
<div class="form-group">
|
||||
<label for="cpu-reservation" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
|
||||
CPU reservation
|
||||
</label>
|
||||
<div class="col-sm-5">
|
||||
<por-slider model="formValues.CpuReservation" floor="0" ceil="state.sliderMaxCpu" step="0.25" precision="2" ng-if="state.sliderMaxCpu"></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-4" style="margin-top: 20px;">
|
||||
<p class="small text-muted">
|
||||
Minimum CPU available on a node to run a task
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-reservation-input -->
|
||||
<!-- cpu-limit-input -->
|
||||
<div class="form-group">
|
||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px;">
|
||||
CPU limit
|
||||
</label>
|
||||
<div class="col-sm-5">
|
||||
<por-slider model="formValues.CpuLimit" floor="0" ceil="state.sliderMaxCpu" step="0.25" precision="2" ng-if="state.sliderMaxCpu"></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-4" style="margin-top: 20px;">
|
||||
<p class="small text-muted">
|
||||
Maximum CPU usage per task
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Placement
|
||||
</div>
|
||||
<!-- placement-constraints -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Placement constraints</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPlacementConstraint()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> placement constraint
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="constraint in formValues.PlacementConstraints" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">name</span>
|
||||
<input type="text" class="form-control" ng-model="constraint.key" placeholder="e.g. node.role">
|
||||
</div>
|
||||
<div class="input-group col-sm-1 input-group-sm">
|
||||
<select name="constraintOperator" class="form-control" ng-model="constraint.operator">
|
||||
<option value="==">==</option>
|
||||
<option value="!=">!=</option>
|
||||
</select>
|
||||
</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="constraint.value" placeholder="e.g. manager">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removePlacementConstraint($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !placement-constraints -->
|
||||
<!-- placement-preferences -->
|
||||
<div class="form-group" ng-if="applicationState.endpoint.apiVersion >= 1.30">
|
||||
<div class="col-sm-12" style="margin-top: 5px;">
|
||||
<label class="control-label text-left">Placement preferences</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPlacementPreference()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> placement preference
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||
<div ng-repeat="preference in formValues.PlacementPreferences" style="margin-top: 2px;">
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">strategy</span>
|
||||
<input type="text" class="form-control" ng-model="preference.strategy" placeholder="e.g. spread">
|
||||
</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="preference.value" placeholder="e.g. node.labels.datacenter">
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removePlacementPreference($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !placement-preferences -->
|
||||
</form>
|
||||
@@ -0,0 +1,128 @@
|
||||
angular.module('createStack', [])
|
||||
.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper',
|
||||
function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper) {
|
||||
|
||||
// Store the editor content when switching builder methods
|
||||
var editorContent = '';
|
||||
var editorEnabled = true;
|
||||
|
||||
$scope.formValues = {
|
||||
Name: '',
|
||||
StackFileContent: '# Define or paste the content of your docker-compose file here',
|
||||
StackFile: null,
|
||||
RepositoryURL: '',
|
||||
Env: [],
|
||||
RepositoryPath: 'docker-compose.yml',
|
||||
AccessControlData: new AccessControlFormData()
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
Method: 'editor',
|
||||
formValidationError: '',
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.addEnvironmentVariable = function() {
|
||||
$scope.formValues.Env.push({ name: '', value: ''});
|
||||
};
|
||||
|
||||
$scope.removeEnvironmentVariable = function(index) {
|
||||
$scope.formValues.Env.splice(index, 1);
|
||||
};
|
||||
|
||||
function validateForm(accessControlData, isAdmin) {
|
||||
$scope.state.formValidationError = '';
|
||||
var error = '';
|
||||
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
$scope.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function createStack(name) {
|
||||
var method = $scope.state.Method;
|
||||
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
|
||||
|
||||
if (method === 'editor') {
|
||||
// The codemirror editor does not work with ng-model so we need to retrieve
|
||||
// the value directly from the editor.
|
||||
var stackFileContent = $scope.editor.getValue();
|
||||
|
||||
return StackService.createStackFromFileContent(name, stackFileContent, env);
|
||||
} else if (method === 'upload') {
|
||||
var stackFile = $scope.formValues.StackFile;
|
||||
return StackService.createStackFromFileUpload(name, stackFile, env);
|
||||
} else if (method === 'repository') {
|
||||
var gitRepository = $scope.formValues.RepositoryURL;
|
||||
var pathInRepository = $scope.formValues.RepositoryPath;
|
||||
return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository, env);
|
||||
}
|
||||
}
|
||||
|
||||
$scope.deployStack = function () {
|
||||
var name = $scope.formValues.Name;
|
||||
|
||||
var accessControlData = $scope.formValues.AccessControlData;
|
||||
var userDetails = Authentication.getUserDetails();
|
||||
var isAdmin = userDetails.role === 1 ? true : false;
|
||||
var userId = userDetails.ID;
|
||||
|
||||
if (!validateForm(accessControlData, isAdmin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
createStack(name)
|
||||
.then(function success(data) {
|
||||
Notifications.success('Stack successfully deployed');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.warning('Deployment error', err.err.data.err);
|
||||
})
|
||||
.then(function success(data) {
|
||||
return ResourceControlService.applyResourceControl('stack', name, userId, accessControlData, []);
|
||||
})
|
||||
.then(function success() {
|
||||
$state.go('stacks');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to apply resource control on the stack');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
function enableEditor(value) {
|
||||
$document.ready(function() {
|
||||
var webEditorElement = $document[0].getElementById('web-editor');
|
||||
if (webEditorElement) {
|
||||
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false);
|
||||
if (value) {
|
||||
$scope.editor.setValue(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$scope.toggleEditor = function() {
|
||||
if (!editorEnabled) {
|
||||
enableEditor(editorContent);
|
||||
editorEnabled = true;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.saveEditorContent = function() {
|
||||
editorContent = $scope.editor.getValue();
|
||||
editorEnabled = false;
|
||||
};
|
||||
|
||||
function initView() {
|
||||
enableEditor();
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
@@ -0,0 +1,185 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Create stack"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="stacks">Stacks</a> > Add stack
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="stack_name" class="col-sm-1 control-label text-left">Name</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="text" class="form-control" ng-model="formValues.Name" id="stack_name" placeholder="e.g. myStack" auto-focus>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
This stack will be deployed using the equivalent of the <code>docker stack deploy</code> command.
|
||||
</span>
|
||||
</div>
|
||||
<!-- build-method -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Build method
|
||||
</div>
|
||||
<div class="form-group"></div>
|
||||
<div class="form-group" style="margin-bottom: 0">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="method_editor" ng-model="state.Method" value="editor" ng-click="toggleEditor(state.Method)">
|
||||
<label for="method_editor">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Web editor
|
||||
</div>
|
||||
<p>Use our Web editor</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_upload" ng-model="state.Method" value="upload" ng-click="saveEditorContent()">
|
||||
<label for="method_upload">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Upload
|
||||
</div>
|
||||
<p>Upload from your computer</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="method_repository" ng-model="state.Method" value="repository" ng-click="saveEditorContent()">
|
||||
<label for="method_repository">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-git" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Repository
|
||||
</div>
|
||||
<p>Use a git repository</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !build-method -->
|
||||
<!-- web-editor -->
|
||||
<div ng-if="state.Method === 'editor'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Web editor
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<textarea id="web-editor" class="form-control" ng-model="formValues.StackFileContent" placeholder='version: "3"'></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !web-editor -->
|
||||
<!-- upload -->
|
||||
<div ng-if="state.Method === 'upload'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Upload
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can upload a Compose file from your computer.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.StackFile">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ formValues.StackFile.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.StackFile" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !upload -->
|
||||
<!-- repository -->
|
||||
<div ng-if="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 public 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="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">
|
||||
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="formValues.RepositoryPath" id="stack_repository_path" placeholder="docker-compose.yml">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Environment
|
||||
</div>
|
||||
<!-- 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 -->
|
||||
<!-- !repository -->
|
||||
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || (state.Method === 'editor' && !formValues.StackFileContent)
|
||||
|| (state.Method === 'upload' && !formValues.StackFile)
|
||||
|| (state.Method === 'repository' && (!formValues.RepositoryURL || !formValues.RepositoryPath))
|
||||
|| !formValues.Name" ng-click="deployStack()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Deploy the stack</span>
|
||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,7 +9,8 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi
|
||||
};
|
||||
|
||||
$scope.state = {
|
||||
formValidationError: ''
|
||||
formValidationError: '',
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.availableVolumeDrivers = [];
|
||||
@@ -35,7 +36,6 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi
|
||||
}
|
||||
|
||||
$scope.create = function () {
|
||||
$('#createVolumeSpinner').show();
|
||||
|
||||
var name = $scope.formValues.Name;
|
||||
var driver = $scope.formValues.Driver;
|
||||
@@ -46,10 +46,10 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi
|
||||
var isAdmin = userDetails.role === 1 ? true : false;
|
||||
|
||||
if (!validateForm(accessControlData, isAdmin)) {
|
||||
$('#createVolumeSpinner').hide();
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
VolumeService.createVolume(volumeConfiguration)
|
||||
.then(function success(data) {
|
||||
var volumeIdentifier = data.Id;
|
||||
@@ -64,12 +64,11 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi
|
||||
Notifications.error('Failure', err, 'An error occured during volume creation');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#createVolumeSpinner').hide();
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
function initView() {
|
||||
$('#loadingViewSpinner').show();
|
||||
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
|
||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||
if (endpointProvider !== 'DOCKER_SWARM') {
|
||||
@@ -79,9 +78,6 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve volume drivers');
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Create volume">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-title title="Create volume"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="volumes">Volumes</a> > Add volume
|
||||
</rd-header-content>
|
||||
@@ -73,9 +71,10 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-click="create()">Create volume</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="volumes">Cancel</a>
|
||||
<i id="createVolumeSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-click="create()" ng-disabled="state.actionInProgress" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Create the volume</span>
|
||||
<span ng-show="state.actionInProgress">Creating volume...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Home">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-title title="Home"></rd-header-title>
|
||||
<rd-header-content>Dashboard</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
@@ -85,6 +83,32 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<a ui-sref="stacks">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="widget-icon blue pull-left">
|
||||
<i class="fa fa-th-list"></i>
|
||||
</div>
|
||||
<div class="title">{{ stackCount }}</div>
|
||||
<div class="comment">Stacks</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<a ui-sref="services">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="widget-icon blue pull-left">
|
||||
<i class="fa fa-list-alt"></i>
|
||||
</div>
|
||||
<div class="title">{{ serviceCount }}</div>
|
||||
<div class="comment">Services</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<a ui-sref="containers">
|
||||
<rd-widget>
|
||||
@@ -125,9 +149,6 @@
|
||||
<div class="widget-icon blue pull-left">
|
||||
<i class="fa fa-cubes"></i>
|
||||
</div>
|
||||
<div class="pull-right" ng-if="infoData.Driver">
|
||||
<div><i class="fa fa-hdd-o space-right"></i>{{ infoData.Driver }} driver</div>
|
||||
</div>
|
||||
<div class="title">{{ volumeData.total }}</div>
|
||||
<div class="comment">Volumes</div>
|
||||
</rd-widget-body>
|
||||
@@ -148,6 +169,3 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
angular.module('dashboard', [])
|
||||
.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'Notifications',
|
||||
function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, Notifications) {
|
||||
.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'ServiceService', 'StackService', 'Notifications',
|
||||
function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, ServiceService, StackService, Notifications) {
|
||||
|
||||
$scope.containerData = {
|
||||
total: 0
|
||||
@@ -15,6 +15,9 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
|
||||
total: 0
|
||||
};
|
||||
|
||||
$scope.serviceCount = 0;
|
||||
$scope.stackCount = 0;
|
||||
|
||||
function prepareContainerData(d) {
|
||||
var running = 0;
|
||||
var stopped = 0;
|
||||
@@ -62,22 +65,26 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
|
||||
}
|
||||
|
||||
function initView() {
|
||||
$('#loadingViewSpinner').show();
|
||||
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
|
||||
var endpointRole = $scope.applicationState.endpoint.mode.role;
|
||||
|
||||
$q.all([
|
||||
Container.query({all: 1}).$promise,
|
||||
Image.query({}).$promise,
|
||||
Volume.query({}).$promise,
|
||||
Network.query({}).$promise,
|
||||
SystemService.info()
|
||||
SystemService.info(),
|
||||
endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? ServiceService.services() : [],
|
||||
endpointProvider === 'DOCKER_SWARM_MODE' && endpointRole === 'MANAGER' ? StackService.stacks(true) : []
|
||||
]).then(function (d) {
|
||||
prepareContainerData(d[0]);
|
||||
prepareImageData(d[1]);
|
||||
prepareVolumeData(d[2]);
|
||||
prepareNetworkData(d[3]);
|
||||
prepareInfoData(d[4]);
|
||||
$('#loadingViewSpinner').hide();
|
||||
$scope.serviceCount = d[5].length;
|
||||
$scope.stackCount = d[6].length;
|
||||
}, function(e) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
Notifications.error('Failure', e, 'Unable to load dashboard data');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Endpoint details">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-title title="Endpoint details"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="endpoints">Endpoints</a> > <a ui-sref="endpoint({id: endpoint.Id})">{{ endpoint.Name }}</a>
|
||||
</rd-header-content>
|
||||
@@ -12,6 +10,9 @@
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Configuration
|
||||
</div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
|
||||
@@ -42,73 +43,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- !endpoint-public-url-input -->
|
||||
<!-- tls-checkbox -->
|
||||
<div class="form-group" ng-if="endpointType === 'remote'">
|
||||
<div class="col-sm-12">
|
||||
<label for="tls" class="control-label text-left">
|
||||
TLS
|
||||
<portainer-tooltip position="bottom" message="Enable this option if you need to specify TLS certificates to connect to the Docker endpoint."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="endpoint.TLS"><i></i>
|
||||
</label>
|
||||
<!-- endpoint-security -->
|
||||
<div ng-if="endpointType === 'remote'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Security
|
||||
</div>
|
||||
<por-endpoint-security form-data="formValues.SecurityFormData" endpoint="endpoint"></por-endpoint-security>
|
||||
</div>
|
||||
<!-- !tls-checkbox -->
|
||||
<!-- tls-certs -->
|
||||
<div ng-if="endpoint.TLS">
|
||||
<!-- ca-input -->
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label text-left">TLS CA certificate</label>
|
||||
<div class="col-sm-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSCACert">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
<span ng-if="formValues.TLSCACert !== endpoint.TLSCACert">{{ formValues.TLSCACert.name }}</span>
|
||||
<i class="fa fa-check green-icon" ng-if="formValues.TLSCACert && formValues.TLSCACert === endpoint.TLSCACert" aria-hidden="true"></i>
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSCACert" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !ca-input -->
|
||||
<!-- cert-input -->
|
||||
<div class="form-group">
|
||||
<label for="tls_cert" class="col-sm-2 control-label text-left">TLS certificate</label>
|
||||
<div class="col-sm-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSCert">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
<span ng-if="formValues.TLSCert !== endpoint.TLSCert">{{ formValues.TLSCert.name }}</span>
|
||||
<i class="fa fa-check green-icon" ng-if="formValues.TLSCert && formValues.TLSCert === endpoint.TLSCert" aria-hidden="true"></i>
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSCert" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cert-input -->
|
||||
<!-- key-input -->
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label text-left">TLS key</label>
|
||||
<div class="col-sm-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSKey">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
<span ng-if="formValues.TLSKey !== endpoint.TLSKey">{{ formValues.TLSKey.name }}</span>
|
||||
<i class="fa fa-check green-icon" ng-if="formValues.TLSKey && formValues.TLSKey === endpoint.TLSKey" aria-hidden="true"></i>
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSKey" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !key-input -->
|
||||
</div>
|
||||
<!-- !tls-certs -->
|
||||
<!-- !endpoint-security -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!endpoint.Name || !endpoint.URL || (endpoint.TLS && (!formValues.TLSCACert || !formValues.TLSCert || !formValues.TLSKey))" ng-click="updateEndpoint()">Update endpoint</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !endpoint.Name || !endpoint.URL || (endpoint.TLS && ((endpoint.TLSVerify && !formValues.TLSCACert) || (endpoint.TLSClientCert && (!formValues.TLSCert || !formValues.TLSKey))))" ng-click="updateEndpoint()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Update endpoint</span>
|
||||
<span ng-show="state.actionInProgress">Updating endpoint...</span>
|
||||
</button>
|
||||
<a type="button" class="btn btn-default btn-sm" ui-sref="endpoints">Cancel</a>
|
||||
<i id="updateEndpointSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<span class="text-danger" ng-if="state.error" style="margin: 5px;">
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ state.error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,41 +1,49 @@
|
||||
angular.module('endpoint', [])
|
||||
.controller('EndpointController', ['$scope', '$state', '$stateParams', '$filter', 'EndpointService', 'Notifications',
|
||||
function ($scope, $state, $stateParams, $filter, EndpointService, Notifications) {
|
||||
.controller('EndpointController', ['$scope', '$state', '$transition$', '$filter', 'EndpointService', 'Notifications',
|
||||
function ($scope, $state, $transition$, $filter, EndpointService, Notifications) {
|
||||
|
||||
if (!$scope.applicationState.application.endpointManagement) {
|
||||
$state.go('endpoints');
|
||||
}
|
||||
|
||||
$scope.state = {
|
||||
error: '',
|
||||
uploadInProgress: false
|
||||
uploadInProgress: false,
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
TLSCACert: null,
|
||||
TLSCert: null,
|
||||
TLSKey: null
|
||||
SecurityFormData: new EndpointSecurityFormData()
|
||||
};
|
||||
|
||||
$scope.updateEndpoint = function() {
|
||||
var ID = $scope.endpoint.Id;
|
||||
var endpoint = $scope.endpoint;
|
||||
var securityData = $scope.formValues.SecurityFormData;
|
||||
var TLS = securityData.TLS;
|
||||
var TLSMode = securityData.TLSMode;
|
||||
var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only');
|
||||
var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only');
|
||||
|
||||
var endpointParams = {
|
||||
name: $scope.endpoint.Name,
|
||||
URL: $scope.endpoint.URL,
|
||||
PublicURL: $scope.endpoint.PublicURL,
|
||||
TLS: $scope.endpoint.TLS,
|
||||
TLSCACert: $scope.formValues.TLSCACert !== $scope.endpoint.TLSCACert ? $scope.formValues.TLSCACert : null,
|
||||
TLSCert: $scope.formValues.TLSCert !== $scope.endpoint.TLSCert ? $scope.formValues.TLSCert : null,
|
||||
TLSKey: $scope.formValues.TLSKey !== $scope.endpoint.TLSKey ? $scope.formValues.TLSKey : null,
|
||||
name: endpoint.Name,
|
||||
URL: endpoint.URL,
|
||||
PublicURL: endpoint.PublicURL,
|
||||
TLS: TLS,
|
||||
TLSSkipVerify: TLSSkipVerify,
|
||||
TLSSkipClientVerify: TLSSkipClientVerify,
|
||||
TLSCACert: TLSSkipVerify || securityData.TLSCACert === endpoint.TLSConfig.TLSCACert ? null : securityData.TLSCACert,
|
||||
TLSCert: TLSSkipClientVerify || securityData.TLSCert === endpoint.TLSConfig.TLSCert ? null : securityData.TLSCert,
|
||||
TLSKey: TLSSkipClientVerify || securityData.TLSKey === endpoint.TLSConfig.TLSKey ? null : securityData.TLSKey,
|
||||
type: $scope.endpointType
|
||||
};
|
||||
|
||||
EndpointService.updateEndpoint(ID, endpointParams)
|
||||
$scope.state.actionInProgress = true;
|
||||
EndpointService.updateEndpoint(endpoint.Id, endpointParams)
|
||||
.then(function success(data) {
|
||||
Notifications.success('Endpoint updated', $scope.endpoint.Name);
|
||||
$state.go('endpoints');
|
||||
}, function error(err) {
|
||||
$scope.state.error = err.msg;
|
||||
Notifications.error('Failure', err, 'Unable to update endpoint');
|
||||
$scope.state.actionInProgress = false;
|
||||
}, function update(evt) {
|
||||
if (evt.upload) {
|
||||
$scope.state.uploadInProgress = evt.upload;
|
||||
@@ -43,25 +51,22 @@ function ($scope, $state, $stateParams, $filter, EndpointService, Notifications)
|
||||
});
|
||||
};
|
||||
|
||||
function getEndpoint(endpointID) {
|
||||
$('#loadingViewSpinner').show();
|
||||
EndpointService.endpoint($stateParams.id).then(function success(data) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
$scope.endpoint = data;
|
||||
if (data.URL.indexOf('unix://') === 0) {
|
||||
function initView() {
|
||||
EndpointService.endpoint($transition$.params().id)
|
||||
.then(function success(data) {
|
||||
var endpoint = data;
|
||||
if (endpoint.URL.indexOf('unix://') === 0) {
|
||||
$scope.endpointType = 'local';
|
||||
} else {
|
||||
$scope.endpointType = 'remote';
|
||||
}
|
||||
$scope.endpoint.URL = $filter('stripprotocol')(data.URL);
|
||||
$scope.formValues.TLSCACert = data.TLSCACert;
|
||||
$scope.formValues.TLSCert = data.TLSCert;
|
||||
$scope.formValues.TLSKey = data.TLSKey;
|
||||
}, function error(err) {
|
||||
$('#loadingViewSpinner').hide();
|
||||
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
|
||||
$scope.endpoint = endpoint;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoint details');
|
||||
});
|
||||
}
|
||||
|
||||
getEndpoint($stateParams.id);
|
||||
initView();
|
||||
}]);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Endpoint access">
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-title title="Endpoint access"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="endpoints">Endpoints</a> > <a ui-sref="endpoint({id: endpoint.Id})">{{ endpoint.Name }}</a> > Access management
|
||||
</rd-header-content>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
angular.module('endpointAccess', [])
|
||||
.controller('EndpointAccessController', ['$scope', '$stateParams', 'EndpointService', 'Notifications',
|
||||
function ($scope, $stateParams, EndpointService, Notifications) {
|
||||
.controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'Notifications',
|
||||
function ($scope, $transition$, EndpointService, Notifications) {
|
||||
|
||||
$scope.updateAccess = function(authorizedUsers, authorizedTeams) {
|
||||
return EndpointService.updateAccess($stateParams.id, authorizedUsers, authorizedTeams);
|
||||
return EndpointService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams);
|
||||
};
|
||||
|
||||
function initView() {
|
||||
$('#loadingViewSpinner').show();
|
||||
EndpointService.endpoint($stateParams.id)
|
||||
EndpointService.endpoint($transition$.params().id)
|
||||
.then(function success(data) {
|
||||
$scope.endpoint = data;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoint details');
|
||||
})
|
||||
.finally(function final(){
|
||||
$('#loadingViewSpinner').hide();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
<div class="page-wrapper">
|
||||
<!-- simple box -->
|
||||
<div class="container simple-box">
|
||||
<div class="col-md-8 col-md-offset-2 col-sm-10 col-sm-offset-1">
|
||||
<!-- simple box logo -->
|
||||
<div class="row">
|
||||
<img ng-if="logo" ng-src="{{ logo }}" class="simple-box-logo">
|
||||
<img ng-if="!logo" src="images/logo_alt.png" class="simple-box-logo" alt="Portainer">
|
||||
</div>
|
||||
<!-- !simple box logo -->
|
||||
<!-- init-endpoint panel -->
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<!-- init-endpoint form -->
|
||||
<form class="form-horizontal" style="margin: 20px;" enctype="multipart/form-data" method="POST">
|
||||
<!-- comment -->
|
||||
<div class="form-group" style="text-align: center;">
|
||||
<h4>Connect Portainer to a Docker engine or Swarm cluster endpoint</h4>
|
||||
</div>
|
||||
<!-- !comment input -->
|
||||
<!-- endpoin-type radio -->
|
||||
<div class="form-group">
|
||||
<div class="radio">
|
||||
<label><input type="radio" name="endpointType" value="local" ng-model="formValues.endpointType" ng-click="resetErrorMessage()">Manage the Docker instance where Portainer is running</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label><input type="radio" name="endpointType" value="remote" ng-model="formValues.endpointType" ng-click="resetErrorMessage()">Manage a remote Docker instance</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- endpoint-type radio -->
|
||||
<!-- local-endpoint -->
|
||||
<div ng-if="formValues.endpointType === 'local'" style="margin-top: 25px;">
|
||||
<div class="form-group">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true" style="margin-right: 5px;"></i>
|
||||
<span class="small text-primary">This feature is not yet available for native Docker Windows containers.</span>
|
||||
<div class="small text-primary">On Linux and when using Docker for Mac or Docker for Windows or Docker Toolbox, ensure that you have started Portainer container with the following Docker flag <code>-v "/var/run/docker.sock:/var/run/docker.sock"</code></div>
|
||||
</div>
|
||||
<!-- connect button -->
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<div class="col-sm-12 controls">
|
||||
<p class="pull-left text-danger" ng-if="state.error" style="margin: 5px;">
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ state.error }}
|
||||
</p>
|
||||
<span class="pull-right">
|
||||
<i id="initEndpointSpinner" class="fa fa-cog fa-spin" style="margin-right: 5px; display: none;"></i>
|
||||
<button type="submit" class="btn btn-primary" ng-click="createLocalEndpoint()"><i class="fa fa-plug" aria-hidden="true"></i> Connect</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !connect button -->
|
||||
</div>
|
||||
<!-- !local-endpoint -->
|
||||
<!-- remote-endpoint -->
|
||||
<div ng-if="formValues.endpointType === 'remote'" style="margin-top: 25px;">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-4 col-lg-3 control-label text-left">Name</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<input type="text" class="form-control" id="container_name" ng-model="formValues.Name" placeholder="e.g. docker-prod01">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- endpoint-url-input -->
|
||||
<div class="form-group">
|
||||
<label for="endpoint_url" class="col-sm-4 col-lg-3 control-label text-left">
|
||||
Endpoint URL
|
||||
<portainer-tooltip position="bottom" message="URL or IP address of a Docker host. The Docker API must be exposed over a TCP port. Please refer to the Docker documentation to configure it."></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<input type="text" class="form-control" id="endpoint_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:2375 or mydocker.mydomain.com:2375">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !endpoint-url-input -->
|
||||
<!-- tls-checkbox -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="tls" class="control-label text-left">
|
||||
TLS
|
||||
<portainer-tooltip position="bottom" message="Enable this option if you need to specify TLS certificates to connect to the Docker endpoint."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="formValues.TLS"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tls-checkbox -->
|
||||
<!-- tls-certs -->
|
||||
<div ng-if="formValues.TLS">
|
||||
<!-- ca-input -->
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label text-left">TLS CA certificate</label>
|
||||
<div class="col-sm-9">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSCACert">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ formValues.TLSCACert.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSCACert" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !ca-input -->
|
||||
<!-- cert-input -->
|
||||
<div class="form-group">
|
||||
<label for="tls_cert" class="col-sm-3 control-label text-left">TLS certificate</label>
|
||||
<div class="col-sm-9">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSCert">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ formValues.TLSCert.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSCert" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cert-input -->
|
||||
<!-- key-input -->
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 control-label text-left">TLS key</label>
|
||||
<div class="col-sm-9">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSKey">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ formValues.TLSKey.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSKey" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !key-input -->
|
||||
</div>
|
||||
<!-- !tls-certs -->
|
||||
<!-- connect button -->
|
||||
<div class="form-group" style="margin-top: 10px;">
|
||||
<div class="col-sm-12 controls">
|
||||
<p class="pull-left text-danger" ng-if="state.error" style="margin: 5px;">
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ state.error }}
|
||||
</p>
|
||||
<span class="pull-right">
|
||||
<i id="initEndpointSpinner" class="fa fa-cog fa-spin" style="margin-right: 5px; display: none;"></i>
|
||||
<button type="submit" class="btn btn-primary" ng-disabled="!formValues.Name || !formValues.URL || (formValues.TLS && (!formValues.TLSCACert || !formValues.TLSCert || !formValues.TLSKey))" ng-click="createRemoteEndpoint()"><i class="fa fa-plug" aria-hidden="true"></i> Connect</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !connect button -->
|
||||
</div>
|
||||
<!-- !remote-endpoint -->
|
||||
</form>
|
||||
<!-- !init-endpoint form -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !init-endpoint panel -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !simple box -->
|
||||
</div>
|
||||
@@ -1,91 +0,0 @@
|
||||
angular.module('endpointInit', [])
|
||||
.controller('EndpointInitController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications',
|
||||
function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notifications) {
|
||||
$scope.state = {
|
||||
error: '',
|
||||
uploadInProgress: false
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
endpointType: 'remote',
|
||||
Name: '',
|
||||
URL: '',
|
||||
TLS: false,
|
||||
TLSCACert: null,
|
||||
TLSCert: null,
|
||||
TLSKey: null
|
||||
};
|
||||
|
||||
if (!_.isEmpty($scope.applicationState.endpoint)) {
|
||||
$state.go('dashboard');
|
||||
}
|
||||
|
||||
$scope.resetErrorMessage = function() {
|
||||
$scope.state.error = '';
|
||||
};
|
||||
|
||||
function showErrorMessage(message) {
|
||||
$scope.state.uploadInProgress = false;
|
||||
$scope.state.error = message;
|
||||
}
|
||||
|
||||
function updateEndpointState(endpointID) {
|
||||
EndpointProvider.setEndpointID(endpointID);
|
||||
StateManager.updateEndpointState(false)
|
||||
.then(function success(data) {
|
||||
$state.go('dashboard');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
EndpointService.deleteEndpoint(endpointID)
|
||||
.then(function success() {
|
||||
showErrorMessage('Unable to connect to the Docker endpoint');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$scope.createLocalEndpoint = function() {
|
||||
$('#initEndpointSpinner').show();
|
||||
$scope.state.error = '';
|
||||
var name = 'local';
|
||||
var URL = 'unix:///var/run/docker.sock';
|
||||
var TLS = false;
|
||||
|
||||
EndpointService.createLocalEndpoint(name, URL, TLS, true)
|
||||
.then(function success(data) {
|
||||
var endpointID = data.Id;
|
||||
updateEndpointState(data.Id);
|
||||
}, function error() {
|
||||
$scope.state.error = 'Unable to create endpoint';
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#initEndpointSpinner').hide();
|
||||
});
|
||||
};
|
||||
|
||||
$scope.createRemoteEndpoint = function() {
|
||||
$('#initEndpointSpinner').show();
|
||||
$scope.state.error = '';
|
||||
var name = $scope.formValues.Name;
|
||||
var URL = $scope.formValues.URL;
|
||||
var PublicURL = URL.split(':')[0];
|
||||
var TLS = $scope.formValues.TLS;
|
||||
var TLSCAFile = $scope.formValues.TLSCACert;
|
||||
var TLSCertFile = $scope.formValues.TLSCert;
|
||||
var TLSKeyFile = $scope.formValues.TLSKey;
|
||||
|
||||
EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile)
|
||||
.then(function success(data) {
|
||||
var endpointID = data.Id;
|
||||
updateEndpointState(endpointID);
|
||||
}, function error(err) {
|
||||
showErrorMessage(err.msg);
|
||||
}, function update(evt) {
|
||||
if (evt.upload) {
|
||||
$scope.state.uploadInProgress = evt.upload;
|
||||
}
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#initEndpointSpinner').hide();
|
||||
});
|
||||
};
|
||||
}]);
|
||||
@@ -3,7 +3,6 @@
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="endpoints" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
<i id="loadEndpointsSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Endpoint management</rd-header-content>
|
||||
</rd-header>
|
||||
@@ -60,75 +59,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- !endpoint-public-url-input -->
|
||||
<!-- tls-checkbox -->
|
||||
<!-- endpoint-security -->
|
||||
<por-endpoint-security form-data="formValues.SecurityFormData"></por-endpoint-security>
|
||||
<!-- !endpoint-security -->
|
||||
<!-- actions -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="tls" class="control-label text-left">
|
||||
TLS
|
||||
<portainer-tooltip position="bottom" message="Enable this option if you need to specify TLS certificates to connect to the Docker endpoint."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="formValues.TLS"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Name || !formValues.URL || (formValues.TLS && ((formValues.TLSVerify && !formValues.TLSCACert) || (formValues.TLSClientCert && (!formValues.TLSCert || !formValues.TLSKey))))" ng-click="addEndpoint()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Add endpoint</span>
|
||||
<span ng-show="state.actionInProgress">Creating endpoint...</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<!-- !tls-checkbox -->
|
||||
<!-- tls-certs -->
|
||||
<div ng-if="formValues.TLS">
|
||||
<!-- ca-input -->
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label text-left">TLS CA certificate</label>
|
||||
<div class="col-sm-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSCACert">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ formValues.TLSCACert.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSCACert" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !ca-input -->
|
||||
<!-- cert-input -->
|
||||
<div class="form-group">
|
||||
<label for="tls_cert" class="col-sm-2 control-label text-left">TLS certificate</label>
|
||||
<div class="col-sm-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSCert">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ formValues.TLSCert.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSCert" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cert-input -->
|
||||
<!-- key-input -->
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label text-left">TLS key</label>
|
||||
<div class="col-sm-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSKey">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ formValues.TLSKey.name }}
|
||||
<i class="fa fa-times red-icon" ng-if="!formValues.TLSKey" aria-hidden="true"></i>
|
||||
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !key-input -->
|
||||
</div>
|
||||
<!-- !tls-certs -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name || !formValues.URL || (formValues.TLS && (!formValues.TLSCACert || !formValues.TLSCert || !formValues.TLSKey))" ng-click="addEndpoint()">Add endpoint</button>
|
||||
<i id="createEndpointSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
|
||||
<span class="text-danger" ng-if="state.error" style="margin: 5px;">
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ state.error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -191,7 +139,7 @@
|
||||
<span ng-if="applicationState.application.authentication">
|
||||
<a ui-sref="endpoint.access({id: endpoint.Id})"><i class="fa fa-users" aria-hidden="true" style="margin-left: 7px;"></i> Manage access</a>
|
||||
</span>
|
||||
</td>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!endpoints">
|
||||
<td colspan="5" class="text-center text-muted">Loading...</td>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
angular.module('endpoints', [])
|
||||
.controller('EndpointsController', ['$scope', '$state', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination',
|
||||
function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagination) {
|
||||
.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'EndpointProvider', 'Notifications', 'Pagination',
|
||||
function ($scope, $state, $filter, EndpointService, EndpointProvider, Notifications, Pagination) {
|
||||
$scope.state = {
|
||||
error: '',
|
||||
uploadInProgress: false,
|
||||
selectedItemCount: 0,
|
||||
pagination_count: Pagination.getPaginationCount('endpoints')
|
||||
pagination_count: Pagination.getPaginationCount('endpoints'),
|
||||
actionInProgress: false
|
||||
};
|
||||
$scope.sortType = 'Name';
|
||||
$scope.sortReverse = true;
|
||||
@@ -14,10 +14,7 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi
|
||||
Name: '',
|
||||
URL: '',
|
||||
PublicURL: '',
|
||||
TLS: false,
|
||||
TLSCACert: null,
|
||||
TLSCert: null,
|
||||
TLSKey: null
|
||||
SecurityFormData: new EndpointSecurityFormData()
|
||||
};
|
||||
|
||||
$scope.order = function(sortType) {
|
||||
@@ -47,23 +44,30 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi
|
||||
};
|
||||
|
||||
$scope.addEndpoint = function() {
|
||||
$scope.state.error = '';
|
||||
var name = $scope.formValues.Name;
|
||||
var URL = $scope.formValues.URL;
|
||||
var URL = $filter('stripprotocol')($scope.formValues.URL);
|
||||
var PublicURL = $scope.formValues.PublicURL;
|
||||
if (PublicURL === '') {
|
||||
PublicURL = URL.split(':')[0];
|
||||
}
|
||||
var TLS = $scope.formValues.TLS;
|
||||
var TLSCAFile = $scope.formValues.TLSCACert;
|
||||
var TLSCertFile = $scope.formValues.TLSCert;
|
||||
var TLSKeyFile = $scope.formValues.TLSKey;
|
||||
EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSCAFile, TLSCertFile, TLSKeyFile, false).then(function success(data) {
|
||||
|
||||
var securityData = $scope.formValues.SecurityFormData;
|
||||
var TLS = securityData.TLS;
|
||||
var TLSMode = securityData.TLSMode;
|
||||
var TLSSkipVerify = TLS && (TLSMode === 'tls_client_noca' || TLSMode === 'tls_only');
|
||||
var TLSSkipClientVerify = TLS && (TLSMode === 'tls_ca' || TLSMode === 'tls_only');
|
||||
var TLSCAFile = TLSSkipVerify ? null : securityData.TLSCACert;
|
||||
var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert;
|
||||
var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey;
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile).then(function success(data) {
|
||||
Notifications.success('Endpoint created', name);
|
||||
$state.reload();
|
||||
}, function error(err) {
|
||||
$scope.state.uploadInProgress = false;
|
||||
$scope.state.error = err.msg;
|
||||
$scope.state.actionInProgress = false;
|
||||
Notifications.error('Failure', err, 'Unable to create endpoint');
|
||||
}, function update(evt) {
|
||||
if (evt.upload) {
|
||||
$scope.state.uploadInProgress = evt.upload;
|
||||
@@ -72,32 +76,20 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi
|
||||
};
|
||||
|
||||
$scope.removeAction = function () {
|
||||
$('#loadEndpointsSpinner').show();
|
||||
var counter = 0;
|
||||
var complete = function () {
|
||||
counter = counter - 1;
|
||||
if (counter === 0) {
|
||||
$('#loadEndpointsSpinner').hide();
|
||||
}
|
||||
};
|
||||
angular.forEach($scope.endpoints, function (endpoint) {
|
||||
if (endpoint.Checked) {
|
||||
counter = counter + 1;
|
||||
EndpointService.deleteEndpoint(endpoint.Id).then(function success(data) {
|
||||
Notifications.success('Endpoint deleted', endpoint.Name);
|
||||
var index = $scope.endpoints.indexOf(endpoint);
|
||||
$scope.endpoints.splice(index, 1);
|
||||
complete();
|
||||
}, function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove endpoint');
|
||||
complete();
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function fetchEndpoints() {
|
||||
$('#loadEndpointsSpinner').show();
|
||||
EndpointService.endpoints()
|
||||
.then(function success(data) {
|
||||
$scope.endpoints = data;
|
||||
@@ -105,9 +97,6 @@ function ($scope, $state, EndpointService, EndpointProvider, Notifications, Pagi
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
|
||||
$scope.endpoints = [];
|
||||
})
|
||||
.finally(function final() {
|
||||
$('#loadEndpointsSpinner').hide();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<rd-header>
|
||||
<rd-header-title title="Engine overview">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="docker" ui-sref-opts="{reload: true}">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="engine" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i>
|
||||
</a>
|
||||
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
|
||||
</rd-header-title>
|
||||
<rd-header-content>Docker</rd-header-content>
|
||||
</rd-header>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user