From 2327d696e088d40232b105b4b3eff395d6566b4a Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 6 May 2018 09:15:57 +0200 Subject: [PATCH] feat(agent): add agent support (#1828) --- api/bolt/migrate_dbversion9.go | 20 + api/bolt/migrator.go | 8 + api/cli/cli.go | 1 + api/cli/defaults.go | 1 + api/cli/defaults_windows.go | 1 + api/cmd/portainer/main.go | 68 +++- api/crypto/ecdsa.go | 125 +++++++ api/crypto/tls.go | 4 +- api/exec/stack_manager.go | 52 ++- api/filesystem/filesystem.go | 109 ++++++ api/http/client/client.go | 77 ++++ api/http/handler/endpoint.go | 48 +-- api/http/handler/websocket.go | 343 +++++++++++------- api/http/proxy/access_control.go | 9 +- api/http/proxy/configs.go | 4 +- api/http/proxy/containers.go | 4 +- api/http/proxy/factory.go | 21 +- api/http/proxy/manager.go | 48 ++- api/http/proxy/networks.go | 4 +- api/http/proxy/secrets.go | 4 +- api/http/proxy/services.go | 4 +- api/http/proxy/tasks.go | 2 +- api/http/proxy/transport.go | 16 +- api/http/proxy/volumes.go | 4 +- api/http/server.go | 12 +- api/portainer.go | 39 +- api/swagger.yaml | 2 +- app/__module.js | 1 + app/agent/_module.js | 1 + .../components/node-selector/node-selector.js | 7 + .../node-selector/nodeSelector.html | 8 + .../node-selector/nodeSelectorController.js | 18 + app/agent/models/agent.js | 5 + app/agent/rest/agent.js | 10 + app/agent/services/agentService.js | 24 ++ app/app.js | 7 +- app/config.js | 11 + app/docker/__module.js | 13 +- .../dashboard-cluster-agent-info.js | 4 + .../dashboardClusterAgentInfo.html | 20 + .../dashboardClusterAgentInfoController.js | 16 + .../containerNetworksDatatable.html | 2 +- .../containerNetworksDatatable.js | 3 +- .../containersDatatable.html | 22 +- .../containersDatatable.js | 1 + .../images-datatable/imagesDatatable.html | 14 +- .../images-datatable/imagesDatatable.js | 1 + .../networks-datatable/networksDatatable.html | 14 +- .../networks-datatable/networksDatatable.js | 1 + .../tasks-datatable/tasksDatatable.html | 14 +- .../tasks-datatable/tasksDatatable.js | 3 +- .../volumes-datatable/volumesDatatable.html | 14 +- .../volumes-datatable/volumesDatatable.js | 1 + app/docker/helpers/infoHelper.js | 12 +- app/docker/helpers/taskHelper.js | 18 + app/docker/models/container.js | 3 + app/docker/models/image.js | 4 + app/docker/models/network.js | 3 + app/docker/models/task.js | 3 + app/docker/models/volume.js | 3 + app/docker/rest/container.js | 34 +- app/docker/rest/exec.js | 3 +- app/docker/rest/image.js | 3 +- app/docker/rest/network.js | 28 +- app/docker/rest/service.js | 3 +- app/docker/rest/system.js | 5 +- app/docker/services/containerService.js | 62 ++-- app/docker/services/execService.js | 2 +- app/docker/services/networkService.js | 10 +- .../console/containerConsoleController.js | 49 +-- app/docker/views/containers/containers.html | 1 + .../views/containers/containersController.js | 6 +- .../create/createContainerController.js | 27 +- .../containers/create/createcontainer.html | 10 + .../views/containers/edit/container.html | 19 +- .../containers/edit/containerController.js | 224 +++++------- .../inspect/containerInspectController.js | 5 +- .../logs/containerLogsController.js | 5 +- .../stats/containerStatsController.js | 5 +- app/docker/views/dashboard/dashboard.html | 8 +- .../images/build/buildImageController.js | 10 +- app/docker/views/images/build/buildimage.html | 10 + .../views/images/edit/imageController.js | 5 +- app/docker/views/images/images.html | 11 + app/docker/views/images/imagesController.js | 11 +- .../create/createNetworkController.js | 10 +- .../views/networks/create/createnetwork.html | 10 + app/docker/views/networks/edit/network.html | 4 +- .../views/networks/edit/networkController.js | 42 +-- app/docker/views/networks/networks.html | 1 + .../views/networks/networksController.js | 5 +- .../views/services/edit/includes/tasks.html | 1 + .../views/services/edit/serviceController.js | 20 +- app/docker/views/stacks/edit/stack.html | 1 + .../views/stacks/edit/stackController.js | 18 +- .../volumes/create/createVolumeController.js | 12 +- .../views/volumes/create/createvolume.html | 12 +- app/docker/views/volumes/edit/volume.html | 2 +- .../views/volumes/edit/volumeController.js | 5 +- app/docker/views/volumes/volumes.html | 1 + app/docker/views/volumes/volumesController.js | 5 +- app/portainer/__module.js | 12 + .../endpointsDatatable.html | 3 + .../forms/group-form/groupForm.html | 2 +- app/portainer/services/httpRequestHelper.js | 28 ++ app/portainer/services/stateManager.js | 8 +- app/portainer/views/auth/authController.js | 2 +- .../create/createEndpointController.js | 71 ++++ .../endpoints/create/createendpoint.html | 141 +++++++ app/portainer/views/endpoints/endpoints.html | 68 +--- .../views/endpoints/endpointsController.js | 48 +-- .../views/init/admin/initAdminController.js | 2 +- .../views/init/endpoint/initEndpoint.html | 105 +++++- .../init/endpoint/initEndpointController.js | 36 +- .../views/sidebar/sidebarController.js | 37 +- assets/css/app.css | 10 +- 116 files changed, 1900 insertions(+), 689 deletions(-) create mode 100644 api/bolt/migrate_dbversion9.go create mode 100644 api/crypto/ecdsa.go create mode 100644 api/http/client/client.go create mode 100644 app/agent/_module.js create mode 100644 app/agent/components/node-selector/node-selector.js create mode 100644 app/agent/components/node-selector/nodeSelector.html create mode 100644 app/agent/components/node-selector/nodeSelectorController.js create mode 100644 app/agent/models/agent.js create mode 100644 app/agent/rest/agent.js create mode 100644 app/agent/services/agentService.js create mode 100644 app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js create mode 100644 app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html create mode 100644 app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js create mode 100644 app/docker/helpers/taskHelper.js create mode 100644 app/portainer/views/endpoints/create/createEndpointController.js create mode 100644 app/portainer/views/endpoints/create/createendpoint.html diff --git a/api/bolt/migrate_dbversion9.go b/api/bolt/migrate_dbversion9.go new file mode 100644 index 000000000..1882f55c7 --- /dev/null +++ b/api/bolt/migrate_dbversion9.go @@ -0,0 +1,20 @@ +package bolt + +import "github.com/portainer/portainer" + +func (m *Migrator) updateEndpointsToVersion10() error { + legacyEndpoints, err := m.EndpointService.Endpoints() + if err != nil { + return err + } + + for _, endpoint := range legacyEndpoints { + endpoint.Type = portainer.DockerEnvironment + err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator.go b/api/bolt/migrator.go index 1975f1e67..66566ae8d 100644 --- a/api/bolt/migrator.go +++ b/api/bolt/migrator.go @@ -104,6 +104,14 @@ func (m *Migrator) Migrate() error { } } + // https://github.com/portainer/portainer/issues/461 + if m.CurrentDBVersion < 10 { + err := m.updateEndpointsToVersion10() + if err != nil { + return err + } + } + err := m.VersionService.StoreDBVersion(portainer.DBVersion) if err != nil { return err diff --git a/api/cli/cli.go b/api/cli/cli.go index db0d80603..f2307718b 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -38,6 +38,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { NoAuth: kingpin.Flag("no-auth", "Disable authentication").Default(defaultNoAuth).Bool(), NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app").Default(defaultNoAnalytics).Bool(), TLSVerify: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLSVerify).Bool(), + TLSSkipVerify: kingpin.Flag("tlsskipverify", "Disable TLS server verification").Default(defaultTLSSkipVerify).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(), TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 2d350c1c7..b2e40c969 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -9,6 +9,7 @@ const ( defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" + defaultTLSSkipVerify = "false" defaultTLSCACertPath = "/certs/ca.pem" defaultTLSCertPath = "/certs/cert.pem" defaultTLSKeyPath = "/certs/key.pem" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index a94657258..6fead127e 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -7,6 +7,7 @@ const ( defaultNoAuth = "false" defaultNoAnalytics = "false" defaultTLSVerify = "false" + defaultTLSSkipVerify = "false" defaultTLSCACertPath = "C:\\certs\\ca.pem" defaultTLSCertPath = "C:\\certs\\cert.pem" defaultTLSKeyPath = "C:\\certs\\key.pem" diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 03260b7db..0e30cf276 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -10,6 +10,7 @@ import ( "github.com/portainer/portainer/filesystem" "github.com/portainer/portainer/git" "github.com/portainer/portainer/http" + "github.com/portainer/portainer/http/client" "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" @@ -61,8 +62,8 @@ func initStore(dataStorePath string) *bolt.Store { return store } -func initStackManager(assetsPath string) portainer.StackManager { - return exec.NewStackManager(assetsPath) +func initStackManager(assetsPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.StackManager, error) { + return exec.NewStackManager(assetsPath, signatureService, fileService) } func initJWTService(authenticationEnabled bool) portainer.JWTService { @@ -76,6 +77,10 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService { return nil } +func initDigitalSignatureService() portainer.DigitalSignatureService { + return &crypto.ECDSAService{} +} + func initCryptoService() portainer.CryptoService { return &crypto.Service{} } @@ -173,6 +178,35 @@ func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService return &endpoints[0] } +func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + private, public, err := fileService.LoadKeyPair() + if err != nil { + return err + } + return signatureService.ParseKeyPair(private, public) +} + +func generateAndStoreKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + private, public, err := signatureService.GenerateKeyPair() + if err != nil { + return err + } + privateHeader, publicHeader := signatureService.PEMHeaders() + return fileService.StoreKeyPair(private, public, privateHeader, publicHeader) +} + +func initKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { + existingKeyPair, err := fileService.KeyPairFilesExist() + if err != nil { + log.Fatal(err) + } + + if existingKeyPair { + return loadAndParseKeyPair(fileService, signatureService) + } + return generateAndStoreKeyPair(fileService, signatureService) +} + func main() { flags := initCLI() @@ -181,19 +215,29 @@ func main() { store := initStore(*flags.Data) defer store.Close() - stackManager := initStackManager(*flags.Assets) - jwtService := initJWTService(!*flags.NoAuth) cryptoService := initCryptoService() + digitalSignatureService := initDigitalSignatureService() + ldapService := initLDAPService() gitService := initGitService() authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) - err := initSettings(store.SettingsService, flags) + err := initKeyPair(fileService, digitalSignatureService) + if err != nil { + log.Fatal(err) + } + + stackManager, err := initStackManager(*flags.Assets, digitalSignatureService, fileService) + if err != nil { + log.Fatal(err) + } + + err = initSettings(store.SettingsService, flags) if err != nil { log.Fatal(err) } @@ -214,9 +258,10 @@ func main() { endpoint := &portainer.Endpoint{ Name: "primary", URL: *flags.Endpoint, + Type: portainer.DockerEnvironment, TLSConfig: portainer.TLSConfiguration{ TLS: *flags.TLSVerify, - TLSSkipVerify: false, + TLSSkipVerify: *flags.TLSSkipVerify, TLSCACertPath: *flags.TLSCacert, TLSCertPath: *flags.TLSCert, TLSKeyPath: *flags.TLSKey, @@ -225,6 +270,16 @@ func main() { AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, } + + agentOnDockerEnvironment, err := client.ExecutePingOperationFromEndpoint(endpoint) + if err != nil { + log.Fatal(err) + } + + if agentOnDockerEnvironment { + endpoint.Type = portainer.AgentOnDockerEnvironment + } + err = store.EndpointService.CreateEndpoint(endpoint) if err != nil { log.Fatal(err) @@ -292,6 +347,7 @@ func main() { FileService: fileService, LDAPService: ldapService, GitService: gitService, + SignatureService: digitalSignatureService, SSL: *flags.SSL, SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go new file mode 100644 index 000000000..1b453dd3e --- /dev/null +++ b/api/crypto/ecdsa.go @@ -0,0 +1,125 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/md5" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "math/big" +) + +const ( + // PrivateKeyPemHeader represents the header that is appended to the PEM file when + // storing the private key. + PrivateKeyPemHeader = "EC PRIVATE KEY" + // PublicKeyPemHeader represents the header that is appended to the PEM file when + // storing the public key. + PublicKeyPemHeader = "ECDSA PUBLIC KEY" +) + +// ECDSAService is a service used to create digital signatures when communicating with +// an agent based environment. It will automatically generates a key pair using ECDSA or +// can also reuse an existing ECDSA key pair. +type ECDSAService struct { + privateKey *ecdsa.PrivateKey + publicKey *ecdsa.PublicKey + encodedPubKey string +} + +// EncodedPublicKey returns the encoded version of the public that can be used +// to be shared with other services. It's the hexadecimal encoding of the public key +// content. +func (service *ECDSAService) EncodedPublicKey() string { + return service.encodedPubKey +} + +// PEMHeaders returns the ECDSA PEM headers. +func (service *ECDSAService) PEMHeaders() (string, string) { + return PrivateKeyPemHeader, PublicKeyPemHeader +} + +// ParseKeyPair parses existing private/public key pair content and associate +// the parsed keys to the service. +func (service *ECDSAService) ParseKeyPair(private, public []byte) error { + privateKey, err := x509.ParseECPrivateKey(private) + if err != nil { + return err + } + + service.privateKey = privateKey + + encodedKey := hex.EncodeToString(public) + service.encodedPubKey = encodedKey + + publicKey, err := x509.ParsePKIXPublicKey(public) + if err != nil { + return err + } + + service.publicKey = publicKey.(*ecdsa.PublicKey) + + return nil +} + +// GenerateKeyPair will create a new key pair using ECDSA. +func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) { + pubkeyCurve := elliptic.P256() + + privatekey, err := ecdsa.GenerateKey(pubkeyCurve, rand.Reader) + if err != nil { + return nil, nil, err + } + + service.privateKey = privatekey + service.publicKey = &privatekey.PublicKey + + private, err := x509.MarshalECPrivateKey(service.privateKey) + if err != nil { + return nil, nil, err + } + + public, err := x509.MarshalPKIXPublicKey(service.publicKey) + if err != nil { + return nil, nil, err + } + + encodedKey := hex.EncodeToString(public) + service.encodedPubKey = encodedKey + + return private, public, nil +} + +// Sign creates a signature from a message. +// It automatically hash the message using MD5 and creates a signature from +// that hash. +// It then encodes the generated signature in base64. +func (service *ECDSAService) Sign(message string) (string, error) { + digest := md5.New() + digest.Write([]byte(message)) + hash := digest.Sum(nil) + + r := big.NewInt(0) + s := big.NewInt(0) + + r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash) + if err != nil { + return "", err + } + + keyBytes := service.privateKey.Params().BitSize / 8 + + rBytes := r.Bytes() + rBytesPadded := make([]byte, keyBytes) + copy(rBytesPadded[keyBytes-len(rBytes):], rBytes) + + sBytes := s.Bytes() + sBytesPadded := make([]byte, keyBytes) + copy(sBytesPadded[keyBytes-len(sBytes):], sBytes) + + signature := append(rBytesPadded, sBytesPadded...) + + return base64.RawStdEncoding.EncodeToString(signature), nil +} diff --git a/api/crypto/tls.go b/api/crypto/tls.go index 83cd6aa7f..ae6bac867 100644 --- a/api/crypto/tls.go +++ b/api/crypto/tls.go @@ -33,7 +33,7 @@ func CreateTLSConfig(caCert, cert, key []byte, skipClientVerification, skipServe func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, error) { TLSConfig := &tls.Config{} - if config.TLSCertPath != "" && config.TLSKeyPath != "" { + if config.TLS && config.TLSCertPath != "" && config.TLSKeyPath != "" { cert, err := tls.LoadX509KeyPair(config.TLSCertPath, config.TLSKeyPath) if err != nil { return nil, err @@ -42,7 +42,7 @@ func CreateTLSConfiguration(config *portainer.TLSConfiguration) (*tls.Config, er TLSConfig.Certificates = []tls.Certificate{cert} } - if !config.TLSSkipVerify { + if config.TLS && !config.TLSSkipVerify { caCert, err := ioutil.ReadFile(config.TLSCACertPath) if err != nil { return nil, err diff --git a/api/exec/stack_manager.go b/api/exec/stack_manager.go index b7cf6df86..232ad68b5 100644 --- a/api/exec/stack_manager.go +++ b/api/exec/stack_manager.go @@ -12,14 +12,34 @@ import ( // StackManager represents a service for managing stacks. type StackManager struct { - binaryPath string + binaryPath string + signatureService portainer.DigitalSignatureService + fileService portainer.FileService +} + +type dockerCLIConfiguration struct { + HTTPHeaders struct { + ManagerOperationHeader string `json:"X-PortainerAgent-ManagerOperation"` + SignatureHeader string `json:"X-PortainerAgent-Signature"` + PublicKey string `json:"X-PortainerAgent-PublicKey"` + } `json:"HttpHeaders"` } // NewStackManager initializes a new StackManager service. -func NewStackManager(binaryPath string) *StackManager { - return &StackManager{ - binaryPath: binaryPath, +// It also updates the configuration of the Docker CLI binary. +func NewStackManager(binaryPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*StackManager, error) { + manager := &StackManager{ + binaryPath: binaryPath, + signatureService: signatureService, + fileService: fileService, } + + err := manager.updateDockerCLIConfiguration(binaryPath) + if err != nil { + return nil, err + } + + return manager, nil } // Login executes the docker login command against a list of registries (including DockerHub). @@ -99,9 +119,12 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint } args := make([]string, 0) + args = append(args, "--config", binaryPath) args = append(args, "-H", endpoint.URL) - if endpoint.TLSConfig.TLS { + if !endpoint.TLSConfig.TLS && endpoint.TLSConfig.TLSSkipVerify { + args = append(args, "--tls") + } else if endpoint.TLSConfig.TLS { args = append(args, "--tls") if !endpoint.TLSConfig.TLSSkipVerify { @@ -115,3 +138,22 @@ func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint return command, args } + +func (manager *StackManager) updateDockerCLIConfiguration(binaryPath string) error { + config := dockerCLIConfiguration{} + config.HTTPHeaders.ManagerOperationHeader = "1" + + signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage) + if err != nil { + return err + } + config.HTTPHeaders.SignatureHeader = signature + config.HTTPHeaders.PublicKey = manager.signatureService.EncodedPublicKey() + + err = manager.fileService.WriteJSONToFile(path.Join(binaryPath, "config.json"), config) + if err != nil { + return err + } + + return nil +} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 1f59fd0c8..770b8af76 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -2,6 +2,8 @@ package filesystem import ( "bytes" + "encoding/json" + "encoding/pem" "io/ioutil" "github.com/portainer/portainer" @@ -26,6 +28,10 @@ const ( ComposeStorePath = "compose" // ComposeFileDefaultName represents the default name of a compose file. ComposeFileDefaultName = "docker-compose.yml" + // PrivateKeyFile represents the name on disk of the file containing the private key. + PrivateKeyFile = "portainer.key" + // PublicKeyFile represents the name on disk of the file containing the public key. + PublicKeyFile = "portainer.pub" ) // Service represents a service for managing files and directories. @@ -198,6 +204,69 @@ func (service *Service) GetFileContent(filePath string) (string, error) { return string(content), nil } +// WriteJSONToFile writes JSON to the specified file. +func (service *Service) WriteJSONToFile(path string, content interface{}) error { + jsonContent, err := json.Marshal(content) + if err != nil { + return err + } + + return ioutil.WriteFile(path, jsonContent, 0644) +} + +// KeyPairFilesExist checks for the existence of the key files. +func (service *Service) KeyPairFilesExist() (bool, error) { + privateKeyPath := path.Join(service.dataStorePath, PrivateKeyFile) + exists, err := fileExists(privateKeyPath) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + + publicKeyPath := path.Join(service.dataStorePath, PublicKeyFile) + exists, err = fileExists(publicKeyPath) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + + return true, nil +} + +// StoreKeyPair store the specified keys content as PEM files on disk. +func (service *Service) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error { + err := service.createPEMFileInStore(private, privatePEMHeader, PrivateKeyFile) + if err != nil { + return err + } + + err = service.createPEMFileInStore(public, publicPEMHeader, PublicKeyFile) + if err != nil { + return err + } + + return nil +} + +// LoadKeyPair retrieve the content of both key files on disk. +func (service *Service) LoadKeyPair() ([]byte, []byte, error) { + privateKey, err := service.getContentFromPEMFile(PrivateKeyFile) + if err != nil { + return nil, nil, err + } + + publicKey, err := service.getContentFromPEMFile(PublicKeyFile) + if err != nil { + return nil, nil, err + } + + return privateKey, publicKey, nil +} + // createDirectoryInStore creates a new directory in the file store func (service *Service) createDirectoryInStore(name string) error { path := path.Join(service.fileStorePath, name) @@ -221,3 +290,43 @@ func (service *Service) createFileInStore(filePath string, r io.Reader) error { return nil } + +func (service *Service) createPEMFileInStore(content []byte, fileType, filePath string) error { + path := path.Join(service.fileStorePath, filePath) + block := &pem.Block{Type: fileType, Bytes: content} + + out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer out.Close() + + err = pem.Encode(out, block) + if err != nil { + return err + } + + return nil +} + +func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { + path := path.Join(service.fileStorePath, filePath) + + fileContent, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(fileContent) + return block.Bytes, nil +} + +func fileExists(filePath string) (bool, error) { + if _, err := os.Stat(filePath); err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/api/http/client/client.go b/api/http/client/client.go new file mode 100644 index 000000000..b9e3f318b --- /dev/null +++ b/api/http/client/client.go @@ -0,0 +1,77 @@ +package client + +import ( + "crypto/tls" + "net/http" + "strings" + "time" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" +) + +// ExecutePingOperationFromEndpoint will send a SystemPing operation HTTP request to a Docker environment +// using the specified endpoint configuration. It is used exclusively when +// specifying an endpoint from the CLI via the -H flag. +func ExecutePingOperationFromEndpoint(endpoint *portainer.Endpoint) (bool, error) { + if strings.HasPrefix(endpoint.URL, "unix://") { + return false, nil + } + + transport := &http.Transport{} + + scheme := "http" + + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) + if err != nil { + return false, err + } + scheme = "https" + transport.TLSClientConfig = tlsConfig + } + + client := &http.Client{ + Timeout: time.Second * 3, + Transport: transport, + } + + target := strings.Replace(endpoint.URL, "tcp://", scheme+"://", 1) + return pingOperation(client, target) +} + +// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment +// using the specified host and optional TLS configuration. +func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) { + transport := &http.Transport{} + + scheme := "http" + if tlsConfig != nil { + transport.TLSClientConfig = tlsConfig + scheme = "https" + } + + client := &http.Client{ + Timeout: time.Second * 3, + Transport: transport, + } + + target := strings.Replace(host, "tcp://", scheme+"://", 1) + return pingOperation(client, target) +} + +func pingOperation(client *http.Client, target string) (bool, error) { + pingOperationURL := target + "/_ping" + + response, err := client.Get(pingOperationURL) + if err != nil { + return false, err + } + + agentOnDockerEnvironment := false + if response.Header.Get(portainer.PortainerAgentHeader) != "" { + agentOnDockerEnvironment = true + } + + return agentOnDockerEnvironment, nil +} diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index 0e67cc1b8..141f8d29d 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -2,12 +2,11 @@ package handler import ( "bytes" - "crypto/tls" "strings" - "time" "github.com/portainer/portainer" "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/http/client" httperror "github.com/portainer/portainer/http/error" "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" @@ -63,10 +62,6 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag } type ( - postEndpointsResponse struct { - ID int `json:"Id"` - } - putEndpointAccessRequest struct { AuthorizedUsers []int `valid:"-"` AuthorizedTeams []int `valid:"-"` @@ -125,44 +120,26 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt encodeJSON(w, filteredEndpoints, handler.Logger) } -func sendPingRequest(host string, tlsConfig *tls.Config) error { - transport := &http.Transport{} - - scheme := "http" - if tlsConfig != nil { - transport.TLSClientConfig = tlsConfig - scheme = "https" - } - - client := &http.Client{ - Timeout: time.Second * 3, - Transport: transport, - } - - pingOperationURL := strings.Replace(host, "tcp://", scheme+"://", 1) + "/_ping" - _, err := client.Get(pingOperationURL) - if err != nil { - return err - } - - return nil -} - func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { - tlsConfig, err := crypto.CreateTLSConfig(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification) if err != nil { return nil, err } - err = sendPingRequest(payload.url, tlsConfig) + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, tlsConfig) if err != nil { return nil, err } + endpointType := portainer.DockerEnvironment + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } + endpoint := &portainer.Endpoint{ Name: payload.name, URL: payload.url, + Type: endpointType, GroupID: portainer.EndpointGroupID(payload.groupID), PublicURL: payload.publicURL, TLSConfig: portainer.TLSConfiguration{ @@ -224,17 +201,22 @@ func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPa } func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { + endpointType := portainer.DockerEnvironment if !strings.HasPrefix(payload.url, "unix://") { - err := sendPingRequest(payload.url, nil) + agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.url, nil) if err != nil { return nil, err } + if agentOnDockerEnvironment { + endpointType = portainer.AgentOnDockerEnvironment + } } endpoint := &portainer.Endpoint{ Name: payload.name, URL: payload.url, + Type: endpointType, GroupID: portainer.EndpointGroupID(payload.groupID), PublicURL: payload.publicURL, TLSConfig: portainer.TLSConfiguration{ @@ -331,7 +313,7 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht return } - encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger) + encodeJSON(w, &endpoint, handler.Logger) } // handleGetEndpoint handles GET requests on /endpoints/:id diff --git a/api/http/handler/websocket.go b/api/http/handler/websocket.go index 70fc77d9d..3e624d3b4 100644 --- a/api/http/handler/websocket.go +++ b/api/http/handler/websocket.go @@ -1,11 +1,11 @@ package handler import ( + "bufio" "bytes" "crypto/tls" "encoding/json" "fmt" - "io" "log" "net" "net/http" @@ -16,119 +16,135 @@ import ( "time" "github.com/gorilla/mux" + "github.com/gorilla/websocket" + "github.com/koding/websocketproxy" "github.com/portainer/portainer" "github.com/portainer/portainer/crypto" - "golang.org/x/net/websocket" + httperror "github.com/portainer/portainer/http/error" ) -// WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. -type WebSocketHandler struct { - *mux.Router - Logger *log.Logger - EndpointService portainer.EndpointService -} +type ( + // WebSocketHandler represents an HTTP API handler for proxying requests to a web socket. + WebSocketHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + SignatureService portainer.DigitalSignatureService + connectionUpgrader websocket.Upgrader + } + + webSocketExecRequestParams struct { + execID string + nodeName string + endpoint *portainer.Endpoint + } + + execStartOperationPayload struct { + Tty bool + Detach bool + } +) // NewWebSocketHandler returns a new instance of WebSocketHandler. func NewWebSocketHandler() *WebSocketHandler { h := &WebSocketHandler{ - Router: mux.NewRouter(), - Logger: log.New(os.Stderr, "", log.LstdFlags), + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + connectionUpgrader: websocket.Upgrader{}, } - h.Handle("/websocket/exec", websocket.Handler(h.webSocketDockerExec)) + h.HandleFunc("/websocket/exec", h.handleWebsocketExec).Methods(http.MethodGet) return h } -func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) { - qry := ws.Request().URL.Query() - execID := qry.Get("id") - edpID := qry.Get("endpointId") - - parsedID, err := strconv.Atoi(edpID) - if err != nil { - log.Printf("Unable to parse endpoint ID: %s", err) +// handleWebsocketExec handles GET requests on /websocket/exec?id=&endpointId=&nodeName= +// If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. +// If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and +// an ExecStart operation HTTP request will be created and hijacked. +func (handler *WebSocketHandler) handleWebsocketExec(w http.ResponseWriter, r *http.Request) { + paramExecID := r.FormValue("id") + paramEndpointID := r.FormValue("endpointId") + if paramExecID == "" || paramEndpointID == "" { + httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger) return } - endpointID := portainer.EndpointID(parsedID) - endpoint, err := handler.EndpointService.Endpoint(endpointID) + endpointID, err := strconv.Atoi(paramEndpointID) if err != nil { - log.Printf("Unable to retrieve endpoint: %s", err) + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) return } - endpointURL, err := url.Parse(endpoint.URL) + endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID)) if err != nil { - log.Printf("Unable to parse endpoint URL: %s", err) + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) return } - var host string - if endpointURL.Scheme == "tcp" { - host = endpointURL.Host - } else if endpointURL.Scheme == "unix" { - host = endpointURL.Path + params := &webSocketExecRequestParams{ + endpoint: endpoint, + execID: paramExecID, + nodeName: r.FormValue("nodeName"), } - // TODO: Should not be managed here - var tlsConfig *tls.Config - if endpoint.TLSConfig.TLS { - tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig) - if err != nil { - log.Fatalf("Unable to create TLS configuration: %s", err) - return + err = handler.handleRequest(w, r, params) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } +} + +func (handler *WebSocketHandler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { + if params.nodeName != "" { + return handler.proxyWebsocketRequest(w, r, params) + } + + websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) + if err != nil { + return err + } + defer websocketConn.Close() + + return hijackExecStartOperation(websocketConn, params.endpoint, params.execID) +} + +func (handler *WebSocketHandler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error { + agentURL, err := url.Parse(params.endpoint.URL) + if err != nil { + return err + } + + agentURL.Scheme = "ws" + proxy := websocketproxy.NewProxy(agentURL) + + if params.endpoint.TLSConfig.TLS || params.endpoint.TLSConfig.TLSSkipVerify { + agentURL.Scheme = "wss" + proxy.Dialer = &websocket.Dialer{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: params.endpoint.TLSConfig.TLSSkipVerify, + }, } } - if err := hijack(host, endpointURL.Scheme, "POST", "/exec/"+execID+"/start", tlsConfig, true, ws, ws, ws, nil, nil); err != nil { - log.Fatalf("error during hijack: %s", err) - return + signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage) + if err != nil { + return err } + + proxy.Director = func(incoming *http.Request, out http.Header) { + out.Set(portainer.PortainerAgentSignatureHeader, signature) + out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) + } + + r.Header.Del("Origin") + proxy.ServeHTTP(w, r) + + return nil } -type execConfig struct { - Tty bool - Detach bool -} - -// hijack allows to upgrade an HTTP connection to a TCP connection -// It redirects IO streams for stdin, stdout and stderr to a websocket -func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error { - execConfig := &execConfig{ - Tty: true, - Detach: false, - } - - buf, err := json.Marshal(execConfig) +func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error { + dial, err := createDial(endpoint) if err != nil { - return fmt.Errorf("error marshaling exec config: %s", err) - } - - rdr := bytes.NewReader(buf) - - req, err := http.NewRequest(method, path, rdr) - if err != nil { - return fmt.Errorf("error during hijack request: %s", err) - } - - req.Header.Set("User-Agent", "Docker-Client") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Connection", "Upgrade") - req.Header.Set("Upgrade", "tcp") - req.Host = addr - - var ( - dial net.Conn - dialErr error - ) - - if tlsConfig == nil { - dial, dialErr = net.Dial(scheme, addr) - } else { - dial, dialErr = tls.Dial(scheme, addr, tlsConfig) - } - - if dialErr != nil { - return dialErr + return err } // When we set up a TCP connection for hijack, there could be long periods @@ -140,57 +156,128 @@ func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerm tcpConn.SetKeepAlive(true) tcpConn.SetKeepAlivePeriod(30 * time.Second) } + + httpConn := httputil.NewClientConn(dial, nil) + defer httpConn.Close() + + execStartRequest, err := createExecStartRequest(execID) if err != nil { return err } - clientconn := httputil.NewClientConn(dial, nil) - defer clientconn.Close() - // Server hijacks the connection, error 'connection closed' expected - clientconn.Do(req) - - rwc, br := clientconn.Hijack() - defer rwc.Close() - - if started != nil { - started <- rwc + err = hijackRequest(websocketConn, httpConn, execStartRequest) + if err != nil { + return err } - var receiveStdout chan error - - if stdout != nil || stderr != nil { - go func() (err error) { - if setRawTerminal && stdout != nil { - _, err = io.Copy(stdout, br) - } - return err - }() - } - - go func() error { - if in != nil { - io.Copy(rwc, in) - } - - if conn, ok := rwc.(interface { - CloseWrite() error - }); ok { - if err := conn.CloseWrite(); err != nil { - } - } - return nil - }() - - if stdout != nil || stderr != nil { - if err := <-receiveStdout; err != nil { - return err - } - } - go func() { - for { - fmt.Println(br) - } - }() - return nil } + +func createDial(endpoint *portainer.Endpoint) (net.Conn, error) { + url, err := url.Parse(endpoint.URL) + if err != nil { + return nil, err + } + + var host string + if url.Scheme == "tcp" { + host = url.Host + } else if url.Scheme == "unix" { + host = url.Path + } + + if endpoint.TLSConfig.TLS { + tlsConfig, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) + if err != nil { + return nil, err + } + return tls.Dial(url.Scheme, host, tlsConfig) + } + + return net.Dial(url.Scheme, host) +} + +func createExecStartRequest(execID string) (*http.Request, error) { + execStartOperationPayload := &execStartOperationPayload{ + Tty: true, + Detach: false, + } + + encodedBody := bytes.NewBuffer(nil) + err := json.NewEncoder(encodedBody).Encode(execStartOperationPayload) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", "/exec/"+execID+"/start", encodedBody) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Upgrade", "tcp") + + return request, nil +} + +func hijackRequest(websocketConn *websocket.Conn, httpConn *httputil.ClientConn, request *http.Request) error { + // Server hijacks the connection, error 'connection closed' expected + resp, err := httpConn.Do(request) + if err != httputil.ErrPersistEOF { + if err != nil { + return err + } + if resp.StatusCode != http.StatusSwitchingProtocols { + resp.Body.Close() + return fmt.Errorf("unable to upgrade to tcp, received %d", resp.StatusCode) + } + } + + tcpConn, brw := httpConn.Hijack() + defer tcpConn.Close() + + errorChan := make(chan error, 1) + go streamFromTCPConnToWebsocketConn(websocketConn, brw, errorChan) + go streamFromWebsocketConnToTCPConn(websocketConn, tcpConn, errorChan) + + err = <-errorChan + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { + return err + } + + return nil +} + +func streamFromWebsocketConnToTCPConn(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) { + for { + _, in, err := websocketConn.ReadMessage() + if err != nil { + errorChan <- err + break + } + + _, err = tcpConn.Write(in) + if err != nil { + errorChan <- err + break + } + } +} + +func streamFromTCPConnToWebsocketConn(websocketConn *websocket.Conn, br *bufio.Reader, errorChan chan error) { + for { + out := make([]byte, 1024) + _, err := br.Read(out) + if err != nil { + errorChan <- err + break + } + + err = websocketConn.WriteMessage(websocket.TextMessage, out) + if err != nil { + errorChan <- err + break + } + } +} diff --git a/api/http/proxy/access_control.go b/api/http/proxy/access_control.go index eece43d9c..408ff8c7d 100644 --- a/api/http/proxy/access_control.go +++ b/api/http/proxy/access_control.go @@ -98,9 +98,12 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team } func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} { - metadata := make(map[string]interface{}) - metadata["ResourceControl"] = resourceControl - object["Portainer"] = metadata + if object["Portainer"] == nil { + object["Portainer"] = make(map[string]interface{}) + } + + portainerMetadata := object["Portainer"].(map[string]interface{}) + portainerMetadata["ResourceControl"] = resourceControl return object } diff --git a/api/http/proxy/configs.go b/api/http/proxy/configs.go index 16904c6c2..fc05dd74c 100644 --- a/api/http/proxy/configs.go +++ b/api/http/proxy/configs.go @@ -14,7 +14,7 @@ const ( // 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 { +func configListOperation(response *http.Response, executor *operationExecutor) error { var err error // ConfigList response is a JSON array @@ -39,7 +39,7 @@ func configListOperation(request *http.Request, response *http.Response, executo // 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 { +func configInspectOperation(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) diff --git a/api/http/proxy/containers.go b/api/http/proxy/containers.go index 6992ae623..b05149940 100644 --- a/api/http/proxy/containers.go +++ b/api/http/proxy/containers.go @@ -16,7 +16,7 @@ const ( // containerListOperation extracts the response as a JSON object, loop through the containers array // decorate and/or filter the containers based on resource controls before rewriting the response -func containerListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func containerListOperation(response *http.Response, executor *operationExecutor) error { var err error // ContainerList response is a JSON array // https://docs.docker.com/engine/api/v1.28/#operation/ContainerList @@ -47,7 +47,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec // containerInspectOperation extracts the response as a JSON object, verify that the user // has access to the container based on resource control (check are done based on the containerID and optional Swarm service ID) // and either rewrite an access denied response or a decorated container. -func containerInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func containerInspectOperation(response *http.Response, executor *operationExecutor) error { // ContainerInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect responseObject, err := getResponseAsJSONOBject(response) diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 91015367c..9df363332 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -17,6 +17,7 @@ type proxyFactory struct { SettingsService portainer.SettingsService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService + SignatureService portainer.DigitalSignatureService } func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { @@ -24,10 +25,11 @@ func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { return newSingleHostReverseProxyWithHostHeader(u) } -func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) { +func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) { u.Scheme = "https" - proxy := factory.createDockerReverseProxy(u) - config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig) + + proxy := factory.createDockerReverseProxy(u, enableSignature) + config, err := crypto.CreateTLSConfiguration(tlsConfig) if err != nil { return nil, err } @@ -36,14 +38,15 @@ func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer return proxy, nil } -func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL) http.Handler { +func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool) http.Handler { u.Scheme = "http" - return factory.createDockerReverseProxy(u) + return factory.createDockerReverseProxy(u, enableSignature) } func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler { proxy := &socketProxy{} transport := &proxyTransport{ + enableSignature: false, ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, @@ -55,9 +58,10 @@ func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler { return proxy } -func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.ReverseProxy { +func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool) *httputil.ReverseProxy { proxy := newSingleHostReverseProxyWithHostHeader(u) transport := &proxyTransport{ + enableSignature: enableSignature, ResourceControlService: factory.ResourceControlService, TeamMembershipService: factory.TeamMembershipService, SettingsService: factory.SettingsService, @@ -65,6 +69,11 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.Reve DockerHubService: factory.DockerHubService, dockerTransport: &http.Transport{}, } + + if enableSignature { + transport.SignatureService = factory.SignatureService + } + proxy.Transport = transport return proxy } diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 747c7870a..c1906e45b 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -9,24 +9,37 @@ import ( "github.com/portainer/portainer" ) -// Manager represents a service used to manage Docker proxies. -type Manager struct { - proxyFactory *proxyFactory - proxies cmap.ConcurrentMap - extensionProxies cmap.ConcurrentMap -} +type ( + // Manager represents a service used to manage Docker proxies. + Manager struct { + proxyFactory *proxyFactory + proxies cmap.ConcurrentMap + extensionProxies cmap.ConcurrentMap + } + + // ManagerParams represents the required parameters to create a new Manager instance. + ManagerParams struct { + ResourceControlService portainer.ResourceControlService + TeamMembershipService portainer.TeamMembershipService + SettingsService portainer.SettingsService + RegistryService portainer.RegistryService + DockerHubService portainer.DockerHubService + SignatureService portainer.DigitalSignatureService + } +) // NewManager initializes a new proxy Service -func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService, registryService portainer.RegistryService, dockerHubService portainer.DockerHubService) *Manager { +func NewManager(parameters *ManagerParams) *Manager { return &Manager{ proxies: cmap.New(), extensionProxies: cmap.New(), proxyFactory: &proxyFactory{ - ResourceControlService: resourceControlService, - TeamMembershipService: teamMembershipService, - SettingsService: settingsService, - RegistryService: registryService, - DockerHubService: dockerHubService, + ResourceControlService: parameters.ResourceControlService, + TeamMembershipService: parameters.TeamMembershipService, + SettingsService: parameters.SettingsService, + RegistryService: parameters.RegistryService, + DockerHubService: parameters.DockerHubService, + SignatureService: parameters.SignatureService, }, } } @@ -41,14 +54,19 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht return nil, err } + enableSignature := false + if endpoint.Type == portainer.AgentOnDockerEnvironment { + enableSignature = true + } + if endpointURL.Scheme == "tcp" { - if endpoint.TLSConfig.TLS { - proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, endpoint) + if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify { + proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature) if err != nil { return nil, err } } else { - proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL) + proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature) } } else { // Assume unix:// scheme diff --git a/api/http/proxy/networks.go b/api/http/proxy/networks.go index aa1bcea70..7e40d385a 100644 --- a/api/http/proxy/networks.go +++ b/api/http/proxy/networks.go @@ -15,7 +15,7 @@ const ( // 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 { +func networkListOperation(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 @@ -39,7 +39,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut // 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 { +func networkInspectOperation(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) diff --git a/api/http/proxy/secrets.go b/api/http/proxy/secrets.go index cbef6ac84..9c4d8b093 100644 --- a/api/http/proxy/secrets.go +++ b/api/http/proxy/secrets.go @@ -14,7 +14,7 @@ const ( // 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 { +func secretListOperation(response *http.Response, executor *operationExecutor) error { var err error // SecretList response is a JSON array @@ -39,7 +39,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo // 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 { +func secretInspectOperation(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) diff --git a/api/http/proxy/services.go b/api/http/proxy/services.go index fa010b022..9934cf381 100644 --- a/api/http/proxy/services.go +++ b/api/http/proxy/services.go @@ -15,7 +15,7 @@ const ( // 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 { +func serviceListOperation(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 @@ -39,7 +39,7 @@ func serviceListOperation(request *http.Request, response *http.Response, execut // 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 { +func serviceInspectOperation(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) diff --git a/api/http/proxy/tasks.go b/api/http/proxy/tasks.go index 9187a0530..daea6b926 100644 --- a/api/http/proxy/tasks.go +++ b/api/http/proxy/tasks.go @@ -15,7 +15,7 @@ const ( // 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 { +func taskListOperation(response *http.Response, executor *operationExecutor) error { var err error // TaskList response is a JSON array diff --git a/api/http/proxy/transport.go b/api/http/proxy/transport.go index f5491213e..9effb54d8 100644 --- a/api/http/proxy/transport.go +++ b/api/http/proxy/transport.go @@ -17,11 +17,13 @@ var apiVersionRe = regexp.MustCompile(`(/v[0-9]\.[0-9]*)?`) type ( proxyTransport struct { dockerTransport *http.Transport + enableSignature bool ResourceControlService portainer.ResourceControlService TeamMembershipService portainer.TeamMembershipService RegistryService portainer.RegistryService DockerHubService portainer.DockerHubService SettingsService portainer.SettingsService + SignatureService portainer.DigitalSignatureService } restrictedOperationContext struct { isAdmin bool @@ -45,7 +47,7 @@ type ( operationContext *restrictedOperationContext labelBlackList []portainer.Pair } - restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error + restrictedOperationRequest func(*http.Response, *operationExecutor) error operationRequest func(*http.Request) error ) @@ -61,6 +63,16 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon path := apiVersionRe.ReplaceAllString(request.URL.Path, "") request.URL.Path = path + if p.enableSignature { + signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + request.Header.Set(portainer.PortainerAgentPublicKeyHeader, p.SignatureService.EncodedPublicKey()) + request.Header.Set(portainer.PortainerAgentSignatureHeader, signature) + } + switch { case strings.HasPrefix(path, "/configs"): return p.proxyConfigRequest(request) @@ -392,7 +404,7 @@ func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, return response, err } - err = operation(request, response, executor) + err = operation(response, executor) return response, err } diff --git a/api/http/proxy/volumes.go b/api/http/proxy/volumes.go index a8bc25ccf..c0582a6bd 100644 --- a/api/http/proxy/volumes.go +++ b/api/http/proxy/volumes.go @@ -15,7 +15,7 @@ const ( // volumeListOperation extracts the response as a JSON object, loop through the volume array // decorate and/or filter the volumes based on resource controls before rewriting the response -func volumeListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { +func volumeListOperation(response *http.Response, executor *operationExecutor) error { var err error // VolumeList response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/VolumeList @@ -48,7 +48,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 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 { +func volumeInspectOperation(response *http.Response, executor *operationExecutor) error { // VolumeInspect response is a JSON object // https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect responseObject, err := getResponseAsJSONOBject(response) diff --git a/api/http/server.go b/api/http/server.go index 153b667f6..d893a5eaf 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -34,6 +34,7 @@ type Server struct { StackManager portainer.StackManager LDAPService portainer.LDAPService GitService portainer.GitService + SignatureService portainer.DigitalSignatureService Handler *handler.Handler SSL bool SSLCert string @@ -43,7 +44,15 @@ type Server struct { // Start starts the HTTP server func (server *Server) Start() error { requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled) - proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService, server.RegistryService, server.DockerHubService) + proxyManagerParameters := &proxy.ManagerParams{ + ResourceControlService: server.ResourceControlService, + TeamMembershipService: server.TeamMembershipService, + SettingsService: server.SettingsService, + RegistryService: server.RegistryService, + DockerHubService: server.DockerHubService, + SignatureService: server.SignatureService, + } + proxyManager := proxy.NewManager(proxyManagerParameters) var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public")) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) @@ -78,6 +87,7 @@ func (server *Server) Start() error { dockerHandler.ProxyManager = proxyManager var websocketHandler = handler.NewWebSocketHandler() websocketHandler.EndpointService = server.EndpointService + websocketHandler.SignatureService = server.SignatureService var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement) endpointHandler.EndpointService = server.EndpointService endpointHandler.EndpointGroupService = server.EndpointGroupService diff --git a/api/portainer.go b/api/portainer.go index 1fc754f0c..170e326e8 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -24,6 +24,7 @@ type ( NoAnalytics *bool Templates *string TLSVerify *bool + TLSSkipVerify *bool TLSCacert *string TLSCert *string TLSKey *string @@ -168,11 +169,15 @@ type ( // EndpointID represents an endpoint identifier. EndpointID int + // EndpointType represents the type of an endpoint. + EndpointType int + // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { ID EndpointID `json:"Id"` Name string `json:"Name"` + Type EndpointType `json:"Type"` URL string `json:"URL"` GroupID EndpointGroupID `json:"GroupId"` PublicURL string `json:"PublicURL"` @@ -378,6 +383,15 @@ type ( CompareHashAndData(hash string, data string) error } + // DigitalSignatureService represents a service to manage digital signatures. + DigitalSignatureService interface { + ParseKeyPair(private, public []byte) error + GenerateKeyPair() ([]byte, []byte, error) + EncodedPublicKey() string + PEMHeaders() (string, string) + Sign(message string) (string, error) + } + // JWTService represents a service for managing JWT tokens. JWTService interface { GenerateToken(data *TokenData) (string, error) @@ -395,6 +409,10 @@ type ( GetStackProjectPath(stackIdentifier string) string StoreStackFileFromString(stackIdentifier, fileName, stackFileContent string) (string, error) StoreStackFileFromReader(stackIdentifier, fileName string, r io.Reader) (string, error) + KeyPairFilesExist() (bool, error) + StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error + LoadKeyPair() ([]byte, []byte, error) + WriteJSONToFile(path string, content interface{}) error } // GitService represents a service for managing Git. @@ -427,9 +445,20 @@ const ( // APIVersion is the version number of the Portainer API. APIVersion = "1.16.5" // DBVersion is the version number of the Portainer database. - DBVersion = 9 + DBVersion = 10 // DefaultTemplatesURL represents the default URL for the templates definitions. DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json" + // PortainerAgentHeader represents the name of the header available in any agent response + PortainerAgentHeader = "Portainer-Agent" + // PortainerAgentTargetHeader represent the name of the header containing the target node name. + PortainerAgentTargetHeader = "X-PortainerAgent-Target" + // PortainerAgentSignatureHeader represent the name of the header containing the digital signature + PortainerAgentSignatureHeader = "X-PortainerAgent-Signature" + // PortainerAgentPublicKeyHeader represent the name of the header containing the public key + PortainerAgentPublicKeyHeader = "X-PortainerAgent-PublicKey" + // PortainerAgentSignatureMessage represents the message used to create a digital signature + // to be used when communicating with an agent + PortainerAgentSignatureMessage = "Portainer-App" ) const ( @@ -494,3 +523,11 @@ const ( // StoridgeEndpointExtension represents the Storidge extension StoridgeEndpointExtension ) + +const ( + _ EndpointType = iota + // DockerEnvironment represents an endpoint connected to a Docker environment + DockerEnvironment + // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment + AgentOnDockerEnvironment +) diff --git a/api/swagger.yaml b/api/swagger.yaml index c029e46d2..83b8db639 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -238,7 +238,7 @@ paths: 200: description: "Success" schema: - $ref: "#/definitions/EndpointCreateResponse" + $ref: "#/definitions/Endpoint" 400: description: "Invalid request" schema: diff --git a/app/__module.js b/app/__module.js index 98591b7e6..f5ebe25a2 100644 --- a/app/__module.js +++ b/app/__module.js @@ -17,6 +17,7 @@ angular.module('portainer', [ 'luegg.directives', 'portainer.templates', 'portainer.app', + 'portainer.agent', 'portainer.docker', 'extension.storidge', 'rzModule']); diff --git a/app/agent/_module.js b/app/agent/_module.js new file mode 100644 index 000000000..c74f4eda2 --- /dev/null +++ b/app/agent/_module.js @@ -0,0 +1 @@ +angular.module('portainer.agent', []); diff --git a/app/agent/components/node-selector/node-selector.js b/app/agent/components/node-selector/node-selector.js new file mode 100644 index 000000000..8dfb46ce6 --- /dev/null +++ b/app/agent/components/node-selector/node-selector.js @@ -0,0 +1,7 @@ +angular.module('portainer.agent').component('nodeSelector', { + templateUrl: 'app/agent/components/node-selector/nodeSelector.html', + controller: 'NodeSelectorController', + bindings: { + model: '=' + } +}); diff --git a/app/agent/components/node-selector/nodeSelector.html b/app/agent/components/node-selector/nodeSelector.html new file mode 100644 index 000000000..1a910396c --- /dev/null +++ b/app/agent/components/node-selector/nodeSelector.html @@ -0,0 +1,8 @@ +
+ +
+ +
+
diff --git a/app/agent/components/node-selector/nodeSelectorController.js b/app/agent/components/node-selector/nodeSelectorController.js new file mode 100644 index 000000000..be2f6ce92 --- /dev/null +++ b/app/agent/components/node-selector/nodeSelectorController.js @@ -0,0 +1,18 @@ +angular.module('portainer.agent') +.controller('NodeSelectorController', ['AgentService', 'Notifications', function (AgentService, Notifications) { + var ctrl = this; + + this.$onInit = function() { + AgentService.agents() + .then(function success(data) { + ctrl.agents = data; + if (!ctrl.model) { + ctrl.model = data[0].NodeName; + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load agents'); + }); + }; + +}]); diff --git a/app/agent/models/agent.js b/app/agent/models/agent.js new file mode 100644 index 000000000..9f7ed8b43 --- /dev/null +++ b/app/agent/models/agent.js @@ -0,0 +1,5 @@ +function AgentViewModel(data) { + this.IPAddress = data.IPAddress; + this.NodeName = data.NodeName; + this.NodeRole = data.NodeRole; +} diff --git a/app/agent/rest/agent.js b/app/agent/rest/agent.js new file mode 100644 index 000000000..04d974780 --- /dev/null +++ b/app/agent/rest/agent.js @@ -0,0 +1,10 @@ +angular.module('portainer.agent') +.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', { + endpointId: EndpointProvider.endpointID + }, + { + query: {method: 'GET', isArray: true} + }); +}]); diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js new file mode 100644 index 000000000..5f011d01c --- /dev/null +++ b/app/agent/services/agentService.js @@ -0,0 +1,24 @@ +angular.module('portainer.agent') +.factory('AgentService', ['$q', 'Agent', function AgentServiceFactory($q, Agent) { + 'use strict'; + var service = {}; + + service.agents = function() { + var deferred = $q.defer(); + + Agent.query({}).$promise + .then(function success(data) { + var agents = data.map(function (item) { + return new AgentViewModel(item); + }); + deferred.resolve(agents); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve agents', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/app.js b/app/app.js index 7ae0688b9..0ed1166fe 100644 --- a/app/app.js +++ b/app/app.js @@ -1,5 +1,6 @@ angular.module('portainer') -.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar) { +.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', +function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar, $transitions, HttpRequestHelper) { 'use strict'; EndpointProvider.initialize(); @@ -27,6 +28,10 @@ angular.module('portainer') originalSet.apply(cfpLoadingBar, arguments); } }; + + $transitions.onBefore({ to: 'docker.**' }, function() { + HttpRequestHelper.resetAgentTargetQueue(); + }); }]); diff --git a/app/config.js b/app/config.js index 8718db8cf..8e3366856 100644 --- a/app/config.js +++ b/app/config.js @@ -24,6 +24,17 @@ angular.module('portainer') $httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; + $httpProvider.interceptors.push(['HttpRequestHelper', function(HttpRequestHelper) { + return { + request: function(config) { + if (config.url.indexOf('/docker/') > -1) { + config.headers['X-PortainerAgent-Target'] = HttpRequestHelper.portainerAgentTargetHeader(); + } + return config; + } + }; + }]); + AnalyticsProvider.setAccount('@@CONFIG_GA_ID'); AnalyticsProvider.startOffline(true); diff --git a/app/docker/__module.js b/app/docker/__module.js index 0ede8298d..28ad5e52d 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -57,7 +57,7 @@ angular.module('portainer.docker', ['portainer.app']) var container = { name: 'docker.containers.container', - url: '/:id', + url: '/:id?nodeName', views: { 'content@': { templateUrl: 'app/docker/views/containers/edit/container.html', @@ -79,15 +79,12 @@ angular.module('portainer.docker', ['portainer.app']) var containerCreation = { name: 'docker.containers.new', - url: '/new', + url: '/new?nodeName&from', views: { 'content@': { templateUrl: 'app/docker/views/containers/create/createcontainer.html', controller: 'CreateContainerController' } - }, - params: { - from: '' } }; @@ -170,7 +167,7 @@ angular.module('portainer.docker', ['portainer.app']) var image = { name: 'docker.images.image', - url: '/:id', + url: '/:id?nodeName', views: { 'content@': { templateUrl: 'app/docker/views/images/edit/image.html', @@ -203,7 +200,7 @@ angular.module('portainer.docker', ['portainer.app']) var network = { name: 'docker.networks.network', - url: '/:id', + url: '/:id?nodeName', views: { 'content@': { templateUrl: 'app/docker/views/networks/edit/network.html', @@ -443,7 +440,7 @@ angular.module('portainer.docker', ['portainer.app']) var volume = { name: 'docker.volumes.volume', - url: '/:id', + url: '/:id?nodeName', views: { 'content@': { templateUrl: 'app/docker/views/volumes/edit/volume.html', diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js b/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js new file mode 100644 index 000000000..54dc0caf6 --- /dev/null +++ b/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('dashboardClusterAgentInfo', { + templateUrl: 'app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html', + controller: 'DashboardClusterAgentInfoController' +}); diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html new file mode 100644 index 000000000..7012bb15c --- /dev/null +++ b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + +
Nodes in the cluster{{ $ctrl.agentCount }}
+ +
+
+
diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js new file mode 100644 index 000000000..7ba6f7f08 --- /dev/null +++ b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js @@ -0,0 +1,16 @@ +angular.module('portainer.docker') +.controller('DashboardClusterAgentInfoController', ['AgentService', 'Notifications', +function (AgentService, Notifications) { + var ctrl = this; + + this.$onInit = function() { + AgentService.agents() + .then(function success(data) { + ctrl.agentCount = data.length; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve agent information'); + }); + }; + +}]); diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html index d33805ce6..5eddcae40 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html @@ -38,7 +38,7 @@ - {{ key }} + {{ key }} {{ value.IPAddress || '-' }} {{ value.Gateway || '-' }} {{ value.MacAddress || '-' }} diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js index 9447a0e1a..2b294141b 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.js @@ -11,6 +11,7 @@ angular.module('portainer.docker').component('containerNetworksDatatable', { joinNetworkAction: '<', joinNetworkActionInProgress: '<', leaveNetworkActionInProgress: '<', - leaveNetworkAction: '<' + leaveNetworkAction: '<', + nodeName: '<' } }); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index b2e67e014..9ce58d5f7 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -156,6 +156,13 @@ + + + Host + + + + Published Ports @@ -179,7 +186,7 @@ - {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} + {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} {{ item.Status }} @@ -187,15 +194,16 @@
- - - - + + + +
{{ item.StackName ? item.StackName : '-' }} {{ item.Image | trimshasum }} {{ item.IP ? item.IP : '-' }} + {{ item.NodeName ? item.NodeName : '-' }} {{ p.public }}:{{ p.private }} @@ -210,10 +218,10 @@ - Loading... + Loading... - No container available. + No container available. diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js index 01c12de81..4e806b415 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.js @@ -10,6 +10,7 @@ angular.module('portainer.docker').component('containersDatatable', { reverseOrder: '<', showTextFilter: '<', showOwnershipColumn: '<', + showHostColumn: '<', publicUrl: '<', containerNameTruncateSize: '<', startAction: '<', diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index 57d947e07..bded973b1 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -93,6 +93,13 @@ + + + Host + + + + @@ -102,7 +109,7 @@ - {{ item.Id | truncate:20 }} + {{ item.Id | truncate:20 }} Unused @@ -110,12 +117,13 @@ {{ item.VirtualSize | humansize }} {{ item.Created | getisodatefromtimestamp }} + {{ item.NodeName ? item.NodeName : '-' }} - Loading... + Loading... - No image available. + No image available. diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.js b/app/docker/components/datatables/images-datatable/imagesDatatable.js index 6a7349d3d..d2e5305d4 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.js +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.js @@ -9,6 +9,7 @@ angular.module('portainer.docker').component('imagesDatatable', { orderBy: '@', reverseOrder: '<', showTextFilter: '<', + showHostColumn: '<', removeAction: '<', forceRemoveAction: '<' } diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index 9e9c1da26..6ccf496f6 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -81,6 +81,13 @@ + + + Host + + + + Ownership @@ -97,7 +104,7 @@ - {{ item.Name | truncate:40 }} + {{ item.Name | truncate:40 }} {{ item.StackName ? item.StackName : '-' }} {{ item.Scope }} @@ -105,6 +112,7 @@ {{ item.IPAM.Driver }} {{ item.IPAM.Config[0].Subnet ? item.IPAM.Config[0].Subnet : '-' }} {{ item.IPAM.Config[0].Gateway ? item.IPAM.Config[0].Gateway : '-' }} + {{ item.NodeName ? item.NodeName : '-' }} @@ -113,10 +121,10 @@ - Loading... + Loading... - No network available. + No network available. diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.js b/app/docker/components/datatables/networks-datatable/networksDatatable.js index 3d86651bc..1f7527890 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.js @@ -10,6 +10,7 @@ angular.module('portainer.docker').component('networksDatatable', { reverseOrder: '<', showTextFilter: '<', showOwnershipColumn: '<', + showHostColumn: '<', removeAction: '<' } }); diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html index e1ba5886a..c7e98c0dc 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html @@ -54,20 +54,26 @@ - Actions + Actions - {{ item.Id }} + + {{ item.Id }} + {{ item.Id }} + {{ item.Status.State }} {{ item.Slot ? item.Slot : '-' }} {{ item.NodeId | tasknodename: $ctrl.nodes }} {{ item.Updated | getisodate }} - - + + View logs + + Console + diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js index b560bdade..991c328ec 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js @@ -11,6 +11,7 @@ angular.module('portainer.docker').component('tasksDatatable', { nodes: '<', showTextFilter: '<', showSlotColumn: '<', - showLogsButton: '<' + showLogsButton: '<', + agentProxy: '<' } }); diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index 760085b3e..3d896409c 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -84,6 +84,13 @@ + + + Host + + + + Ownership @@ -100,12 +107,13 @@ - {{ item.Id | truncate:25 }} + {{ item.Id | truncate:25 }} Unused {{ item.StackName ? item.StackName : '-' }} {{ item.Driver }} {{ item.Mountpoint | truncatelr }} + {{ item.NodeName ? item.NodeName : '-' }} @@ -114,10 +122,10 @@ - Loading... + Loading... - No volume available. + No volume available. diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js index daa6f5b47..8d1707fe5 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js @@ -10,6 +10,7 @@ angular.module('portainer.docker').component('volumesDatatable', { reverseOrder: '<', showTextFilter: '<', showOwnershipColumn: '<', + showHostColumn: '<', removeAction: '<' } }); diff --git a/app/docker/helpers/infoHelper.js b/app/docker/helpers/infoHelper.js index 7421e1713..3d84d9523 100644 --- a/app/docker/helpers/infoHelper.js +++ b/app/docker/helpers/infoHelper.js @@ -4,12 +4,20 @@ angular.module('portainer.docker') var helper = {}; - helper.determineEndpointMode = function(info) { + helper.determineEndpointMode = function(info, type) { var mode = { provider: '', - role: '' + role: '', + agentProxy: false }; + if (type === 2) { + mode.provider = 'DOCKER_SWARM_MODE'; + mode.role = 'MANAGER'; + mode.agentProxy = true; + return mode; + } + if (!info.Swarm || _.isEmpty(info.Swarm.NodeID)) { if (info.ID === 'vSphere Integrated Containers') { mode.provider = 'VMWARE_VIC'; diff --git a/app/docker/helpers/taskHelper.js b/app/docker/helpers/taskHelper.js new file mode 100644 index 000000000..5526c3c32 --- /dev/null +++ b/app/docker/helpers/taskHelper.js @@ -0,0 +1,18 @@ +angular.module('portainer.docker') +.factory('TaskHelper', [function TaskHelperFactory() { + 'use strict'; + + var helper = {}; + + helper.associateContainerToTask = function(task, containers) { + for (var i = 0; i < containers.length; i++) { + var container = containers[i]; + if (task.ContainerId === container.Id) { + task.Container = container; + break; + } + } + }; + + return helper; +}]); diff --git a/app/docker/models/container.js b/app/docker/models/container.js index 64d4d7047..e4f64f6f5 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -34,6 +34,9 @@ function ContainerViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } + if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } } } diff --git a/app/docker/models/image.js b/app/docker/models/image.js index 60e7b0c11..129d1f90b 100644 --- a/app/docker/models/image.js +++ b/app/docker/models/image.js @@ -7,6 +7,10 @@ function ImageViewModel(data) { this.RepoTags = data.RepoTags; this.VirtualSize = data.VirtualSize; this.ContainerCount = data.ContainerCount; + + if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } } function ImageBuildModel(data) { diff --git a/app/docker/models/network.js b/app/docker/models/network.js index baa5ab984..402d4cd96 100644 --- a/app/docker/models/network.js +++ b/app/docker/models/network.js @@ -19,5 +19,8 @@ function NetworkViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } + if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } } } diff --git a/app/docker/models/task.js b/app/docker/models/task.js index ab23be4c0..e28390483 100644 --- a/app/docker/models/task.js +++ b/app/docker/models/task.js @@ -7,4 +7,7 @@ function TaskViewModel(data) { this.Status = data.Status; this.ServiceId = data.ServiceID; this.NodeId = data.NodeID; + if (data.Status && data.Status.ContainerStatus && data.Status.ContainerStatus.ContainerID) { + this.ContainerId = data.Status.ContainerStatus.ContainerID; + } } diff --git a/app/docker/models/volume.js b/app/docker/models/volume.js index 5e2701b70..5ac1d366d 100644 --- a/app/docker/models/volume.js +++ b/app/docker/models/volume.js @@ -14,5 +14,8 @@ function VolumeViewModel(data) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); } + if (data.Portainer.Agent && data.Portainer.Agent.NodeName) { + this.NodeName = data.Portainer.Agent.NodeName; + } } } diff --git a/app/docker/rest/container.js b/app/docker/rest/container.js index f15ec77b3..9f35d18d8 100644 --- a/app/docker/rest/container.js +++ b/app/docker/rest/container.js @@ -1,18 +1,34 @@ angular.module('portainer.docker') -.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/containers/:id/:action', { name: '@name', endpointId: EndpointProvider.endpointID }, { - query: {method: 'GET', params: {all: 0, action: 'json', filters: '@filters' }, isArray: true}, - get: {method: 'GET', params: {action: 'json'}}, - stop: {method: 'POST', params: {id: '@id', t: 5, action: 'stop'}}, - restart: {method: 'POST', params: {id: '@id', t: 5, action: 'restart'}}, - kill: {method: 'POST', params: {id: '@id', action: 'kill'}}, - pause: {method: 'POST', params: {id: '@id', action: 'pause'}}, - unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}}, + query: { + method: 'GET', params: { all: 0, action: 'json', filters: '@filters' }, + isArray: true + }, + get: { + method: 'GET', params: { action: 'json' } + }, + stop: { + method: 'POST', params: { id: '@id', t: 5, action: 'stop' } + }, + restart: { + method: 'POST', params: { id: '@id', t: 5, action: 'restart' } + }, + kill: { + method: 'POST', params: { id: '@id', action: 'kill' } + }, + pause: { + method: 'POST', params: { id: '@id', action: 'pause' } + }, + unpause: { + method: 'POST', params: { id: '@id', action: 'unpause' } + }, logs: { method: 'GET', params: { id: '@id', action: 'logs' }, timeout: 4500, ignoreLoadingBar: true, @@ -40,7 +56,7 @@ angular.module('portainer.docker') transformResponse: genericHandler }, rename: { - method: 'POST', params: {id: '@id', action: 'rename', name: '@name'}, + method: 'POST', params: { id: '@id', action: 'rename', name: '@name' }, transformResponse: genericHandler }, exec: { diff --git a/app/docker/rest/exec.js b/app/docker/rest/exec.js index bbd7642d9..c77af4679 100644 --- a/app/docker/rest/exec.js +++ b/app/docker/rest/exec.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('Exec', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Exec', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ExecFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/exec/:id/:action', { endpointId: EndpointProvider.endpointID diff --git a/app/docker/rest/image.js b/app/docker/rest/image.js index 6430c5637..5395bb6c7 100644 --- a/app/docker/rest/image.js +++ b/app/docker/rest/image.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { +.factory('Image', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', +function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/images/:id/:action', { diff --git a/app/docker/rest/network.js b/app/docker/rest/network.js index 2bfc4cd4c..bd2a288eb 100644 --- a/app/docker/rest/network.js +++ b/app/docker/rest/network.js @@ -1,16 +1,30 @@ angular.module('portainer.docker') -.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { +.factory('Network', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/networks/:id/:action', { id: '@id', endpointId: EndpointProvider.endpointID }, { - query: {method: 'GET', isArray: true}, - get: {method: 'GET'}, - create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true}, - remove: { method: 'DELETE', transformResponse: genericHandler }, - connect: {method: 'POST', params: {action: 'connect'}}, - disconnect: {method: 'POST', params: {action: 'disconnect'}} + query: { + method: 'GET', isArray: true + }, + get: { + method: 'GET' + }, + create: { + method: 'POST', params: {action: 'create'}, + transformResponse: genericHandler, ignoreLoadingBar: true + }, + remove: { + method: 'DELETE', transformResponse: genericHandler + }, + connect: { + method: 'POST', params: { action: 'connect' } + }, + disconnect: { + method: 'POST', params: { action: 'disconnect' } + } }); }]); diff --git a/app/docker/rest/service.js b/app/docker/rest/service.js index 361186637..806586104 100644 --- a/app/docker/rest/service.js +++ b/app/docker/rest/service.js @@ -1,5 +1,6 @@ angular.module('portainer.docker') -.factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper' ,function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { +.factory('Service', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'HttpRequestHelper', +function ServiceFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpRequestHelper) { 'use strict'; return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/services/:id/:action', { endpointId: EndpointProvider.endpointID diff --git a/app/docker/rest/system.js b/app/docker/rest/system.js index 5af2ebbce..84ce2e756 100644 --- a/app/docker/rest/system.js +++ b/app/docker/rest/system.js @@ -6,7 +6,10 @@ angular.module('portainer.docker') endpointId: EndpointProvider.endpointID }, { - info: { method: 'GET', params: { action: 'info' }, ignoreLoadingBar: true }, + info: { + method: 'GET', params: { action: 'info' }, + ignoreLoadingBar: true + }, version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true, timeout: 4500 }, events: { method: 'GET', params: { action: 'events', since: '@since', until: '@until' }, diff --git a/app/docker/services/containerService.js b/app/docker/services/containerService.js index 776b8ea21..d0368ade7 100644 --- a/app/docker/services/containerService.js +++ b/app/docker/services/containerService.js @@ -35,6 +35,34 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe return deferred.promise; }; + service.startContainer = function(id) { + return Container.start({ id: id }, {}).$promise; + }; + + service.stopContainer = function(id) { + return Container.stop({ id: id }, {}).$promise; + }; + + service.restartContainer = function(id) { + return Container.restart({ id: id }, {}).$promise; + }; + + service.killContainer = function(id) { + return Container.kill({ id: id }, {}).$promise; + }; + + service.pauseContainer = function(id) { + return Container.pause({ id: id }, {}).$promise; + }; + + service.resumeContainer = function(id) { + return Container.unpause({ id: id }, {}).$promise; + }; + + service.renameContainer = function(id, newContainerName) { + return Container.rename({id: id, name: newContainerName }, {}).$promise; + }; + service.createContainer = function(configuration) { var deferred = $q.defer(); Container.create(configuration).$promise @@ -51,30 +79,6 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe return deferred.promise; }; - service.startContainer = function(containerID) { - return Container.start({ id: containerID }, {}).$promise; - }; - - service.stopContainer = function(containerID) { - return Container.stop({ id: containerID }, {}).$promise; - }; - - service.restartContainer = function(containerID) { - return Container.restart({ id: containerID }, {}).$promise; - }; - - service.killContainer = function(containerID) { - return Container.kill({ id: containerID }, {}).$promise; - }; - - service.pauseContainer = function(containerID) { - return Container.pause({ id: containerID }, {}).$promise; - }; - - service.resumeContainer = function(containerID) { - return Container.unpause({ id: containerID }, {}).$promise; - }; - service.createAndStartContainer = function(configuration) { var deferred = $q.defer(); var containerID; @@ -95,7 +99,7 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe service.remove = function(container, removeVolumes) { var deferred = $q.defer(); - Container.remove({id: container.Id, v: (removeVolumes) ? 1 : 0, force: true}).$promise + Container.remove({ id: container.Id, v: (removeVolumes) ? 1 : 0, force: true }).$promise .then(function success(data) { if (data.message) { deferred.reject({ msg: data.message, err: data.message }); @@ -117,7 +121,7 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe service.createExec = function(execConfig) { var deferred = $q.defer(); - Container.exec(execConfig).$promise + Container.exec({}, execConfig).$promise .then(function success(data) { if (data.message) { deferred.reject({ msg: data.message, err: data.message }); @@ -158,7 +162,7 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe service.containerStats = function(id) { var deferred = $q.defer(); - Container.stats({id: id}).$promise + Container.stats({ id: id }).$promise .then(function success(data) { var containerStats = new ContainerStatsViewModel(data); deferred.resolve(containerStats); @@ -171,11 +175,11 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe }; service.containerTop = function(id) { - return Container.top({id: id}).$promise; + return Container.top({ id: id }).$promise; }; service.inspect = function(id) { - return Container.inspect({id: id}).$promise; + return Container.inspect({ id: id }).$promise; }; return service; diff --git a/app/docker/services/execService.js b/app/docker/services/execService.js index 0499aced0..2e0c04f95 100644 --- a/app/docker/services/execService.js +++ b/app/docker/services/execService.js @@ -7,7 +7,7 @@ angular.module('portainer.docker') var deferred = $q.defer(); $timeout(function() { - Exec.resize({id: execId, height: height, width: width}).$promise + Exec.resize({}, { id: execId, height: height, width: width }).$promise .then(function success(data) { if (data.message) { deferred.reject({ msg: 'Unable to exec into container', err: data.message }); diff --git a/app/docker/services/networkService.js b/app/docker/services/networkService.js index f28aaac08..010a5d9b5 100644 --- a/app/docker/services/networkService.js +++ b/app/docker/services/networkService.js @@ -19,7 +19,7 @@ angular.module('portainer.docker') service.network = function(id) { var deferred = $q.defer(); - Network.get({id: id}).$promise + Network.get({ id: id }).$promise .then(function success(data) { var network = new NetworkViewModel(data); deferred.resolve(network); @@ -65,5 +65,13 @@ angular.module('portainer.docker') return Network.remove({ id: id }).$promise; }; + service.disconnectContainer = function(networkId, containerId, force) { + return Network.disconnect({ id: networkId }, { Container: containerId, Force: force }).$promise; + }; + + service.connectContainer = function(networkId, containerId) { + return Network.connect({ id: networkId }, { Container: containerId }).$promise; + }; + return service; }]); diff --git a/app/docker/views/containers/console/containerConsoleController.js b/app/docker/views/containers/console/containerConsoleController.js index bf8adee6c..1f9353a77 100644 --- a/app/docker/views/containers/console/containerConsoleController.js +++ b/app/docker/views/containers/console/containerConsoleController.js @@ -1,6 +1,8 @@ angular.module('portainer.docker') -.controller('ContainerConsoleController', ['$scope', '$transition$', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService', -function ($scope, $transition$, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) { +.controller('ContainerConsoleController', ['$scope', '$transition$', 'ContainerService', 'ImageService', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ExecService', 'HttpRequestHelper', +function ($scope, $transition$, ContainerService, ImageService, EndpointProvider, Notifications, ContainerHelper, ExecService, HttpRequestHelper) { + var socket, term; + $scope.state = { loaded: false, connected: false @@ -8,8 +10,6 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification $scope.formValues = {}; - var socket, term; - // Ensure the socket is closed before leaving the view $scope.$on('$stateChangeStart', function (event, next, current) { if (socket && socket !== null) { @@ -17,23 +17,6 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification } }); - Container.get({id: $transition$.params().id}, function(d) { - $scope.container = d; - if (d.message) { - Notifications.error('Error', d, 'Unable to retrieve container details'); - } else { - Image.get({id: d.Image}, function(imgData) { - $scope.imageOS = imgData.Os; - $scope.formValues.command = imgData.Os === 'windows' ? 'powershell' : 'bash'; - $scope.state.loaded = true; - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve image details'); - }); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve container details'); - }); - $scope.connect = function() { var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39); var termHeight = 30; @@ -54,6 +37,9 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification .then(function success(data) { execId = data.Id; var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID(); + if ($transition$.params().nodeName) { + url += '&nodeName=' + $transition$.params().nodeName; + } if (url.indexOf('https') > -1) { url = url.replace('https://', 'wss://'); } else { @@ -108,4 +94,25 @@ function ($scope, $transition$, Container, Image, EndpointProvider, Notification }; }; } + + function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); + ContainerService.container($transition$.params().id) + .then(function success(data) { + var container = data; + $scope.container = container; + return ImageService.image(container.Image); + }) + .then(function success(data) { + var image = data; + $scope.imageOS = image.Os; + $scope.formValues.command = image.Os === 'windows' ? 'powershell' : 'bash'; + $scope.state.loaded = true; + }) + .catch(function error(err) { + Notifications.error('Error', err, 'Unable to retrieve container details'); + }); + } + + initView(); }]); diff --git a/app/docker/views/containers/containers.html b/app/docker/views/containers/containers.html index 0630bbe04..f04727b6d 100644 --- a/app/docker/views/containers/containers.html +++ b/app/docker/views/containers/containers.html @@ -14,6 +14,7 @@ dataset="containers" table-key="containers" order-by="Status" show-text-filter="true" show-ownership-column="applicationState.application.authentication" + show-host-column="applicationState.endpoint.mode.agentProxy" public-url="state.publicURL" container-name-truncate-size="truncate_size" start-action="startAction" diff --git a/app/docker/views/containers/containersController.js b/app/docker/views/containers/containersController.js index 42384fe68..fe7fb6c79 100644 --- a/app/docker/views/containers/containersController.js +++ b/app/docker/views/containers/containersController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') - .controller('ContainersController', ['$q', '$scope', '$state', '$filter', '$transition$', 'ContainerService', 'SystemService', 'Notifications', 'ModalService', 'EndpointProvider', - function ($q, $scope, $state, $filter, $transition$, ContainerService, SystemService, Notifications, ModalService, EndpointProvider) { + .controller('ContainersController', ['$q', '$scope', '$state', '$filter', '$transition$', 'ContainerService', 'SystemService', 'Notifications', 'ModalService', 'EndpointProvider', 'HttpRequestHelper', + function ($q, $scope, $state, $filter, $transition$, ContainerService, SystemService, Notifications, ModalService, EndpointProvider, HttpRequestHelper) { $scope.state = { publicURL: EndpointProvider.endpointPublicURL() }; @@ -70,6 +70,7 @@ angular.module('portainer.docker') function executeActionOnContainerList(containers, action, successMessage, errorMessage) { var actionCount = containers.length; angular.forEach(containers, function (container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); action(container.Id) .then(function success() { Notifications.success(successMessage, container.Names[0]); @@ -89,6 +90,7 @@ angular.module('portainer.docker') function removeAction(containers, cleanVolumes) { var actionCount = containers.length; angular.forEach(containers, function (container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); ContainerService.remove(container, cleanVolumes) .then(function success() { Notifications.success('Container successfully removed', container.Names[0]); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index 8332e8f64..cca719cf0 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.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) { +.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'HttpRequestHelper', +function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, HttpRequestHelper) { $scope.formValues = { alwaysPull: true, @@ -15,7 +15,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai AccessControlData: new AccessControlFormData(), CpuLimit: 0, MemoryLimit: 0, - MemoryReservation: 0 + MemoryReservation: 0, + NodeName: null }; $scope.state = { @@ -388,12 +389,14 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai // Mac Address $scope.formValues.MacAddress = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].MacAddress; // ExtraHosts - var extraHosts = $scope.config.HostConfig.ExtraHosts; - for (var i = 0; i < extraHosts.length; i++) { - var host = extraHosts[i]; - $scope.formValues.ExtraHosts.push({ 'value': host }); + if ($scope.config.HostConfig.ExtraHosts) { + var extraHosts = $scope.config.HostConfig.ExtraHosts; + for (var i = 0; i < extraHosts.length; i++) { + var host = extraHosts[i]; + $scope.formValues.ExtraHosts.push({ 'value': host }); + } + $scope.config.HostConfig.ExtraHosts = []; } - $scope.config.HostConfig.ExtraHosts = []; } function loadFromContainerEnvironmentVariables(d) { @@ -491,6 +494,10 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } function initView() { + var nodeName = $transition$.params().nodeName; + $scope.formValues.NodeName = nodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + Volume.query({}, function (d) { $scope.availableVolumes = d.Volumes; }, function (e) { @@ -520,7 +527,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai Container.query({}, function (d) { var containers = d; $scope.runningContainers = containers; - if ($transition$.params().from !== '') { + if ($transition$.params().from) { loadFromContainerSpec(); } else { $scope.fromContainer = {}; @@ -587,6 +594,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai $scope.state.actionInProgress = true; var config = prepareConfiguration(); + var nodeName = $scope.formValues.NodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); createContainer(config, accessControlData); }) .catch(function error(err) { diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index b77e6f866..fefcfa7b7 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -103,6 +103,16 @@ +
+
+ Deployment +
+ + + + +
diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html index 03e9fba83..dbf9cd64d 100644 --- a/app/docker/views/containers/edit/container.html +++ b/app/docker/views/containers/edit/container.html @@ -20,12 +20,12 @@ -
- - + Duplicate/Edit
@@ -84,10 +84,10 @@ @@ -184,7 +184,7 @@ Image - {{ container.Image }} + {{ container.Image }} Port configuration @@ -259,7 +259,7 @@ {{ vol.Source }} - {{ vol.Name }} + {{ vol.Name }} {{ vol.Destination }} @@ -280,6 +280,7 @@ join-network-action-in-progress="state.joinNetworkInProgress" leave-network-action="containerLeaveNetwork" leave-network-action-in-progress="state.leaveNetworkInProgress" + node-name="nodeName" > diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index 51af3bbae..a0a2f4e60 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', -function ($q, $scope, $state, $transition$, $filter, Container, Commit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService) { +.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService', 'HttpRequestHelper', +function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, ContainerService, ImageHelper, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService, HttpRequestHelper) { $scope.activityTime = 0; $scope.portBindings = []; @@ -16,8 +16,13 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe }; var update = function () { - Container.get({id: $transition$.params().id}, function (d) { - var container = new ContainerDetailsViewModel(d); + var nodeName = $transition$.params().nodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + $scope.nodeName = nodeName; + + ContainerService.container($transition$.params().id) + .then(function success(data) { + var container = data; $scope.container = container; $scope.container.edit = false; $scope.container.newContainerName = $filter('trimcontainername')(container.Name); @@ -41,41 +46,105 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe } }); } - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve container info'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve container info'); }); }; - $scope.start = function () { - Container.start({id: $scope.container.Id}, {}, function (d) { + function executeContainerAction(id, action, successMessage, errorMessage) { + action(id) + .then(function success(data) { + Notifications.success(successMessage, id); update(); - Notifications.success('Container started', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to start container'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, errorMessage); }); + } + + $scope.start = function () { + var successMessage = 'Container successfully started'; + var errorMessage = 'Unable to start container'; + executeContainerAction($transition$.params().id, ContainerService.startContainer, successMessage, errorMessage); }; $scope.stop = function () { - Container.stop({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container stopped', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to stop container'); - }); + var successMessage = 'Container successfully stopped'; + var errorMessage = 'Unable to stop container'; + executeContainerAction($transition$.params().id, ContainerService.stopContainer, successMessage, errorMessage); }; $scope.kill = function () { - Container.kill({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container killed', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to kill container'); + var successMessage = 'Container successfully killed'; + var errorMessage = 'Unable to kill container'; + executeContainerAction($transition$.params().id, ContainerService.killContainer, successMessage, errorMessage); + }; + + $scope.pause = function() { + var successMessage = 'Container successfully paused'; + var errorMessage = 'Unable to pause container'; + executeContainerAction($transition$.params().id, ContainerService.pauseContainer, successMessage, errorMessage); + }; + + $scope.unpause = function() { + var successMessage = 'Container successfully resumed'; + var errorMessage = 'Unable to resume container'; + executeContainerAction($transition$.params().id, ContainerService.resumeContainer, successMessage, errorMessage); + }; + + $scope.restart = function () { + var successMessage = 'Container successfully restarted'; + var errorMessage = 'Unable to restart container'; + executeContainerAction($transition$.params().id, ContainerService.restartContainer, successMessage, errorMessage); + }; + + $scope.renameContainer = function () { + var container = $scope.container; + ContainerService.renameContainer($transition$.params().id, container.newContainerName) + .then(function success(data) { + container.Name = container.newContainerName; + Notifications.success('Container successfully renamed', container.Name); + }) + .catch(function error(err) { + container.newContainerName = container.Name; + Notifications.error('Failure', err, 'Unable to rename container'); + }) + .finally(function final() { + $scope.container.edit = false; }); }; + $scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) { + $scope.state.leaveNetworkInProgress = true; + NetworkService.disconnectContainer(networkId, container.Id, false) + .then(function success(data) { + Notifications.success('Container left network', container.Id); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to disconnect container from network'); + }) + .finally(function final() { + $scope.state.leaveNetworkInProgress = false; + }); + }; + + $scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) { + $scope.state.joinNetworkInProgress = true; + NetworkService.connectContainer(networkId, container.Id) + .then(function success(data) { + Notifications.success('Container joined network', container.Id); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect container to network'); + }) + .finally(function final() { + $scope.state.joinNetworkInProgress = false; + }); + }; + $scope.commit = function () { var image = $scope.config.Image; var registry = $scope.config.Registry; @@ -89,25 +158,6 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe }); }; - $scope.pause = function () { - Container.pause({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container paused', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to pause container'); - }); - }; - - $scope.unpause = function () { - Container.unpause({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container unpaused', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to unpause container'); - }); - }; $scope.confirmRemove = function () { var title = 'You are about to remove a container.'; @@ -122,12 +172,12 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe if (result[0]) { cleanAssociatedVolumes = true; } - $scope.remove(cleanAssociatedVolumes); + removeContainer(cleanAssociatedVolumes); } ); }; - $scope.remove = function(cleanAssociatedVolumes) { + function removeContainer(cleanAssociatedVolumes) { ContainerService.remove($scope.container, cleanAssociatedVolumes) .then(function success() { Notifications.success('Container successfully removed'); @@ -136,71 +186,7 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe .catch(function error(err) { Notifications.error('Failure', err, 'Unable to remove container'); }); - }; - - $scope.restart = function () { - Container.restart({id: $transition$.params().id}, function (d) { - update(); - Notifications.success('Container restarted', $transition$.params().id); - }, function (e) { - update(); - Notifications.error('Failure', e, 'Unable to restart container'); - }); - }; - - $scope.renameContainer = function () { - var container = $scope.container; - 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); - } else { - container.Name = container.newContainerName; - Notifications.success('Container successfully renamed', container.Name); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to rename container'); - }); - $scope.container.edit = false; - }; - - $scope.containerLeaveNetwork = function containerLeaveNetwork(container, networkId) { - $scope.state.leaveNetworkInProgress = true; - Network.disconnect({id: networkId}, { Container: $transition$.params().id, Force: false }, function (d) { - if (container.message) { - Notifications.error('Error', d, 'Unable to disconnect container from network'); - } else { - Notifications.success('Container left network', $transition$.params().id); - $state.go('docker.containers.container', {id: $transition$.params().id}, {reload: true}); - } - $scope.state.leaveNetworkInProgress = false; - }, function (e) { - Notifications.error('Failure', e, 'Unable to disconnect container from network'); - $scope.state.leaveNetworkInProgress = false; - }); - }; - - $scope.duplicate = function() { - $state.go('docker.containers.new', {from: $transition$.params().id}, {reload: true}); - }; - - $scope.confirmRemove = function () { - var title = 'You are about to remove a container.'; - if ($scope.container.State.Running) { - title = 'You are about to remove a running container.'; - } - ModalService.confirmContainerDeletion( - title, - function (result) { - if(!result) { return; } - var cleanAssociatedVolumes = false; - if (result[0]) { - cleanAssociatedVolumes = true; - } - $scope.remove(cleanAssociatedVolumes); - } - ); - }; + } function recreateContainer(pullImage) { var container = $scope.container; @@ -253,22 +239,6 @@ function ($q, $scope, $state, $transition$, $filter, Container, Commit, Containe }); }; - $scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) { - $scope.state.joinNetworkInProgress = true; - Network.connect({id: networkId}, { Container: $transition$.params().id }, function (d) { - if (container.message) { - Notifications.error('Error', d, 'Unable to connect container to network'); - } else { - Notifications.success('Container joined network', $transition$.params().id); - $state.go('docker.containers.container', {id: $transition$.params().id}, {reload: true}); - } - $scope.state.joinNetworkInProgress = false; - }, function (e) { - Notifications.error('Failure', e, 'Unable to connect container to network'); - $scope.state.joinNetworkInProgress = false; - }); - }; - var provider = $scope.applicationState.endpoint.mode.provider; var apiVersion = $scope.applicationState.endpoint.apiVersion; NetworkService.networks( diff --git a/app/docker/views/containers/inspect/containerInspectController.js b/app/docker/views/containers/inspect/containerInspectController.js index 27d7b21f2..458a0e6a4 100644 --- a/app/docker/views/containers/inspect/containerInspectController.js +++ b/app/docker/views/containers/inspect/containerInspectController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', -function ($scope, $transition$, Notifications, ContainerService) { +.controller('ContainerInspectController', ['$scope', '$transition$', 'Notifications', 'ContainerService', 'HttpRequestHelper', +function ($scope, $transition$, Notifications, ContainerService, HttpRequestHelper) { $scope.state = { DisplayTextView: false @@ -8,6 +8,7 @@ function ($scope, $transition$, Notifications, ContainerService) { $scope.containerInfo = {}; function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); ContainerService.inspect($transition$.params().id) .then(function success(d) { $scope.containerInfo = d; diff --git a/app/docker/views/containers/logs/containerLogsController.js b/app/docker/views/containers/logs/containerLogsController.js index 0c7f926ee..2141a1f3b 100644 --- a/app/docker/views/containers/logs/containerLogsController.js +++ b/app/docker/views/containers/logs/containerLogsController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerLogsController', ['$scope', '$transition$', '$interval', 'ContainerService', 'Notifications', -function ($scope, $transition$, $interval, ContainerService, Notifications) { +.controller('ContainerLogsController', ['$scope', '$transition$', '$interval', 'ContainerService', 'Notifications', 'HttpRequestHelper', +function ($scope, $transition$, $interval, ContainerService, Notifications, HttpRequestHelper) { $scope.state = { refreshRate: 3, lineCount: 2000, @@ -58,6 +58,7 @@ function ($scope, $transition$, $interval, ContainerService, Notifications) { } function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); ContainerService.container($transition$.params().id) .then(function success(data) { var container = data; diff --git a/app/docker/views/containers/stats/containerStatsController.js b/app/docker/views/containers/stats/containerStatsController.js index 554db948f..ed161c8d1 100644 --- a/app/docker/views/containers/stats/containerStatsController.js +++ b/app/docker/views/containers/stats/containerStatsController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ContainerStatsController', ['$q', '$scope', '$transition$', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', -function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications) { +.controller('ContainerStatsController', ['$q', '$scope', '$transition$', '$document', '$interval', 'ContainerService', 'ChartService', 'Notifications', 'HttpRequestHelper', +function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications, HttpRequestHelper) { $scope.state = { refreshRate: '5', @@ -126,6 +126,7 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char } function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); ContainerService.container($transition$.params().id) .then(function success(data) { $scope.container = data; diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 638518c78..6f70f696d 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -3,7 +3,13 @@ Dashboard -
+
+
+ +
+
+ +
diff --git a/app/docker/views/images/build/buildImageController.js b/app/docker/views/images/build/buildImageController.js index 4b28b4492..5f4aec623 100644 --- a/app/docker/views/images/build/buildImageController.js +++ b/app/docker/views/images/build/buildImageController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('BuildImageController', ['$scope', '$state', 'BuildService', 'Notifications', -function ($scope, $state, BuildService, Notifications) { +.controller('BuildImageController', ['$scope', '$state', 'BuildService', 'Notifications', 'HttpRequestHelper', +function ($scope, $state, BuildService, Notifications, HttpRequestHelper) { $scope.state = { BuildType: 'editor', @@ -13,7 +13,8 @@ function ($scope, $state, BuildService, Notifications) { UploadFile: null, DockerFileContent: '', URL: '', - Path: 'Dockerfile' + Path: 'Dockerfile', + NodeName: null }; $scope.addImageName = function() { @@ -56,6 +57,9 @@ function ($scope, $state, BuildService, Notifications) { return x.Name; }); + var nodeName = $scope.formValues.NodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + buildImageBasedOnBuildType(buildType, imageNames) .then(function success(data) { $scope.buildLogs = data.buildLogs; diff --git a/app/docker/views/images/build/buildimage.html b/app/docker/views/images/build/buildimage.html index 53e00acd9..9a6e23b51 100644 --- a/app/docker/views/images/build/buildimage.html +++ b/app/docker/views/images/build/buildimage.html @@ -194,6 +194,16 @@
+
+
+ Deployment +
+ + + + +
Actions diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js index 2e59fe912..236f1633f 100644 --- a/app/docker/views/images/edit/imageController.js +++ b/app/docker/views/images/edit/imageController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', -function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryService, Notifications) { +.controller('ImageController', ['$q', '$scope', '$transition$', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications', 'HttpRequestHelper', +function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryService, Notifications, HttpRequestHelper) { $scope.formValues = { Image: '', Registry: '' @@ -98,6 +98,7 @@ function ($q, $scope, $transition$, $state, $timeout, ImageService, RegistryServ }; function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); var endpointProvider = $scope.applicationState.endpoint.mode.provider; $q.all({ image: ImageService.image($transition$.params().id), diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index 2dd679bda..1b6c807c3 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -26,6 +26,16 @@
+
+
+ Deployment +
+ + + + +
+
+
+ Deployment +
+ + + + +
diff --git a/app/docker/views/networks/edit/network.html b/app/docker/views/networks/edit/network.html index 87527f91a..b9e269187 100644 --- a/app/docker/views/networks/edit/network.html +++ b/app/docker/views/networks/edit/network.html @@ -89,12 +89,12 @@ - {{ container.Name }} + {{ container.Name }} {{ container.IPv4Address || '-' }} {{ container.IPv6Address || '-' }} {{ container.MacAddress || '-' }} - + diff --git a/app/docker/views/networks/edit/networkController.js b/app/docker/views/networks/edit/networkController.js index b4b889bcd..0ba905c2a 100644 --- a/app/docker/views/networks/edit/networkController.js +++ b/app/docker/views/networks/edit/networkController.js @@ -1,30 +1,27 @@ angular.module('portainer.docker') -.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'Network', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', -function ($scope, $state, $transition$, $filter, Network, NetworkService, Container, ContainerHelper, Notifications) { +.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'NetworkService', 'Container', 'ContainerHelper', 'Notifications', 'HttpRequestHelper', +function ($scope, $state, $transition$, $filter, NetworkService, Container, ContainerHelper, Notifications, HttpRequestHelper) { $scope.removeNetwork = function removeNetwork(networkId) { - Network.remove({id: $transition$.params().id}, function (d) { - if (d.message) { - Notifications.error('Error', d, 'Unable to remove network'); - } else { - Notifications.success('Network removed', $transition$.params().id); - $state.go('docker.networks', {}); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to remove network'); + NetworkService.remove($transition$.params().id, $transition$.params().id) + .then(function success(data) { + Notifications.success('Network removed', $transition$.params().id); + $state.go('docker.networks', {}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove network'); }); }; - $scope.containerLeaveNetwork = function containerLeaveNetwork(network, containerId) { - Network.disconnect({id: $transition$.params().id}, { Container: containerId, Force: false }, function (d) { - if (d.message) { - Notifications.error('Error', d, 'Unable to disconnect container from network'); - } else { - Notifications.success('Container left network', $transition$.params().id); - $state.go('docker.networks.network', {id: network.Id}, {reload: true}); - } - }, function (e) { - Notifications.error('Failure', e, 'Unable to disconnect container from network'); + $scope.containerLeaveNetwork = function containerLeaveNetwork(network, container) { + HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName); + NetworkService.disconnectContainer($transition$.params().id, container.Id, false) + .then(function success(data) { + Notifications.success('Container left network', $transition$.params().id); + $state.go('docker.networks.network', { id: network.Id }, { reload: true }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to disconnect container from network'); }); }; @@ -71,6 +68,9 @@ function ($scope, $state, $transition$, $filter, Network, NetworkService, Contai } function initView() { + var nodeName = $transition$.params().nodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + $scope.nodeName = nodeName; NetworkService.network($transition$.params().id) .then(function success(data) { $scope.network = data; diff --git a/app/docker/views/networks/networks.html b/app/docker/views/networks/networks.html index de09d8d4b..1c224f0da 100644 --- a/app/docker/views/networks/networks.html +++ b/app/docker/views/networks/networks.html @@ -15,6 +15,7 @@ order-by="Name" show-text-filter="true" remove-action="removeAction" show-ownership-column="applicationState.application.authentication" + show-host-column="applicationState.endpoint.mode.agentProxy" > diff --git a/app/docker/views/networks/networksController.js b/app/docker/views/networks/networksController.js index c5ff237b5..013b3bb35 100644 --- a/app/docker/views/networks/networksController.js +++ b/app/docker/views/networks/networksController.js @@ -1,10 +1,11 @@ angular.module('portainer.docker') -.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', -function ($scope, $state, NetworkService, Notifications) { +.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', +function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; angular.forEach(selectedItems, function (network) { + HttpRequestHelper.setPortainerAgentTargetHeader(network.NodeName); NetworkService.remove(network.Id) .then(function success() { Notifications.success('Network successfully removed', network.Name); diff --git a/app/docker/views/services/edit/includes/tasks.html b/app/docker/views/services/edit/includes/tasks.html index 369276b9a..6730948aa 100644 --- a/app/docker/views/services/edit/includes/tasks.html +++ b/app/docker/views/services/edit/includes/tasks.html @@ -7,5 +7,6 @@ show-text-filter="true" show-slot-column="service.Mode !== 'global'" show-logs-button="applicationState.endpoint.apiVersion >= 1.30" + agent-powered="applicationState.endpoint.mode.agentProxy" > diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js index eb317988d..a809dd746 100644 --- a/app/docker/views/services/edit/serviceController.js +++ b/app/docker/views/services/edit/serviceController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'ImageService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'ModalService', 'PluginService', 'Authentication', 'SettingsService', 'VolumeService', -function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, ImageService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, ModalService, PluginService, Authentication, SettingsService, VolumeService) { +.controller('ServiceController', ['$q', '$scope', '$transition$', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'ConfigService', 'ConfigHelper', 'SecretService', 'ImageService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'ContainerService', 'TaskHelper', 'Notifications', 'ModalService', 'PluginService', 'Authentication', 'SettingsService', 'VolumeService', +function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, ServiceService, ConfigService, ConfigHelper, SecretService, ImageService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, ContainerService, TaskHelper, Notifications, ModalService, PluginService, Authentication, SettingsService, VolumeService) { $scope.state = { updateInProgress: false, @@ -407,6 +407,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, function initView() { var apiVersion = $scope.applicationState.endpoint.apiVersion; + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; ServiceService.service($transition$.params().id) .then(function success(data) { @@ -425,6 +426,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, return $q.all({ volumes: VolumeService.volumes(), tasks: TaskService.tasks({ service: [service.Name] }), + containers: agentProxy ? ContainerService.containers() : [], nodes: NodeService.nodes(), secrets: apiVersion >= 1.25 ? SecretService.secrets() : [], configs: apiVersion >= 1.30 ? ConfigService.configs() : [], @@ -434,7 +436,6 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, }); }) .then(function success(data) { - $scope.tasks = data.tasks; $scope.nodes = data.nodes; $scope.configs = data.configs; $scope.secrets = data.secrets; @@ -445,6 +446,19 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll, var userDetails = Authentication.getUserDetails(); $scope.isAdmin = userDetails.role === 1; + var tasks = data.tasks; + + if (agentProxy) { + var containers = data.containers; + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + TaskHelper.associateContainerToTask(task, containers); + } + } + + $scope.tasks = data.tasks; + + // Set max cpu value var maxCpus = 0; for (var n in data.nodes) { diff --git a/app/docker/views/stacks/edit/stack.html b/app/docker/views/stacks/edit/stack.html index 9ec0404ad..b14dcdd61 100644 --- a/app/docker/views/stacks/edit/stack.html +++ b/app/docker/views/stacks/edit/stack.html @@ -42,6 +42,7 @@ show-text-filter="true" show-slot-column="true" show-logs-button="applicationState.endpoint.apiVersion >= 1.30" + agent-powered="applicationState.endpoint.mode.agentProxy" > diff --git a/app/docker/views/stacks/edit/stackController.js b/app/docker/views/stacks/edit/stackController.js index 4c84d4db1..e6b5f8b9f 100644 --- a/app/docker/views/stacks/edit/stackController.js +++ b/app/docker/views/stacks/edit/stackController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'Notifications', 'FormHelper', 'EndpointProvider', -function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ServiceHelper, Notifications, FormHelper, EndpointProvider) { +.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ContainerService', 'ServiceHelper', 'TaskHelper', 'Notifications', 'FormHelper', 'EndpointProvider', +function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ContainerService, ServiceHelper, TaskHelper, Notifications, FormHelper, EndpointProvider) { $scope.state = { actionInProgress: false, @@ -41,6 +41,7 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe function initView() { var stackId = $transition$.params().id; var apiVersion = $scope.applicationState.endpoint.apiVersion; + var agentProxy = $scope.applicationState.endpoint.mode.agentProxy; StackService.stack(stackId) .then(function success(data) { @@ -55,24 +56,31 @@ function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceSe stackFile: StackService.getStackFile(stackId), services: ServiceService.services(serviceFilters), tasks: TaskService.tasks(serviceFilters), + containers: agentProxy ? ContainerService.containers() : [], nodes: NodeService.nodes() }); }) .then(function success(data) { $scope.stackFileContent = data.stackFile; - $scope.nodes = data.nodes; var services = data.services; - var tasks = data.tasks; - $scope.tasks = tasks; for (var i = 0; i < services.length; i++) { var service = services[i]; ServiceHelper.associateTasksToService(service, tasks); } + if (agentProxy) { + var containers = data.containers; + for (var j = 0; j < tasks.length; j++) { + var task = tasks[j]; + TaskHelper.associateContainerToTask(task, containers); + } + } + + $scope.tasks = tasks; $scope.services = services; }) .catch(function error(err) { diff --git a/app/docker/views/volumes/create/createVolumeController.js b/app/docker/views/volumes/create/createVolumeController.js index 45478d094..5180f3976 100644 --- a/app/docker/views/volumes/create/createVolumeController.js +++ b/app/docker/views/volumes/create/createVolumeController.js @@ -1,11 +1,12 @@ angular.module('portainer.docker') -.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', -function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator) { +.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', 'HttpRequestHelper', +function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator, HttpRequestHelper) { $scope.formValues = { Driver: 'local', DriverOptions: [], - AccessControlData: new AccessControlFormData() + AccessControlData: new AccessControlFormData(), + NodeName: null }; $scope.state = { @@ -55,6 +56,9 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi return; } + var nodeName = $scope.formValues.NodeName; + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + $scope.state.actionInProgress = true; VolumeService.createVolume(volumeConfiguration) .then(function success(data) { @@ -76,7 +80,7 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi function initView() { var apiVersion = $scope.applicationState.endpoint.apiVersion; - var endpointProvider = $scope.applicationState.endpoint.provider; + var endpointProvider = $scope.applicationState.endpoint.mode.provider; PluginService.volumePlugins(apiVersion < 1.25 || endpointProvider === 'VMWARE_VIC') .then(function success(data) { diff --git a/app/docker/views/volumes/create/createvolume.html b/app/docker/views/volumes/create/createvolume.html index 25c222e1c..27046bc97 100644 --- a/app/docker/views/volumes/create/createvolume.html +++ b/app/docker/views/volumes/create/createvolume.html @@ -6,7 +6,7 @@
-
+
@@ -70,6 +70,16 @@
+
+
+ Deployment +
+ + + + +
diff --git a/app/docker/views/volumes/edit/volume.html b/app/docker/views/volumes/edit/volume.html index 1b017ec95..0392e8cb3 100644 --- a/app/docker/views/volumes/edit/volume.html +++ b/app/docker/views/volumes/edit/volume.html @@ -84,7 +84,7 @@ - {{ container | containername }} + {{ container | containername }} {{ container.volumeData.Destination }} {{ !container.volumeData.RW }} diff --git a/app/docker/views/volumes/edit/volumeController.js b/app/docker/views/volumes/edit/volumeController.js index 61581ecb6..f314c1b3b 100644 --- a/app/docker/views/volumes/edit/volumeController.js +++ b/app/docker/views/volumes/edit/volumeController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications', -function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications) { +.controller('VolumeController', ['$scope', '$state', '$transition$', 'VolumeService', 'ContainerService', 'Notifications', 'HttpRequestHelper', +function ($scope, $state, $transition$, VolumeService, ContainerService, Notifications, HttpRequestHelper) { $scope.removeVolume = function removeVolume() { VolumeService.remove($scope.volume) @@ -20,6 +20,7 @@ function ($scope, $state, $transition$, VolumeService, ContainerService, Notific } function initView() { + HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); VolumeService.volume($transition$.params().id) .then(function success(data) { var volume = data; diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html index 85b57a11c..b31614b21 100644 --- a/app/docker/views/volumes/volumes.html +++ b/app/docker/views/volumes/volumes.html @@ -15,6 +15,7 @@ order-by="Id" show-text-filter="true" remove-action="removeAction" show-ownership-column="applicationState.application.authentication" + show-host-column="applicationState.endpoint.mode.agentProxy" >
diff --git a/app/docker/views/volumes/volumesController.js b/app/docker/views/volumes/volumesController.js index 30882a35d..362c54b4b 100644 --- a/app/docker/views/volumes/volumesController.js +++ b/app/docker/views/volumes/volumesController.js @@ -1,10 +1,11 @@ angular.module('portainer.docker') -.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', -function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications) { +.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', +function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; angular.forEach(selectedItems, function (volume) { + HttpRequestHelper.setPortainerAgentTargetHeader(volume.NodeName); VolumeService.remove(volume) .then(function success() { Notifications.success('Volume successfully removed', volume.Id); diff --git a/app/portainer/__module.js b/app/portainer/__module.js index ca94b581b..77caaa70c 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -121,6 +121,17 @@ angular.module('portainer.app', []) } }; + var endpointCreation = { + name: 'portainer.endpoints.new', + url: '/new', + views: { + 'content@': { + templateUrl: 'app/portainer/views/endpoints/create/createendpoint.html', + controller: 'CreateEndpointController' + } + } + }; + var endpointAccess = { name: 'portainer.endpoints.endpoint.access', url: '/access', @@ -297,6 +308,7 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(endpoints); $stateRegistryProvider.register(endpoint); $stateRegistryProvider.register(endpointAccess); + $stateRegistryProvider.register(endpointCreation); $stateRegistryProvider.register(groups); $stateRegistryProvider.register(group); $stateRegistryProvider.register(groupAccess); diff --git a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html index d3006a093..c22dfdc5e 100644 --- a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html +++ b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html @@ -16,6 +16,9 @@ ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"> Remove + -
+
All the endpoints are assigned to a group.
diff --git a/app/portainer/services/httpRequestHelper.js b/app/portainer/services/httpRequestHelper.js index 857d47d7f..1d5b5dbe8 100644 --- a/app/portainer/services/httpRequestHelper.js +++ b/app/portainer/services/httpRequestHelper.js @@ -4,6 +4,7 @@ angular.module('portainer.app') var service = {}; var headers = {}; + headers.agentTargetQueue = []; service.registryAuthenticationHeader = function() { return headers.registryAuthentication; @@ -13,5 +14,32 @@ angular.module('portainer.app') headers.registryAuthentication = headerValue; }; + // Due to the fact that async HTTP requests are decorated using an interceptor + // we need to store and retrieve the headers using a first-in-first-out (FIFO) data structure. + // Otherwise, sequential HTTP requests might end up using the same header value (incorrect in the case + // of starting multiple containers on different nodes for example). + // To prevent having to use the HttpRequestHelper.setPortainerAgentTargetHeader before EACH request, + // we re-use the latest available header in the data structure (handy in thee case of multiple requests affecting + // the same node in the same view). + service.portainerAgentTargetHeader = function() { + if (headers.agentTargetQueue.length === 0) { + return headers.agentTargetLastValue; + } else if (headers.agentTargetQueue.length === 1) { + headers.agentTargetLastValue = headers.agentTargetQueue[0]; + } + return headers.agentTargetQueue.shift(); + }; + + service.setPortainerAgentTargetHeader = function(headerValue) { + if (headerValue) { + headers.agentTargetQueue.push(headerValue); + } + }; + + service.resetAgentTargetQueue = function() { + headers.agentTargetQueue = []; + delete headers.agentTargetLastValue; + }; + return service; }]); diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 3b9d843dd..c7322a4c3 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -122,18 +122,18 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin return extensions; } - manager.updateEndpointState = function(loading, extensions) { + manager.updateEndpointState = function(loading, type, extensions) { var deferred = $q.defer(); if (loading) { state.loading = true; } $q.all({ - info: SystemService.info(), - version: SystemService.version() + version: SystemService.version(), + info: SystemService.info() }) .then(function success(data) { - var endpointMode = InfoHelper.determineEndpointMode(data.info); + var endpointMode = InfoHelper.determineEndpointMode(data.info, type); var endpointAPIVersion = parseFloat(data.version.ApiVersion); state.endpoint.mode = endpointMode; state.endpoint.apiVersion = endpointAPIVersion; diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 300372b75..7b53dfe91 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -22,7 +22,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica ExtensionManager.initEndpointExtensions(endpoint.Id) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(true, extensions); + return StateManager.updateEndpointState(true, endpoint.Type, extensions); }) .then(function success(data) { $state.go('docker.dashboard'); diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js new file mode 100644 index 000000000..bda06746d --- /dev/null +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -0,0 +1,71 @@ +angular.module('portainer.app') +.controller('CreateEndpointController', ['$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'Notifications', +function ($scope, $state, $filter, EndpointService, GroupService, Notifications) { + + $scope.state = { + EnvironmentType: 'docker', + actionInProgress: false + }; + + $scope.formValues = { + Name: '', + URL: '', + PublicURL: '', + GroupId: 1, + SecurityFormData: new EndpointSecurityFormData() + }; + + $scope.addDockerEndpoint = function() { + var name = $scope.formValues.Name; + var URL = $filter('stripprotocol')($scope.formValues.URL); + var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; + var groupId = $scope.formValues.GroupId; + + 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; + + addEndpoint(name, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + }; + + $scope.addAgentEndpoint = function() { + var name = $scope.formValues.Name; + var URL = $filter('stripprotocol')($scope.formValues.URL); + var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; + var groupId = $scope.formValues.GroupId; + + addEndpoint(name, URL, publicURL, groupId, true, true, true, null, null, null); + }; + + function addEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + $scope.state.actionInProgress = true; + EndpointService.createRemoteEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + .then(function success() { + Notifications.success('Endpoint created', name); + $state.go('portainer.endpoints', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create endpoint'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function initView() { + GroupService.groups() + .then(function success(data) { + $scope.groups = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load groups'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html new file mode 100644 index 000000000..1ab647c4b --- /dev/null +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -0,0 +1,141 @@ + + + + Endpoints > Add endpoint + + + +
+
+ + + +
+ Environment type +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+ Important notice +
+
+ + The Docker API must be exposed over TCP. You can find more information about how to expose the Docker API over TCP in the Docker documentation. + +
+
+
+
+ Information +
+
+ + If you have started Portainer in the same overlay network as the agent, you can use tasks.AGENT_SERVICE_NAME:AGENT_SERVICE_PORT as the + endpoint URL format. + +
+
+
+ Environment details +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ + +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + + + + +
+
+ + +
+
+ + +
+
+
+
diff --git a/app/portainer/views/endpoints/endpoints.html b/app/portainer/views/endpoints/endpoints.html index a10d4acf3..75459a89a 100644 --- a/app/portainer/views/endpoints/endpoints.html +++ b/app/portainer/views/endpoints/endpoints.html @@ -8,7 +8,7 @@
-
+
@@ -22,72 +22,6 @@
-
-
- - - - -
- -
- -
- -
-
- - -
- -
- -
-
- - -
- -
- -
-
- - -
- -
- -
-
- - - - - -
-
- -
-
- -
-
-
-
-
-
-
+
@@ -29,36 +29,49 @@
+
+ + +
+
+ Information +
-

- - This feature is not yet available for native Docker Windows containers. -

- Please ensure that you have started the Portainer container with the following Docker flag -v "/var/run/docker.sock:/var/run/docker.sock" in order to connect to the local Docker environment. + Manage the Docker environment where Portainer is running using the Unix filesystem socket. +

+

+ + Ensure that you have started the Portainer container with the following Docker flag: -v "/var/run/docker.sock:/var/run/docker.sock".

@@ -75,8 +88,80 @@
+ +
+
+ Information +
+
+
+ +

+ Connect directly to a Portainer agent running inside a Swarm cluster. +

+

+ + If you have started Portainer in the same overlay network as the agent, you can use tasks.AGENT_SERVICE_NAME:AGENT_SERVICE_PORT as the + endpoint URL format. +

+
+
+
+
+ Environment +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
+
+ Information +
+
+
+ +

+ Connect Portainer to a remote Docker environment using the Docker API over TCP. +

+

+ + The Docker API must be exposed over TCP. You can find more information about how to expose the Docker API over TCP in the Docker documentation. +

+
+
+
+
+ Environment +
@@ -89,7 +174,7 @@
diff --git a/app/portainer/views/init/endpoint/initEndpointController.js b/app/portainer/views/init/endpoint/initEndpointController.js index e12e2f476..67d9da7b1 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -28,18 +28,18 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif $scope.createLocalEndpoint = function() { var name = 'local'; var URL = 'unix:///var/run/docker.sock'; - var endpointID = 1; + var endpoint; $scope.state.actionInProgress = true; EndpointService.createLocalEndpoint() .then(function success(data) { - endpointID = data.Id; - EndpointProvider.setEndpointID(endpointID); - return ExtensionManager.initEndpointExtensions(endpointID); + endpoint = data; + EndpointProvider.setEndpointID(endpoint.Id); + return ExtensionManager.initEndpointExtensions(endpoint.Id); }) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(false, extensions); + return StateManager.updateEndpointState(false, endpoint.Type, extensions); }) .then(function success(data) { $state.go('docker.dashboard'); @@ -52,6 +52,14 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif }); }; + $scope.createAgentEndpoint = function() { + var name = $scope.formValues.Name; + var URL = $scope.formValues.URL; + var PublicURL = URL.split(':')[0]; + + createRemoteEndpoint(name, URL, PublicURL, true, true, true, null, null, null); + }; + $scope.createRemoteEndpoint = function() { var name = $scope.formValues.Name; var URL = $scope.formValues.URL; @@ -62,18 +70,22 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif var TLSCAFile = TLSSkipVerify ? null : $scope.formValues.TLSCACert; var TLSCertFile = TLSSKipClientVerify ? null : $scope.formValues.TLSCert; var TLSKeyFile = TLSSKipClientVerify ? null : $scope.formValues.TLSKey; - var endpointID = 1; + createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + }; + + function createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + var endpoint; $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createRemoteEndpoint(name, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(data) { - endpointID = data.Id; - EndpointProvider.setEndpointID(endpointID); - return ExtensionManager.initEndpointExtensions(endpointID); + endpoint = data; + EndpointProvider.setEndpointID(endpoint.Id); + return ExtensionManager.initEndpointExtensions(endpoint.Id); }) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(false, extensions); + return StateManager.updateEndpointState(false, endpoint.Type, extensions); }) .then(function success(data) { $state.go('docker.dashboard'); @@ -84,5 +96,5 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif .finally(function final() { $scope.state.actionInProgress = false; }); - }; + } }]); diff --git a/app/portainer/views/sidebar/sidebarController.js b/app/portainer/views/sidebar/sidebarController.js index 910e93b70..d730415ce 100644 --- a/app/portainer/views/sidebar/sidebarController.js +++ b/app/portainer/views/sidebar/sidebarController.js @@ -3,28 +3,40 @@ angular.module('portainer.app') function ($q, $scope, $state, EndpointService, GroupService, StateManager, EndpointProvider, Notifications, Authentication, UserService, ExtensionManager) { $scope.switchEndpoint = function(endpoint) { - var activeEndpointID = EndpointProvider.endpointID(); - var activeEndpointPublicURL = EndpointProvider.endpointPublicURL(); EndpointProvider.setEndpointID(endpoint.Id); EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - ExtensionManager.initEndpointExtensions(endpoint.Id) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(true, extensions); + return StateManager.updateEndpointState(true, endpoint.Type, extensions); }) .then(function success() { + $scope.currentEndpoint = endpoint; $state.go('docker.dashboard'); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); - EndpointProvider.setEndpointID(activeEndpointID); - EndpointProvider.setEndpointPublicURL(activeEndpointPublicURL); - StateManager.updateEndpointState(true) - .then(function success() {}); + var currentEndpoint = $scope.currentEndpoint; + EndpointProvider.setEndpointID(currentEndpoint.Id); + EndpointProvider.setEndpointPublicURL(currentEndpoint.PublicURL); + return StateManager.updateEndpointState(true, currentEndpoint.Type, currentEndpoint.Extensions); }); }; + function setActiveEndpoint(endpoints) { + var activeEndpointID = EndpointProvider.endpointID(); + + for (var i = 0; i < endpoints.length; i++) { + var endpoint = endpoints[i]; + if (endpoint.Id === activeEndpointID) { + $scope.activeEndpoint = endpoint; + $scope.currentEndpoint = endpoint; + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + break; + } + } + } + function checkPermissions(memberships) { var isLeader = false; angular.forEach(memberships, function(membership) { @@ -49,14 +61,7 @@ function ($q, $scope, $state, EndpointService, GroupService, StateManager, Endpo $scope.groups = data.groups; $scope.endpoints = endpoints; - var activeEndpointID = EndpointProvider.endpointID(); - for (var i = 0; i < endpoints.length; i++) { - var endpoint = endpoints[i]; - if (endpoint.Id === activeEndpointID) { - $scope.activeEndpoint = endpoint; - break; - } - } + setActiveEndpoint(endpoints); if (StateManager.getState().application.authentication) { var userDetails = Authentication.getUserDetails(); diff --git a/assets/css/app.css b/assets/css/app.css index 4475b6d13..16ab7489b 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -119,6 +119,10 @@ a[ng-click]{ color: white; } +.fa.blue-icon { + color: #337ab7; +} + .widget .widget-body table tbody .image-tag { font-size: 90% !important; margin-right: 5px; @@ -133,7 +137,7 @@ a[ng-click]{ } .widget .widget-body table tbody td { - white-space: nowrap; + white-space: nowrap; } .template-widget { @@ -560,6 +564,10 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { font-weight: bold; } +.boxselector_header .fa,.fab { + font-weight: normal; +} + .boxselector_wrapper input[type="radio"] { display: none; }