mirror of
https://github.com/portainer/portainer.git
synced 2025-08-04 05:15:25 +02:00
Merge branch 'release/1.19.0'
This commit is contained in:
commit
dc9512f25c
257 changed files with 6395 additions and 1739 deletions
|
@ -18,7 +18,7 @@ steps:
|
||||||
- mv api/cmd/portainer/portainer dist/
|
- mv api/cmd/portainer/portainer dist/
|
||||||
|
|
||||||
get_docker_version:
|
get_docker_version:
|
||||||
image: alpine
|
image: alpine:3.7
|
||||||
working_directory: ${{build_frontend}}
|
working_directory: ${{build_frontend}}
|
||||||
commands:
|
commands:
|
||||||
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
|
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
|
||||||
|
|
|
@ -18,7 +18,7 @@ steps:
|
||||||
- mv api/cmd/portainer/portainer dist/
|
- mv api/cmd/portainer/portainer dist/
|
||||||
|
|
||||||
get_docker_version:
|
get_docker_version:
|
||||||
image: alpine
|
image: alpine:3.7
|
||||||
working_directory: ${{build_frontend}}
|
working_directory: ${{build_frontend}}
|
||||||
commands:
|
commands:
|
||||||
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
|
- cf_export DOCKER_VERSION=`cat gruntfile.js | grep -m 1 'shippedDockerVersion' | cut -d\' -f2`
|
||||||
|
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/portainer/portainer/bolt/tag"
|
"github.com/portainer/portainer/bolt/tag"
|
||||||
"github.com/portainer/portainer/bolt/team"
|
"github.com/portainer/portainer/bolt/team"
|
||||||
"github.com/portainer/portainer/bolt/teammembership"
|
"github.com/portainer/portainer/bolt/teammembership"
|
||||||
|
"github.com/portainer/portainer/bolt/template"
|
||||||
"github.com/portainer/portainer/bolt/user"
|
"github.com/portainer/portainer/bolt/user"
|
||||||
"github.com/portainer/portainer/bolt/version"
|
"github.com/portainer/portainer/bolt/version"
|
||||||
)
|
)
|
||||||
|
@ -43,6 +44,7 @@ type Store struct {
|
||||||
TagService *tag.Service
|
TagService *tag.Service
|
||||||
TeamMembershipService *teammembership.Service
|
TeamMembershipService *teammembership.Service
|
||||||
TeamService *team.Service
|
TeamService *team.Service
|
||||||
|
TemplateService *template.Service
|
||||||
UserService *user.Service
|
UserService *user.Service
|
||||||
VersionService *version.Service
|
VersionService *version.Service
|
||||||
}
|
}
|
||||||
|
@ -212,6 +214,12 @@ func (store *Store) initServices() error {
|
||||||
}
|
}
|
||||||
store.TeamService = teamService
|
store.TeamService = teamService
|
||||||
|
|
||||||
|
templateService, err := template.NewService(store.db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.TemplateService = templateService
|
||||||
|
|
||||||
userService, err := user.NewService(store.db)
|
userService, err := user.NewService(store.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -82,8 +82,11 @@ func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error {
|
||||||
return service.db.Update(func(tx *bolt.Tx) error {
|
return service.db.Update(func(tx *bolt.Tx) error {
|
||||||
bucket := tx.Bucket([]byte(BucketName))
|
bucket := tx.Bucket([]byte(BucketName))
|
||||||
|
|
||||||
id, _ := bucket.NextSequence()
|
// We manually manage sequences for endpoints
|
||||||
endpoint.ID = portainer.EndpointID(id)
|
err := bucket.SetSequence(uint64(endpoint.ID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
data, err := internal.MarshalObject(endpoint)
|
data, err := internal.MarshalObject(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -94,6 +97,11 @@ func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetNextIdentifier returns the next identifier for an endpoint.
|
||||||
|
func (service *Service) GetNextIdentifier() int {
|
||||||
|
return internal.GetNextIdentifier(service.db, BucketName)
|
||||||
|
}
|
||||||
|
|
||||||
// Synchronize creates, updates and deletes endpoints inside a single transaction.
|
// Synchronize creates, updates and deletes endpoints inside a single transaction.
|
||||||
func (service *Service) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error {
|
func (service *Service) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error {
|
||||||
return service.db.Update(func(tx *bolt.Tx) error {
|
return service.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
|
17
api/bolt/migrator/migrate_dbversion12.go
Normal file
17
api/bolt/migrator/migrate_dbversion12.go
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package migrator
|
||||||
|
|
||||||
|
import "github.com/portainer/portainer"
|
||||||
|
|
||||||
|
func (m *Migrator) updateSettingsToVersion13() error {
|
||||||
|
legacySettings, err := m.settingsService.Settings()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
legacySettings.LDAPSettings.AutoCreateUsers = false
|
||||||
|
legacySettings.LDAPSettings.GroupSearchSettings = []portainer.LDAPGroupSearchSettings{
|
||||||
|
portainer.LDAPGroupSearchSettings{},
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.settingsService.UpdateSettings(legacySettings)
|
||||||
|
}
|
|
@ -170,5 +170,13 @@ func (m *Migrator) Migrate() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portainer 1.19.0
|
||||||
|
if m.currentDBVersion < 13 {
|
||||||
|
err := m.updateSettingsToVersion13()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||||
}
|
}
|
||||||
|
|
95
api/bolt/template/template.go
Normal file
95
api/bolt/template/template.go
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
"github.com/portainer/portainer/bolt/internal"
|
||||||
|
|
||||||
|
"github.com/boltdb/bolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BucketName represents the name of the bucket where this service stores data.
|
||||||
|
BucketName = "templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a service for managing endpoint data.
|
||||||
|
type Service struct {
|
||||||
|
db *bolt.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new instance of a service.
|
||||||
|
func NewService(db *bolt.DB) (*Service, error) {
|
||||||
|
err := internal.CreateBucket(db, BucketName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates return an array containing all the templates.
|
||||||
|
func (service *Service) Templates() ([]portainer.Template, error) {
|
||||||
|
var templates = make([]portainer.Template, 0)
|
||||||
|
|
||||||
|
err := service.db.View(func(tx *bolt.Tx) error {
|
||||||
|
bucket := tx.Bucket([]byte(BucketName))
|
||||||
|
|
||||||
|
cursor := bucket.Cursor()
|
||||||
|
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||||
|
var template portainer.Template
|
||||||
|
err := internal.UnmarshalObject(v, &template)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
templates = append(templates, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template returns a template by ID.
|
||||||
|
func (service *Service) Template(ID portainer.TemplateID) (*portainer.Template, error) {
|
||||||
|
var template portainer.Template
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
|
||||||
|
err := internal.GetObject(service.db, BucketName, identifier, &template)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTemplate creates a new template.
|
||||||
|
func (service *Service) CreateTemplate(template *portainer.Template) error {
|
||||||
|
return service.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
bucket := tx.Bucket([]byte(BucketName))
|
||||||
|
|
||||||
|
id, _ := bucket.NextSequence()
|
||||||
|
template.ID = portainer.TemplateID(id)
|
||||||
|
|
||||||
|
data, err := internal.MarshalObject(template)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket.Put(internal.Itob(int(template.ID)), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTemplate saves a template.
|
||||||
|
func (service *Service) UpdateTemplate(ID portainer.TemplateID, template *portainer.Template) error {
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
return internal.UpdateObject(service.db, BucketName, identifier, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTemplate deletes a template.
|
||||||
|
func (service *Service) DeleteTemplate(ID portainer.TemplateID) error {
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
return internal.DeleteObject(service.db, BucketName, identifier)
|
||||||
|
}
|
|
@ -16,10 +16,12 @@ import (
|
||||||
type Service struct{}
|
type Service struct{}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
|
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix://, npipe:// or tcp://")
|
||||||
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
|
errSocketOrNamedPipeNotFound = portainer.Error("Unable to locate Unix socket or named pipe")
|
||||||
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
|
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
|
||||||
|
errTemplateFileNotFound = portainer.Error("Unable to locate template file on disk")
|
||||||
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
|
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
|
||||||
|
errInvalidSnapshotInterval = portainer.Error("Invalid snapshot interval")
|
||||||
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
|
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
|
||||||
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
|
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
|
||||||
errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file")
|
errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file")
|
||||||
|
@ -46,11 +48,14 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||||
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
|
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
|
||||||
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
|
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
|
||||||
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
|
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
|
||||||
|
Snapshot: kingpin.Flag("snapshot", "Start a background job to create endpoint snapshots").Default(defaultSnapshot).Bool(),
|
||||||
|
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each endpoint snapshot job").Default(defaultSnapshotInterval).String(),
|
||||||
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
|
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
|
||||||
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
|
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
|
||||||
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
|
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
|
||||||
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
|
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
|
||||||
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(),
|
Templates: kingpin.Flag("templates", "URL to the templates definitions.").Short('t').String(),
|
||||||
|
TemplateFile: kingpin.Flag("template-file", "Path to the templates (app) definitions on the filesystem").Default(defaultTemplateFile).String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
kingpin.Parse()
|
kingpin.Parse()
|
||||||
|
@ -73,7 +78,12 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||||
return errEndpointExcludeExternal
|
return errEndpointExcludeExternal
|
||||||
}
|
}
|
||||||
|
|
||||||
err := validateEndpointURL(*flags.EndpointURL)
|
err := validateTemplateFile(*flags.TemplateFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateEndpointURL(*flags.EndpointURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -88,6 +98,11 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = validateSnapshotInterval(*flags.SnapshotInterval)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") {
|
if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") {
|
||||||
return errNoAuthExcludeAdminPassword
|
return errNoAuthExcludeAdminPassword
|
||||||
}
|
}
|
||||||
|
@ -101,15 +116,16 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||||
|
|
||||||
func validateEndpointURL(endpointURL string) error {
|
func validateEndpointURL(endpointURL string) error {
|
||||||
if endpointURL != "" {
|
if endpointURL != "" {
|
||||||
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") {
|
if !strings.HasPrefix(endpointURL, "unix://") && !strings.HasPrefix(endpointURL, "tcp://") && !strings.HasPrefix(endpointURL, "npipe://") {
|
||||||
return errInvalidEndpointProtocol
|
return errInvalidEndpointProtocol
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(endpointURL, "unix://") {
|
if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") {
|
||||||
socketPath := strings.TrimPrefix(endpointURL, "unix://")
|
socketPath := strings.TrimPrefix(endpointURL, "unix://")
|
||||||
|
socketPath = strings.TrimPrefix(socketPath, "npipe://")
|
||||||
if _, err := os.Stat(socketPath); err != nil {
|
if _, err := os.Stat(socketPath); err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return errSocketNotFound
|
return errSocketOrNamedPipeNotFound
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -130,6 +146,16 @@ func validateExternalEndpoints(externalEndpoints string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateTemplateFile(templateFile string) error {
|
||||||
|
if _, err := os.Stat(templateFile); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errTemplateFileNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateSyncInterval(syncInterval string) error {
|
func validateSyncInterval(syncInterval string) error {
|
||||||
if syncInterval != defaultSyncInterval {
|
if syncInterval != defaultSyncInterval {
|
||||||
_, err := time.ParseDuration(syncInterval)
|
_, err := time.ParseDuration(syncInterval)
|
||||||
|
@ -139,3 +165,13 @@ func validateSyncInterval(syncInterval string) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateSnapshotInterval(snapshotInterval string) error {
|
||||||
|
if snapshotInterval != defaultSnapshotInterval {
|
||||||
|
_, err := time.ParseDuration(snapshotInterval)
|
||||||
|
if err != nil {
|
||||||
|
return errInvalidSnapshotInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -17,4 +17,7 @@ const (
|
||||||
defaultSSLCertPath = "/certs/portainer.crt"
|
defaultSSLCertPath = "/certs/portainer.crt"
|
||||||
defaultSSLKeyPath = "/certs/portainer.key"
|
defaultSSLKeyPath = "/certs/portainer.key"
|
||||||
defaultSyncInterval = "60s"
|
defaultSyncInterval = "60s"
|
||||||
|
defaultSnapshot = "true"
|
||||||
|
defaultSnapshotInterval = "5m"
|
||||||
|
defaultTemplateFile = "/templates.json"
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,4 +15,7 @@ const (
|
||||||
defaultSSLCertPath = "C:\\certs\\portainer.crt"
|
defaultSSLCertPath = "C:\\certs\\portainer.crt"
|
||||||
defaultSSLKeyPath = "C:\\certs\\portainer.key"
|
defaultSSLKeyPath = "C:\\certs\\portainer.key"
|
||||||
defaultSyncInterval = "60s"
|
defaultSyncInterval = "60s"
|
||||||
|
defaultSnapshot = "true"
|
||||||
|
defaultSnapshotInterval = "5m"
|
||||||
|
defaultTemplateFile = "/templates.json"
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package main // import "github.com/portainer/portainer"
|
package main // import "github.com/portainer/portainer"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
|
@ -8,6 +9,7 @@ import (
|
||||||
"github.com/portainer/portainer/cli"
|
"github.com/portainer/portainer/cli"
|
||||||
"github.com/portainer/portainer/cron"
|
"github.com/portainer/portainer/cron"
|
||||||
"github.com/portainer/portainer/crypto"
|
"github.com/portainer/portainer/crypto"
|
||||||
|
"github.com/portainer/portainer/docker"
|
||||||
"github.com/portainer/portainer/exec"
|
"github.com/portainer/portainer/exec"
|
||||||
"github.com/portainer/portainer/filesystem"
|
"github.com/portainer/portainer/filesystem"
|
||||||
"github.com/portainer/portainer/git"
|
"github.com/portainer/portainer/git"
|
||||||
|
@ -100,25 +102,41 @@ func initGitService() portainer.GitService {
|
||||||
return &git.Service{}
|
return &git.Service{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
|
func initClientFactory(signatureService portainer.DigitalSignatureService) *docker.ClientFactory {
|
||||||
authorizeEndpointMgmt := true
|
return docker.NewClientFactory(signatureService)
|
||||||
if externalEnpointFile != "" {
|
|
||||||
authorizeEndpointMgmt = false
|
|
||||||
log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.")
|
|
||||||
endpointWatcher := cron.NewWatcher(endpointService, syncInterval)
|
|
||||||
err := endpointWatcher.WatchEndpointFile(externalEnpointFile)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return authorizeEndpointMgmt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Status {
|
func initSnapshotter(clientFactory *docker.ClientFactory) portainer.Snapshotter {
|
||||||
|
return docker.NewSnapshotter(clientFactory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initJobScheduler(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter, flags *portainer.CLIFlags) (portainer.JobScheduler, error) {
|
||||||
|
jobScheduler := cron.NewJobScheduler(endpointService, snapshotter)
|
||||||
|
|
||||||
|
if *flags.ExternalEndpoints != "" {
|
||||||
|
log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.")
|
||||||
|
err := jobScheduler.ScheduleEndpointSyncJob(*flags.ExternalEndpoints, *flags.SyncInterval)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *flags.Snapshot {
|
||||||
|
err := jobScheduler.ScheduleSnapshotJob(*flags.SnapshotInterval)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobScheduler, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initStatus(endpointManagement, snapshot bool, flags *portainer.CLIFlags) *portainer.Status {
|
||||||
return &portainer.Status{
|
return &portainer.Status{
|
||||||
Analytics: !*flags.NoAnalytics,
|
Analytics: !*flags.NoAnalytics,
|
||||||
Authentication: !*flags.NoAuth,
|
Authentication: !*flags.NoAuth,
|
||||||
EndpointManagement: authorizeEndpointMgmt,
|
EndpointManagement: endpointManagement,
|
||||||
|
Snapshot: snapshot,
|
||||||
Version: portainer.APIVersion,
|
Version: portainer.APIVersion,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -144,22 +162,20 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
||||||
if err == portainer.ErrObjectNotFound {
|
if err == portainer.ErrObjectNotFound {
|
||||||
settings := &portainer.Settings{
|
settings := &portainer.Settings{
|
||||||
LogoURL: *flags.Logo,
|
LogoURL: *flags.Logo,
|
||||||
DisplayExternalContributors: false,
|
|
||||||
AuthenticationMethod: portainer.AuthenticationInternal,
|
AuthenticationMethod: portainer.AuthenticationInternal,
|
||||||
LDAPSettings: portainer.LDAPSettings{
|
LDAPSettings: portainer.LDAPSettings{
|
||||||
|
AutoCreateUsers: true,
|
||||||
TLSConfig: portainer.TLSConfiguration{},
|
TLSConfig: portainer.TLSConfiguration{},
|
||||||
SearchSettings: []portainer.LDAPSearchSettings{
|
SearchSettings: []portainer.LDAPSearchSettings{
|
||||||
portainer.LDAPSearchSettings{},
|
portainer.LDAPSearchSettings{},
|
||||||
},
|
},
|
||||||
|
GroupSearchSettings: []portainer.LDAPGroupSearchSettings{
|
||||||
|
portainer.LDAPGroupSearchSettings{},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
AllowBindMountsForRegularUsers: true,
|
AllowBindMountsForRegularUsers: true,
|
||||||
AllowPrivilegedModeForRegularUsers: true,
|
AllowPrivilegedModeForRegularUsers: true,
|
||||||
}
|
SnapshotInterval: *flags.SnapshotInterval,
|
||||||
|
|
||||||
if *flags.Templates != "" {
|
|
||||||
settings.TemplatesURL = *flags.Templates
|
|
||||||
} else {
|
|
||||||
settings.TemplatesURL = portainer.DefaultTemplatesURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flags.Labels != nil {
|
if *flags.Labels != nil {
|
||||||
|
@ -176,6 +192,58 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initTemplates(templateService portainer.TemplateService, fileService portainer.FileService, templateURL, templateFile string) error {
|
||||||
|
|
||||||
|
existingTemplates, err := templateService.Templates()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(existingTemplates) != 0 {
|
||||||
|
log.Printf("Templates already registered inside the database. Skipping template import.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesJSON []byte
|
||||||
|
if templateURL == "" {
|
||||||
|
return loadTemplatesFromFile(fileService, templateService, templateFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
templatesJSON, err = client.Get(templateURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to retrieve templates via HTTP")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return unmarshalAndPersistTemplates(templateService, templatesJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTemplatesFromFile(fileService portainer.FileService, templateService portainer.TemplateService, templateFile string) error {
|
||||||
|
templatesJSON, err := fileService.GetFileContent(templateFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to retrieve template via filesystem")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return unmarshalAndPersistTemplates(templateService, templatesJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalAndPersistTemplates(templateService portainer.TemplateService, templateData []byte) error {
|
||||||
|
var templates []portainer.Template
|
||||||
|
err := json.Unmarshal(templateData, &templates)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to parse templates file. Please review your template definition file.")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, template := range templates {
|
||||||
|
err := templateService.CreateTemplate(&template)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint {
|
func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint {
|
||||||
endpoints, err := endpointService.Endpoints()
|
endpoints, err := endpointService.Endpoints()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -213,7 +281,7 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D
|
||||||
return generateAndStoreKeyPair(fileService, signatureService)
|
return generateAndStoreKeyPair(fileService, signatureService)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService) error {
|
func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error {
|
||||||
tlsConfiguration := portainer.TLSConfiguration{
|
tlsConfiguration := portainer.TLSConfiguration{
|
||||||
TLS: *flags.TLS,
|
TLS: *flags.TLS,
|
||||||
TLSSkipVerify: *flags.TLSSkipVerify,
|
TLSSkipVerify: *flags.TLSSkipVerify,
|
||||||
|
@ -227,7 +295,9 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain
|
||||||
tlsConfiguration.TLS = true
|
tlsConfiguration.TLS = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpointID := endpointService.GetNextIdentifier()
|
||||||
endpoint := &portainer.Endpoint{
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: portainer.EndpointID(endpointID),
|
||||||
Name: "primary",
|
Name: "primary",
|
||||||
URL: *flags.EndpointURL,
|
URL: *flags.EndpointURL,
|
||||||
GroupID: portainer.EndpointGroupID(1),
|
GroupID: portainer.EndpointGroupID(1),
|
||||||
|
@ -237,6 +307,8 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain
|
||||||
AuthorizedTeams: []portainer.TeamID{},
|
AuthorizedTeams: []portainer.TeamID{},
|
||||||
Extensions: []portainer.EndpointExtension{},
|
Extensions: []portainer.EndpointExtension{},
|
||||||
Tags: []string{},
|
Tags: []string{},
|
||||||
|
Status: portainer.EndpointStatusUp,
|
||||||
|
Snapshots: []portainer.Snapshot{},
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(endpoint.URL, "tcp://") {
|
if strings.HasPrefix(endpoint.URL, "tcp://") {
|
||||||
|
@ -255,10 +327,10 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return endpointService.CreateEndpoint(endpoint)
|
return snapshotAndPersistEndpoint(endpoint, endpointService, snapshotter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createUnsecuredEndpoint(endpointURL string, endpointService portainer.EndpointService) error {
|
func createUnsecuredEndpoint(endpointURL string, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error {
|
||||||
if strings.HasPrefix(endpointURL, "tcp://") {
|
if strings.HasPrefix(endpointURL, "tcp://") {
|
||||||
_, err := client.ExecutePingOperation(endpointURL, nil)
|
_, err := client.ExecutePingOperation(endpointURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -266,7 +338,9 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpointID := endpointService.GetNextIdentifier()
|
||||||
endpoint := &portainer.Endpoint{
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: portainer.EndpointID(endpointID),
|
||||||
Name: "primary",
|
Name: "primary",
|
||||||
URL: endpointURL,
|
URL: endpointURL,
|
||||||
GroupID: portainer.EndpointGroupID(1),
|
GroupID: portainer.EndpointGroupID(1),
|
||||||
|
@ -276,12 +350,28 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo
|
||||||
AuthorizedTeams: []portainer.TeamID{},
|
AuthorizedTeams: []portainer.TeamID{},
|
||||||
Extensions: []portainer.EndpointExtension{},
|
Extensions: []portainer.EndpointExtension{},
|
||||||
Tags: []string{},
|
Tags: []string{},
|
||||||
|
Status: portainer.EndpointStatusUp,
|
||||||
|
Snapshots: []portainer.Snapshot{},
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshotAndPersistEndpoint(endpoint, endpointService, snapshotter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotAndPersistEndpoint(endpoint *portainer.Endpoint, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error {
|
||||||
|
snapshot, err := snapshotter.CreateSnapshot(endpoint)
|
||||||
|
endpoint.Status = portainer.EndpointStatusUp
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot != nil {
|
||||||
|
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
|
||||||
}
|
}
|
||||||
|
|
||||||
return endpointService.CreateEndpoint(endpoint)
|
return endpointService.CreateEndpoint(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService) error {
|
func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error {
|
||||||
if *flags.EndpointURL == "" {
|
if *flags.EndpointURL == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -297,9 +387,9 @@ func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointS
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flags.TLS || *flags.TLSSkipVerify {
|
if *flags.TLS || *flags.TLSSkipVerify {
|
||||||
return createTLSSecuredEndpoint(flags, endpointService)
|
return createTLSSecuredEndpoint(flags, endpointService, snapshotter)
|
||||||
}
|
}
|
||||||
return createUnsecuredEndpoint(*flags.EndpointURL, endpointService)
|
return createUnsecuredEndpoint(*flags.EndpointURL, endpointService, snapshotter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
@ -312,21 +402,35 @@ func main() {
|
||||||
|
|
||||||
jwtService := initJWTService(!*flags.NoAuth)
|
jwtService := initJWTService(!*flags.NoAuth)
|
||||||
|
|
||||||
cryptoService := initCryptoService()
|
|
||||||
|
|
||||||
digitalSignatureService := initDigitalSignatureService()
|
|
||||||
|
|
||||||
ldapService := initLDAPService()
|
ldapService := initLDAPService()
|
||||||
|
|
||||||
gitService := initGitService()
|
gitService := initGitService()
|
||||||
|
|
||||||
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
|
cryptoService := initCryptoService()
|
||||||
|
|
||||||
|
digitalSignatureService := initDigitalSignatureService()
|
||||||
|
|
||||||
err := initKeyPair(fileService, digitalSignatureService)
|
err := initKeyPair(fileService, digitalSignatureService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientFactory := initClientFactory(digitalSignatureService)
|
||||||
|
|
||||||
|
snapshotter := initSnapshotter(clientFactory)
|
||||||
|
|
||||||
|
jobScheduler, err := initJobScheduler(store.EndpointService, snapshotter, flags)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobScheduler.Start()
|
||||||
|
|
||||||
|
endpointManagement := true
|
||||||
|
if *flags.ExternalEndpoints != "" {
|
||||||
|
endpointManagement = false
|
||||||
|
}
|
||||||
|
|
||||||
swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService)
|
swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -334,6 +438,11 @@ func main() {
|
||||||
|
|
||||||
composeStackManager := initComposeStackManager(*flags.Data)
|
composeStackManager := initComposeStackManager(*flags.Data)
|
||||||
|
|
||||||
|
err = initTemplates(store.TemplateService, fileService, *flags.Templates, *flags.TemplateFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
err = initSettings(store.SettingsService, flags)
|
err = initSettings(store.SettingsService, flags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -344,9 +453,9 @@ func main() {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
applicationStatus := initStatus(authorizeEndpointMgmt, flags)
|
applicationStatus := initStatus(endpointManagement, *flags.Snapshot, flags)
|
||||||
|
|
||||||
err = initEndpoint(flags, store.EndpointService)
|
err = initEndpoint(flags, store.EndpointService, snapshotter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -357,7 +466,7 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
adminPasswordHash, err = cryptoService.Hash(content)
|
adminPasswordHash, err = cryptoService.Hash(string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -392,7 +501,7 @@ func main() {
|
||||||
BindAddress: *flags.Addr,
|
BindAddress: *flags.Addr,
|
||||||
AssetsPath: *flags.Assets,
|
AssetsPath: *flags.Assets,
|
||||||
AuthDisabled: *flags.NoAuth,
|
AuthDisabled: *flags.NoAuth,
|
||||||
EndpointManagement: authorizeEndpointMgmt,
|
EndpointManagement: endpointManagement,
|
||||||
UserService: store.UserService,
|
UserService: store.UserService,
|
||||||
TeamService: store.TeamService,
|
TeamService: store.TeamService,
|
||||||
TeamMembershipService: store.TeamMembershipService,
|
TeamMembershipService: store.TeamMembershipService,
|
||||||
|
@ -404,6 +513,7 @@ func main() {
|
||||||
DockerHubService: store.DockerHubService,
|
DockerHubService: store.DockerHubService,
|
||||||
StackService: store.StackService,
|
StackService: store.StackService,
|
||||||
TagService: store.TagService,
|
TagService: store.TagService,
|
||||||
|
TemplateService: store.TemplateService,
|
||||||
SwarmStackManager: swarmStackManager,
|
SwarmStackManager: swarmStackManager,
|
||||||
ComposeStackManager: composeStackManager,
|
ComposeStackManager: composeStackManager,
|
||||||
CryptoService: cryptoService,
|
CryptoService: cryptoService,
|
||||||
|
@ -412,6 +522,8 @@ func main() {
|
||||||
LDAPService: ldapService,
|
LDAPService: ldapService,
|
||||||
GitService: gitService,
|
GitService: gitService,
|
||||||
SignatureService: digitalSignatureService,
|
SignatureService: digitalSignatureService,
|
||||||
|
JobScheduler: jobScheduler,
|
||||||
|
Snapshotter: snapshotter,
|
||||||
SSL: *flags.SSL,
|
SSL: *flags.SSL,
|
||||||
SSLCert: *flags.SSLCert,
|
SSLCert: *flags.SSLCert,
|
||||||
SSLKey: *flags.SSLKey,
|
SSLKey: *flags.SSLKey,
|
||||||
|
|
60
api/cron/job_endpoint_snapshot.go
Normal file
60
api/cron/job_endpoint_snapshot.go
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
endpointSnapshotJob struct {
|
||||||
|
endpointService portainer.EndpointService
|
||||||
|
snapshotter portainer.Snapshotter
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func newEndpointSnapshotJob(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) endpointSnapshotJob {
|
||||||
|
return endpointSnapshotJob{
|
||||||
|
endpointService: endpointService,
|
||||||
|
snapshotter: snapshotter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job endpointSnapshotJob) Snapshot() error {
|
||||||
|
|
||||||
|
endpoints, err := job.endpointService.Endpoints()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if endpoint.Type == portainer.AzureEnvironment {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := job.snapshotter.CreateSnapshot(&endpoint)
|
||||||
|
endpoint.Status = portainer.EndpointStatusUp
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
|
||||||
|
endpoint.Status = portainer.EndpointStatusDown
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot != nil {
|
||||||
|
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = job.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (job endpointSnapshotJob) Run() {
|
||||||
|
err := job.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cron error: snapshot job error (err=%s)\n", err)
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
|
@ -12,7 +11,6 @@ import (
|
||||||
|
|
||||||
type (
|
type (
|
||||||
endpointSyncJob struct {
|
endpointSyncJob struct {
|
||||||
logger *log.Logger
|
|
||||||
endpointService portainer.EndpointService
|
endpointService portainer.EndpointService
|
||||||
endpointFilePath string
|
endpointFilePath string
|
||||||
}
|
}
|
||||||
|
@ -41,15 +39,14 @@ const (
|
||||||
|
|
||||||
func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob {
|
func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob {
|
||||||
return endpointSyncJob{
|
return endpointSyncJob{
|
||||||
logger: log.New(os.Stderr, "", log.LstdFlags),
|
|
||||||
endpointService: endpointService,
|
endpointService: endpointService,
|
||||||
endpointFilePath: endpointFilePath,
|
endpointFilePath: endpointFilePath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func endpointSyncError(err error, logger *log.Logger) bool {
|
func endpointSyncError(err error) bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Printf("Endpoint synchronization error: %s", err)
|
log.Printf("cron error: synchronization job error (err=%s)\n", err)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
@ -140,23 +137,23 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
|
||||||
if fidx != -1 {
|
if fidx != -1 {
|
||||||
endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx])
|
endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx])
|
||||||
if endpoint != nil {
|
if endpoint != nil {
|
||||||
job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
|
log.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
|
||||||
endpointsToUpdate = append(endpointsToUpdate, endpoint)
|
endpointsToUpdate = append(endpointsToUpdate, endpoint)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
|
log.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
|
||||||
endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx])
|
endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx, endpoint := range fileEndpoints {
|
for idx, endpoint := range fileEndpoints {
|
||||||
if !isValidEndpoint(&endpoint) {
|
if !isValidEndpoint(&endpoint) {
|
||||||
job.logger.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL)
|
log.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sidx := endpointExists(&fileEndpoints[idx], storedEndpoints)
|
sidx := endpointExists(&fileEndpoints[idx], storedEndpoints)
|
||||||
if sidx == -1 {
|
if sidx == -1 {
|
||||||
job.logger.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL)
|
log.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL)
|
||||||
endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx])
|
endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -170,13 +167,13 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
|
||||||
|
|
||||||
func (job endpointSyncJob) Sync() error {
|
func (job endpointSyncJob) Sync() error {
|
||||||
data, err := ioutil.ReadFile(job.endpointFilePath)
|
data, err := ioutil.ReadFile(job.endpointFilePath)
|
||||||
if endpointSyncError(err, job.logger) {
|
if endpointSyncError(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileEndpoints []fileEndpoint
|
var fileEndpoints []fileEndpoint
|
||||||
err = json.Unmarshal(data, &fileEndpoints)
|
err = json.Unmarshal(data, &fileEndpoints)
|
||||||
if endpointSyncError(err, job.logger) {
|
if endpointSyncError(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -185,7 +182,7 @@ func (job endpointSyncJob) Sync() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
storedEndpoints, err := job.endpointService.Endpoints()
|
storedEndpoints, err := job.endpointService.Endpoints()
|
||||||
if endpointSyncError(err, job.logger) {
|
if endpointSyncError(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -194,16 +191,16 @@ func (job endpointSyncJob) Sync() error {
|
||||||
sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints)
|
sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints)
|
||||||
if sync.requireSync() {
|
if sync.requireSync() {
|
||||||
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
|
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
|
||||||
if endpointSyncError(err, job.logger) {
|
if endpointSyncError(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
job.logger.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
|
log.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (job endpointSyncJob) Run() {
|
func (job endpointSyncJob) Run() {
|
||||||
job.logger.Println("Endpoint synchronization job started.")
|
log.Println("cron: synchronization job started")
|
||||||
err := job.Sync()
|
err := job.Sync()
|
||||||
endpointSyncError(err, job.logger)
|
endpointSyncError(err)
|
||||||
}
|
}
|
86
api/cron/scheduler.go
Normal file
86
api/cron/scheduler.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
"github.com/robfig/cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobScheduler represents a service for managing crons.
|
||||||
|
type JobScheduler struct {
|
||||||
|
cron *cron.Cron
|
||||||
|
endpointService portainer.EndpointService
|
||||||
|
snapshotter portainer.Snapshotter
|
||||||
|
|
||||||
|
endpointFilePath string
|
||||||
|
endpointSyncInterval string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJobScheduler initializes a new service.
|
||||||
|
func NewJobScheduler(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) *JobScheduler {
|
||||||
|
return &JobScheduler{
|
||||||
|
cron: cron.New(),
|
||||||
|
endpointService: endpointService,
|
||||||
|
snapshotter: snapshotter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleEndpointSyncJob schedules a cron job to synchronize the endpoints from a file
|
||||||
|
func (scheduler *JobScheduler) ScheduleEndpointSyncJob(endpointFilePath string, interval string) error {
|
||||||
|
|
||||||
|
scheduler.endpointFilePath = endpointFilePath
|
||||||
|
scheduler.endpointSyncInterval = interval
|
||||||
|
|
||||||
|
job := newEndpointSyncJob(endpointFilePath, scheduler.endpointService)
|
||||||
|
|
||||||
|
err := job.Sync()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduler.cron.AddJob("@every "+interval, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScheduleSnapshotJob schedules a cron job to create endpoint snapshots
|
||||||
|
func (scheduler *JobScheduler) ScheduleSnapshotJob(interval string) error {
|
||||||
|
job := newEndpointSnapshotJob(scheduler.endpointService, scheduler.snapshotter)
|
||||||
|
|
||||||
|
err := job.Snapshot()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduler.cron.AddJob("@every "+interval, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSnapshotJob will update the schedules to match the new snapshot interval
|
||||||
|
func (scheduler *JobScheduler) UpdateSnapshotJob(interval string) {
|
||||||
|
// TODO: the cron library do not support removing/updating schedules.
|
||||||
|
// As a work-around we need to re-create the cron and reschedule the jobs.
|
||||||
|
// We should update the library.
|
||||||
|
jobs := scheduler.cron.Entries()
|
||||||
|
scheduler.cron.Stop()
|
||||||
|
|
||||||
|
scheduler.cron = cron.New()
|
||||||
|
|
||||||
|
for _, job := range jobs {
|
||||||
|
switch job.Job.(type) {
|
||||||
|
case endpointSnapshotJob:
|
||||||
|
scheduler.ScheduleSnapshotJob(interval)
|
||||||
|
case endpointSyncJob:
|
||||||
|
scheduler.ScheduleEndpointSyncJob(scheduler.endpointFilePath, scheduler.endpointSyncInterval)
|
||||||
|
default:
|
||||||
|
log.Println("Unsupported job")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler.cron.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts the scheduled jobs
|
||||||
|
func (scheduler *JobScheduler) Start() {
|
||||||
|
if len(scheduler.cron.Entries()) > 0 {
|
||||||
|
scheduler.cron.Start()
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,40 +0,0 @@
|
||||||
package cron
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/portainer/portainer"
|
|
||||||
"github.com/robfig/cron"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Watcher represents a service for managing crons.
|
|
||||||
type Watcher struct {
|
|
||||||
Cron *cron.Cron
|
|
||||||
EndpointService portainer.EndpointService
|
|
||||||
syncInterval string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWatcher initializes a new service.
|
|
||||||
func NewWatcher(endpointService portainer.EndpointService, syncInterval string) *Watcher {
|
|
||||||
return &Watcher{
|
|
||||||
Cron: cron.New(),
|
|
||||||
EndpointService: endpointService,
|
|
||||||
syncInterval: syncInterval,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WatchEndpointFile starts a cron job to synchronize the endpoints from a file
|
|
||||||
func (watcher *Watcher) WatchEndpointFile(endpointFilePath string) error {
|
|
||||||
job := newEndpointSyncJob(endpointFilePath, watcher.EndpointService)
|
|
||||||
|
|
||||||
err := job.Sync()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = watcher.Cron.AddJob("@every "+watcher.syncInterval, job)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
watcher.Cron.Start()
|
|
||||||
return nil
|
|
||||||
}
|
|
103
api/docker/client.go
Normal file
103
api/docker/client.go
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
"github.com/portainer/portainer/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
unsupportedEnvironmentType = portainer.Error("Environment not supported")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientFactory is used to create Docker clients
|
||||||
|
type ClientFactory struct {
|
||||||
|
signatureService portainer.DigitalSignatureService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientFactory returns a new instance of a ClientFactory
|
||||||
|
func NewClientFactory(signatureService portainer.DigitalSignatureService) *ClientFactory {
|
||||||
|
return &ClientFactory{
|
||||||
|
signatureService: signatureService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateClient is a generic function to create a Docker client based on
|
||||||
|
// a specific endpoint configuration
|
||||||
|
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||||
|
if endpoint.Type == portainer.AzureEnvironment {
|
||||||
|
return nil, unsupportedEnvironmentType
|
||||||
|
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||||
|
return createAgentClient(endpoint, factory.signatureService)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
||||||
|
return createLocalClient(endpoint)
|
||||||
|
}
|
||||||
|
return createTCPClient(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||||
|
return client.NewClientWithOpts(
|
||||||
|
client.WithHost(endpoint.URL),
|
||||||
|
client.WithVersion(portainer.SupportedDockerAPIVersion),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||||
|
httpCli, err := httpClient(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.NewClientWithOpts(
|
||||||
|
client.WithHost(endpoint.URL),
|
||||||
|
client.WithVersion(portainer.SupportedDockerAPIVersion),
|
||||||
|
client.WithHTTPClient(httpCli),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService) (*client.Client, error) {
|
||||||
|
httpCli, err := httpClient(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
signature, err := signatureService.Sign(portainer.PortainerAgentSignatureMessage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := map[string]string{
|
||||||
|
portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(),
|
||||||
|
portainer.PortainerAgentSignatureHeader: signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.NewClientWithOpts(
|
||||||
|
client.WithHost(endpoint.URL),
|
||||||
|
client.WithVersion(portainer.SupportedDockerAPIVersion),
|
||||||
|
client.WithHTTPClient(httpCli),
|
||||||
|
client.WithHTTPHeaders(headers),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
|
||||||
|
transport := &http.Transport{}
|
||||||
|
|
||||||
|
if endpoint.TLSConfig.TLS {
|
||||||
|
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transport.TLSClientConfig = tlsConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Client{
|
||||||
|
Timeout: time.Second * 10,
|
||||||
|
Transport: transport,
|
||||||
|
}, nil
|
||||||
|
}
|
135
api/docker/snapshot.go
Normal file
135
api/docker/snapshot.go
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func snapshot(cli *client.Client) (*portainer.Snapshot, error) {
|
||||||
|
_, err := cli.Ping(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot := &portainer.Snapshot{
|
||||||
|
StackCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = snapshotInfo(snapshot, cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.Swarm {
|
||||||
|
err = snapshotSwarmServices(snapshot, cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = snapshotContainers(snapshot, cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = snapshotImages(snapshot, cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = snapshotVolumes(snapshot, cli)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.Time = time.Now().Unix()
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotInfo(snapshot *portainer.Snapshot, cli *client.Client) error {
|
||||||
|
info, err := cli.Info(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.Swarm = info.Swarm.ControlAvailable
|
||||||
|
snapshot.DockerVersion = info.ServerVersion
|
||||||
|
snapshot.TotalCPU = info.NCPU
|
||||||
|
snapshot.TotalMemory = info.MemTotal
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotSwarmServices(snapshot *portainer.Snapshot, cli *client.Client) error {
|
||||||
|
stacks := make(map[string]struct{})
|
||||||
|
|
||||||
|
services, err := cli.ServiceList(context.Background(), types.ServiceListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, service := range services {
|
||||||
|
for k, v := range service.Spec.Labels {
|
||||||
|
if k == "com.docker.stack.namespace" {
|
||||||
|
stacks[v] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.ServiceCount = len(services)
|
||||||
|
snapshot.StackCount += len(stacks)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotContainers(snapshot *portainer.Snapshot, cli *client.Client) error {
|
||||||
|
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
runningContainers := 0
|
||||||
|
stoppedContainers := 0
|
||||||
|
stacks := make(map[string]struct{})
|
||||||
|
for _, container := range containers {
|
||||||
|
if container.State == "exited" {
|
||||||
|
stoppedContainers++
|
||||||
|
} else if container.State == "running" {
|
||||||
|
runningContainers++
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range container.Labels {
|
||||||
|
if k == "com.docker.compose.project" {
|
||||||
|
stacks[v] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.RunningContainerCount = runningContainers
|
||||||
|
snapshot.StoppedContainerCount = stoppedContainers
|
||||||
|
snapshot.StackCount += len(stacks)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotImages(snapshot *portainer.Snapshot, cli *client.Client) error {
|
||||||
|
images, err := cli.ImageList(context.Background(), types.ImageListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.ImageCount = len(images)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotVolumes(snapshot *portainer.Snapshot, cli *client.Client) error {
|
||||||
|
volumes, err := cli.VolumeList(context.Background(), filters.Args{})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.VolumeCount = len(volumes.Volumes)
|
||||||
|
return nil
|
||||||
|
}
|
27
api/docker/snapshotter.go
Normal file
27
api/docker/snapshotter.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshotter represents a service used to create endpoint snapshots
|
||||||
|
type Snapshotter struct {
|
||||||
|
clientFactory *ClientFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSnapshotter returns a new Snapshotter instance
|
||||||
|
func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter {
|
||||||
|
return &Snapshotter{
|
||||||
|
clientFactory: clientFactory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSnapshot creates a snapshot of a specific endpoint
|
||||||
|
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.Snapshot, error) {
|
||||||
|
cli, err := snapshotter.clientFactory.CreateClient(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot(cli)
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ const (
|
||||||
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed")
|
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed")
|
||||||
ErrAdminAlreadyInitialized = Error("An administrator user already exists")
|
ErrAdminAlreadyInitialized = Error("An administrator user already exists")
|
||||||
ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator")
|
ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator")
|
||||||
|
ErrCannotRemoveLastLocalAdmin = Error("Cannot remove the last local administrator account")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Team errors.
|
// Team errors.
|
||||||
|
|
|
@ -169,7 +169,7 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
|
||||||
return make(map[string]interface{}), nil
|
return make(map[string]interface{}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal([]byte(raw), &config)
|
err = json.Unmarshal(raw, &config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -176,14 +176,14 @@ func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileT
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFileContent returns a string content from file.
|
// GetFileContent returns the content of a file as bytes.
|
||||||
func (service *Service) GetFileContent(filePath string) (string, error) {
|
func (service *Service) GetFileContent(filePath string) ([]byte, error) {
|
||||||
content, err := ioutil.ReadFile(filePath)
|
content, err := ioutil.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(content), nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename renames a file or directory
|
// Rename renames a file or directory
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/src-d/go-git.v4"
|
"gopkg.in/src-d/go-git.v4"
|
||||||
|
"gopkg.in/src-d/go-git.v4/plumbing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service represents a service for managing Git.
|
// Service represents a service for managing Git.
|
||||||
|
@ -19,21 +20,27 @@ func NewService(dataStorePath string) (*Service, error) {
|
||||||
|
|
||||||
// ClonePublicRepository clones a public git repository using the specified URL in the specified
|
// ClonePublicRepository clones a public git repository using the specified URL in the specified
|
||||||
// destination folder.
|
// destination folder.
|
||||||
func (service *Service) ClonePublicRepository(repositoryURL, destination string) error {
|
func (service *Service) ClonePublicRepository(repositoryURL, referenceName string, destination string) error {
|
||||||
return cloneRepository(repositoryURL, destination)
|
return cloneRepository(repositoryURL, referenceName, destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
|
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
|
||||||
// destination folder. It will use the specified username and password for basic HTTP authentication.
|
// destination folder. It will use the specified username and password for basic HTTP authentication.
|
||||||
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error {
|
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error {
|
||||||
credentials := username + ":" + url.PathEscape(password)
|
credentials := username + ":" + url.PathEscape(password)
|
||||||
repositoryURL = strings.Replace(repositoryURL, "://", "://"+credentials+"@", 1)
|
repositoryURL = strings.Replace(repositoryURL, "://", "://"+credentials+"@", 1)
|
||||||
return cloneRepository(repositoryURL, destination)
|
return cloneRepository(repositoryURL, referenceName, destination)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneRepository(repositoryURL, destination string) error {
|
func cloneRepository(repositoryURL, referenceName string, destination string) error {
|
||||||
_, err := git.PlainClone(destination, false, &git.CloneOptions{
|
options := &git.CloneOptions{
|
||||||
URL: repositoryURL,
|
URL: repositoryURL,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if referenceName != "" {
|
||||||
|
options.ReferenceName = plumbing.ReferenceName(referenceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := git.PlainClone(destination, false, options)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -61,6 +62,27 @@ func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portain
|
||||||
return &token, nil
|
return &token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get executes a simple HTTP GET to the specified URL and returns
|
||||||
|
// the content of the response body.
|
||||||
|
func Get(url string) ([]byte, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: time.Second * 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment
|
// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment
|
||||||
// using the specified host and optional TLS configuration.
|
// using the specified host and optional TLS configuration.
|
||||||
// It uses a new Http.Client for each operation.
|
// It uses a new Http.Client for each operation.
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
"github.com/asaskevich/govalidator"
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
|
@ -40,34 +42,84 @@ func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *ht
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
u, err := handler.UserService.UserByUsername(payload.Username)
|
|
||||||
if err == portainer.ErrObjectNotFound {
|
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid credentials", ErrInvalidCredentials}
|
|
||||||
} else if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, err := handler.SettingsService.Settings()
|
settings, err := handler.SettingsService.Settings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 {
|
u, err := handler.UserService.UserByUsername(payload.Username)
|
||||||
err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, &settings.LDAPSettings)
|
if err != nil && err != portainer.ErrObjectNotFound {
|
||||||
if err != nil {
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate user via LDAP/AD", err}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
err = handler.CryptoService.CompareHashAndData(u.Password, payload.Password)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", ErrInvalidCredentials}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err == portainer.ErrObjectNotFound && settings.AuthenticationMethod == portainer.AuthenticationInternal {
|
||||||
|
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized}
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.AuthenticationMethod == portainer.AuthenticationLDAP {
|
||||||
|
if u == nil && settings.LDAPSettings.AutoCreateUsers {
|
||||||
|
return handler.authenticateLDAPAndCreateUser(w, payload.Username, payload.Password, &settings.LDAPSettings)
|
||||||
|
} else if u == nil && !settings.LDAPSettings.AutoCreateUsers {
|
||||||
|
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized}
|
||||||
|
}
|
||||||
|
return handler.authenticateLDAP(w, u, payload.Password, &settings.LDAPSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.authenticateInternal(w, u, payload.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
|
||||||
|
err := handler.LDAPService.AuthenticateUser(user.Username, password, ldapSettings)
|
||||||
|
if err != nil {
|
||||||
|
return handler.authenticateInternal(w, user, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.addUserIntoTeams(user, ldapSettings)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.writeToken(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError {
|
||||||
|
err := handler.CryptoService.CompareHashAndData(user.Password, password)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.writeToken(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError {
|
||||||
|
err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &portainer.User{
|
||||||
|
Username: username,
|
||||||
|
Role: portainer.StandardUserRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.UserService.CreateUser(user)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.addUserIntoTeams(user, ldapSettings)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.writeToken(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
|
||||||
tokenData := &portainer.TokenData{
|
tokenData := &portainer.TokenData{
|
||||||
ID: u.ID,
|
ID: user.ID,
|
||||||
Username: u.Username,
|
Username: user.Username,
|
||||||
Role: u.Role,
|
Role: user.Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := handler.JWTService.GenerateToken(tokenData)
|
token, err := handler.JWTService.GenerateToken(tokenData)
|
||||||
|
@ -77,3 +129,59 @@ func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *ht
|
||||||
|
|
||||||
return response.JSON(w, &authenticateResponse{JWT: token})
|
return response.JSON(w, &authenticateResponse{JWT: token})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portainer.LDAPSettings) error {
|
||||||
|
teams, err := handler.TeamService.Teams()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userGroups, err := handler.LDAPService.GetUserGroups(user.Username, settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userMemberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, team := range teams {
|
||||||
|
if teamExists(team.Name, userGroups) {
|
||||||
|
|
||||||
|
if teamMembershipExists(team.ID, userMemberships) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
membership := &portainer.TeamMembership{
|
||||||
|
UserID: user.ID,
|
||||||
|
TeamID: team.ID,
|
||||||
|
Role: portainer.TeamMember,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := handler.TeamMembershipService.CreateTeamMembership(membership)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func teamExists(teamName string, ldapGroups []string) bool {
|
||||||
|
for _, group := range ldapGroups {
|
||||||
|
if strings.ToLower(group) == strings.ToLower(teamName) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamMembership) bool {
|
||||||
|
for _, membership := range memberships {
|
||||||
|
if membership.TeamID == teamID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
@ -26,6 +26,8 @@ type Handler struct {
|
||||||
JWTService portainer.JWTService
|
JWTService portainer.JWTService
|
||||||
LDAPService portainer.LDAPService
|
LDAPService portainer.LDAPService
|
||||||
SettingsService portainer.SettingsService
|
SettingsService portainer.SettingsService
|
||||||
|
TeamService portainer.TeamService
|
||||||
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage authentication operations.
|
// NewHandler creates a handler to manage authentication operations.
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
package endpoints
|
package endpoints
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
"github.com/portainer/portainer/crypto"
|
"github.com/portainer/portainer/crypto"
|
||||||
|
@ -56,6 +57,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||||
return portainer.Error("Invalid Tags parameter")
|
return portainer.Error("Invalid Tags parameter")
|
||||||
}
|
}
|
||||||
payload.Tags = tags
|
payload.Tags = tags
|
||||||
|
if payload.Tags == nil {
|
||||||
|
payload.Tags = make([]string, 0)
|
||||||
|
}
|
||||||
|
|
||||||
useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true)
|
useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true)
|
||||||
payload.TLS = useTLS
|
payload.TLS = useTLS
|
||||||
|
@ -109,7 +113,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
||||||
}
|
}
|
||||||
payload.AzureAuthenticationKey = azureAuthenticationKey
|
payload.AzureAuthenticationKey = azureAuthenticationKey
|
||||||
default:
|
default:
|
||||||
url, err := request.RetrieveMultiPartFormValue(r, "URL", false)
|
url, err := request.RetrieveMultiPartFormValue(r, "URL", true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return portainer.Error("Invalid endpoint URL")
|
return portainer.Error("Invalid endpoint URL")
|
||||||
}
|
}
|
||||||
|
@ -166,7 +170,9 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
|
||||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", err}
|
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpointID := handler.EndpointService.GetNextIdentifier()
|
||||||
endpoint := &portainer.Endpoint{
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: portainer.EndpointID(endpointID),
|
||||||
Name: payload.Name,
|
Name: payload.Name,
|
||||||
URL: payload.URL,
|
URL: payload.URL,
|
||||||
Type: portainer.AzureEnvironment,
|
Type: portainer.AzureEnvironment,
|
||||||
|
@ -177,6 +183,8 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
|
||||||
Extensions: []portainer.EndpointExtension{},
|
Extensions: []portainer.EndpointExtension{},
|
||||||
AzureCredentials: credentials,
|
AzureCredentials: credentials,
|
||||||
Tags: payload.Tags,
|
Tags: payload.Tags,
|
||||||
|
Status: portainer.EndpointStatusUp,
|
||||||
|
Snapshots: []portainer.Snapshot{},
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.EndpointService.CreateEndpoint(endpoint)
|
err = handler.EndpointService.CreateEndpoint(endpoint)
|
||||||
|
@ -190,7 +198,12 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
|
||||||
func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) {
|
func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) {
|
||||||
endpointType := portainer.DockerEnvironment
|
endpointType := portainer.DockerEnvironment
|
||||||
|
|
||||||
if !strings.HasPrefix(payload.URL, "unix://") {
|
if payload.URL == "" {
|
||||||
|
payload.URL = "unix:///var/run/docker.sock"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
payload.URL = "npipe:////./pipe/docker_engine"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, nil)
|
agentOnDockerEnvironment, err := client.ExecutePingOperation(payload.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err}
|
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to ping Docker environment", err}
|
||||||
|
@ -200,7 +213,9 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpointID := handler.EndpointService.GetNextIdentifier()
|
||||||
endpoint := &portainer.Endpoint{
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: portainer.EndpointID(endpointID),
|
||||||
Name: payload.Name,
|
Name: payload.Name,
|
||||||
URL: payload.URL,
|
URL: payload.URL,
|
||||||
Type: endpointType,
|
Type: endpointType,
|
||||||
|
@ -213,11 +228,13 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
|
||||||
AuthorizedTeams: []portainer.TeamID{},
|
AuthorizedTeams: []portainer.TeamID{},
|
||||||
Extensions: []portainer.EndpointExtension{},
|
Extensions: []portainer.EndpointExtension{},
|
||||||
Tags: payload.Tags,
|
Tags: payload.Tags,
|
||||||
|
Status: portainer.EndpointStatusUp,
|
||||||
|
Snapshots: []portainer.Snapshot{},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := handler.EndpointService.CreateEndpoint(endpoint)
|
err := handler.snapshotAndPersistEndpoint(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err}
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return endpoint, nil
|
return endpoint, nil
|
||||||
|
@ -239,7 +256,9 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload)
|
||||||
endpointType = portainer.AgentOnDockerEnvironment
|
endpointType = portainer.AgentOnDockerEnvironment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpointID := handler.EndpointService.GetNextIdentifier()
|
||||||
endpoint := &portainer.Endpoint{
|
endpoint := &portainer.Endpoint{
|
||||||
|
ID: portainer.EndpointID(endpointID),
|
||||||
Name: payload.Name,
|
Name: payload.Name,
|
||||||
URL: payload.URL,
|
URL: payload.URL,
|
||||||
Type: endpointType,
|
Type: endpointType,
|
||||||
|
@ -253,34 +272,49 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload)
|
||||||
AuthorizedTeams: []portainer.TeamID{},
|
AuthorizedTeams: []portainer.TeamID{},
|
||||||
Extensions: []portainer.EndpointExtension{},
|
Extensions: []portainer.EndpointExtension{},
|
||||||
Tags: payload.Tags,
|
Tags: payload.Tags,
|
||||||
}
|
Status: portainer.EndpointStatusUp,
|
||||||
|
Snapshots: []portainer.Snapshot{},
|
||||||
err = handler.EndpointService.CreateEndpoint(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filesystemError := handler.storeTLSFiles(endpoint, payload)
|
filesystemError := handler.storeTLSFiles(endpoint, payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
|
||||||
return nil, filesystemError
|
return nil, filesystemError
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
endpointCreationError := handler.snapshotAndPersistEndpoint(endpoint)
|
||||||
if err != nil {
|
if endpointCreationError != nil {
|
||||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
|
return nil, endpointCreationError
|
||||||
}
|
}
|
||||||
|
|
||||||
return endpoint, nil
|
return endpoint, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) snapshotAndPersistEndpoint(endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||||
|
snapshot, err := handler.Snapshotter.CreateSnapshot(endpoint)
|
||||||
|
endpoint.Status = portainer.EndpointStatusUp
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
|
||||||
|
endpoint.Status = portainer.EndpointStatusDown
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot != nil {
|
||||||
|
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.EndpointService.CreateEndpoint(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *endpointCreatePayload) *httperror.HandlerError {
|
func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *endpointCreatePayload) *httperror.HandlerError {
|
||||||
folder := strconv.Itoa(int(endpoint.ID))
|
folder := strconv.Itoa(int(endpoint.ID))
|
||||||
|
|
||||||
if !payload.TLSSkipVerify {
|
if !payload.TLSSkipVerify {
|
||||||
caCertPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCA, payload.TLSCACertFile)
|
caCertPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCA, payload.TLSCACertFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err}
|
||||||
}
|
}
|
||||||
endpoint.TLSConfig.TLSCACertPath = caCertPath
|
endpoint.TLSConfig.TLSCACertPath = caCertPath
|
||||||
|
@ -289,14 +323,12 @@ func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *end
|
||||||
if !payload.TLSSkipClientVerify {
|
if !payload.TLSSkipClientVerify {
|
||||||
certPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCert, payload.TLSCertFile)
|
certPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCert, payload.TLSCertFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err}
|
||||||
}
|
}
|
||||||
endpoint.TLSConfig.TLSCertPath = certPath
|
endpoint.TLSConfig.TLSCertPath = certPath
|
||||||
|
|
||||||
keyPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileKey, payload.TLSKeyFile)
|
keyPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileKey, payload.TLSKeyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err}
|
||||||
}
|
}
|
||||||
endpoint.TLSConfig.TLSKeyPath = keyPath
|
endpoint.TLSConfig.TLSKeyPath = keyPath
|
||||||
|
|
|
@ -23,5 +23,12 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = handler.requestBouncer.EndpointAccess(r, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideFields(endpoint)
|
||||||
|
|
||||||
return response.JSON(w, endpoint)
|
return response.JSON(w, endpoint)
|
||||||
}
|
}
|
||||||
|
|
42
api/http/handler/endpoints/endpoint_snapshot.go
Normal file
42
api/http/handler/endpoints/endpoint_snapshot.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package endpoints
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
|
"github.com/portainer/portainer/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST request on /api/endpoints/snapshot
|
||||||
|
func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
endpoints, err := handler.EndpointService.Endpoints()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, endpoint := range endpoints {
|
||||||
|
if endpoint.Type == portainer.AzureEnvironment {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := handler.Snapshotter.CreateSnapshot(&endpoint)
|
||||||
|
endpoint.Status = portainer.EndpointStatusUp
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
|
||||||
|
endpoint.Status = portainer.EndpointStatusDown
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot != nil {
|
||||||
|
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
|
@ -25,10 +25,12 @@ func hideFields(endpoint *portainer.Endpoint) {
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
authorizeEndpointManagement bool
|
authorizeEndpointManagement bool
|
||||||
|
requestBouncer *security.RequestBouncer
|
||||||
EndpointService portainer.EndpointService
|
EndpointService portainer.EndpointService
|
||||||
EndpointGroupService portainer.EndpointGroupService
|
EndpointGroupService portainer.EndpointGroupService
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
ProxyManager *proxy.Manager
|
ProxyManager *proxy.Manager
|
||||||
|
Snapshotter portainer.Snapshotter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage endpoint operations.
|
// NewHandler creates a handler to manage endpoint operations.
|
||||||
|
@ -36,14 +38,17 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
authorizeEndpointManagement: authorizeEndpointManagement,
|
authorizeEndpointManagement: authorizeEndpointManagement,
|
||||||
|
requestBouncer: bouncer,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.Handle("/endpoints",
|
h.Handle("/endpoints",
|
||||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost)
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/endpoints/snapshot",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
|
||||||
h.Handle("/endpoints",
|
h.Handle("/endpoints",
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
||||||
h.Handle("/endpoints/{id}",
|
h.Handle("/endpoints/{id}",
|
||||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointInspect))).Methods(http.MethodGet)
|
||||||
h.Handle("/endpoints/{id}",
|
h.Handle("/endpoints/{id}",
|
||||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
|
||||||
h.Handle("/endpoints/{id}/access",
|
h.Handle("/endpoints/{id}/access",
|
||||||
|
|
|
@ -15,6 +15,7 @@ type Handler struct {
|
||||||
SettingsService portainer.SettingsService
|
SettingsService portainer.SettingsService
|
||||||
LDAPService portainer.LDAPService
|
LDAPService portainer.LDAPService
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
|
JobScheduler portainer.JobScheduler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage settings operations.
|
// NewHandler creates a handler to manage settings operations.
|
||||||
|
|
|
@ -10,7 +10,6 @@ import (
|
||||||
|
|
||||||
type publicSettingsResponse struct {
|
type publicSettingsResponse struct {
|
||||||
LogoURL string `json:"LogoURL"`
|
LogoURL string `json:"LogoURL"`
|
||||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
|
||||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
||||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||||
|
@ -25,7 +24,6 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
|
||||||
|
|
||||||
publicSettings := &publicSettingsResponse{
|
publicSettings := &publicSettingsResponse{
|
||||||
LogoURL: settings.LogoURL,
|
LogoURL: settings.LogoURL,
|
||||||
DisplayExternalContributors: settings.DisplayExternalContributors,
|
|
||||||
AuthenticationMethod: settings.AuthenticationMethod,
|
AuthenticationMethod: settings.AuthenticationMethod,
|
||||||
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
|
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
|
||||||
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
|
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
|
||||||
|
|
|
@ -12,27 +12,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type settingsUpdatePayload struct {
|
type settingsUpdatePayload struct {
|
||||||
TemplatesURL string
|
LogoURL *string
|
||||||
LogoURL string
|
|
||||||
BlackListedLabels []portainer.Pair
|
BlackListedLabels []portainer.Pair
|
||||||
DisplayExternalContributors bool
|
AuthenticationMethod *int
|
||||||
AuthenticationMethod int
|
LDAPSettings *portainer.LDAPSettings
|
||||||
LDAPSettings portainer.LDAPSettings
|
AllowBindMountsForRegularUsers *bool
|
||||||
AllowBindMountsForRegularUsers bool
|
AllowPrivilegedModeForRegularUsers *bool
|
||||||
AllowPrivilegedModeForRegularUsers bool
|
SnapshotInterval *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||||
if govalidator.IsNull(payload.TemplatesURL) || !govalidator.IsURL(payload.TemplatesURL) {
|
if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 {
|
||||||
return portainer.Error("Invalid templates URL. Must correspond to a valid URL format")
|
|
||||||
}
|
|
||||||
if payload.AuthenticationMethod == 0 {
|
|
||||||
return portainer.Error("Invalid authentication method")
|
|
||||||
}
|
|
||||||
if payload.AuthenticationMethod != 1 && payload.AuthenticationMethod != 2 {
|
|
||||||
return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)")
|
return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)")
|
||||||
}
|
}
|
||||||
if !govalidator.IsNull(payload.LogoURL) && !govalidator.IsURL(payload.LogoURL) {
|
if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) {
|
||||||
return portainer.Error("Invalid logo URL. Must correspond to a valid URL format")
|
return portainer.Error("Invalid logo URL. Must correspond to a valid URL format")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -46,17 +39,40 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
settings := &portainer.Settings{
|
settings, err := handler.SettingsService.Settings()
|
||||||
TemplatesURL: payload.TemplatesURL,
|
if err != nil {
|
||||||
LogoURL: payload.LogoURL,
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
|
||||||
BlackListedLabels: payload.BlackListedLabels,
|
}
|
||||||
DisplayExternalContributors: payload.DisplayExternalContributors,
|
|
||||||
LDAPSettings: payload.LDAPSettings,
|
if payload.AuthenticationMethod != nil {
|
||||||
AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers,
|
settings.AuthenticationMethod = portainer.AuthenticationMethod(*payload.AuthenticationMethod)
|
||||||
AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers,
|
}
|
||||||
|
|
||||||
|
if payload.LogoURL != nil {
|
||||||
|
settings.LogoURL = *payload.LogoURL
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.BlackListedLabels != nil {
|
||||||
|
settings.BlackListedLabels = payload.BlackListedLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.LDAPSettings != nil {
|
||||||
|
settings.LDAPSettings = *payload.LDAPSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.AllowBindMountsForRegularUsers != nil {
|
||||||
|
settings.AllowBindMountsForRegularUsers = *payload.AllowBindMountsForRegularUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.AllowPrivilegedModeForRegularUsers != nil {
|
||||||
|
settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
|
||||||
|
settings.SnapshotInterval = *payload.SnapshotInterval
|
||||||
|
handler.JobScheduler.UpdateSnapshotJob(settings.SnapshotInterval)
|
||||||
}
|
}
|
||||||
|
|
||||||
settings.AuthenticationMethod = portainer.AuthenticationMethod(payload.AuthenticationMethod)
|
|
||||||
tlsError := handler.updateTLS(settings)
|
tlsError := handler.updateTLS(settings)
|
||||||
if tlsError != nil {
|
if tlsError != nil {
|
||||||
return tlsError
|
return tlsError
|
||||||
|
|
|
@ -17,6 +17,7 @@ import (
|
||||||
type composeStackFromFileContentPayload struct {
|
type composeStackFromFileContentPayload struct {
|
||||||
Name string
|
Name string
|
||||||
StackFileContent string
|
StackFileContent string
|
||||||
|
Env []portainer.Pair
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) error {
|
func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) error {
|
||||||
|
@ -54,6 +55,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||||
Type: portainer.DockerComposeStack,
|
Type: portainer.DockerComposeStack,
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||||
|
Env: payload.Env,
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
@ -88,10 +90,12 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||||
type composeStackFromGitRepositoryPayload struct {
|
type composeStackFromGitRepositoryPayload struct {
|
||||||
Name string
|
Name string
|
||||||
RepositoryURL string
|
RepositoryURL string
|
||||||
|
RepositoryReferenceName string
|
||||||
RepositoryAuthentication bool
|
RepositoryAuthentication bool
|
||||||
RepositoryUsername string
|
RepositoryUsername string
|
||||||
RepositoryPassword string
|
RepositoryPassword string
|
||||||
ComposeFilePathInRepository string
|
ComposeFilePathInRepository string
|
||||||
|
Env []portainer.Pair
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
||||||
|
@ -135,6 +139,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||||
Type: portainer.DockerComposeStack,
|
Type: portainer.DockerComposeStack,
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: payload.ComposeFilePathInRepository,
|
EntryPoint: payload.ComposeFilePathInRepository,
|
||||||
|
Env: payload.Env,
|
||||||
}
|
}
|
||||||
|
|
||||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||||
|
@ -142,19 +147,21 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||||
|
|
||||||
gitCloneParams := &cloneRepositoryParameters{
|
gitCloneParams := &cloneRepositoryParameters{
|
||||||
url: payload.RepositoryURL,
|
url: payload.RepositoryURL,
|
||||||
|
referenceName: payload.RepositoryReferenceName,
|
||||||
path: projectPath,
|
path: projectPath,
|
||||||
authentication: payload.RepositoryAuthentication,
|
authentication: payload.RepositoryAuthentication,
|
||||||
username: payload.RepositoryUsername,
|
username: payload.RepositoryUsername,
|
||||||
password: payload.RepositoryPassword,
|
password: payload.RepositoryPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doCleanUp := true
|
||||||
|
defer handler.cleanUp(stack, &doCleanUp)
|
||||||
|
|
||||||
err = handler.cloneGitRepository(gitCloneParams)
|
err = handler.cloneGitRepository(gitCloneParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
doCleanUp := true
|
|
||||||
defer handler.cleanUp(stack, &doCleanUp)
|
|
||||||
|
|
||||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||||
if configErr != nil {
|
if configErr != nil {
|
||||||
return configErr
|
return configErr
|
||||||
|
@ -177,6 +184,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||||
type composeStackFromFileUploadPayload struct {
|
type composeStackFromFileUploadPayload struct {
|
||||||
Name string
|
Name string
|
||||||
StackFileContent []byte
|
StackFileContent []byte
|
||||||
|
Env []portainer.Pair
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||||
|
@ -192,6 +200,12 @@ func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) erro
|
||||||
}
|
}
|
||||||
payload.StackFileContent = composeFileContent
|
payload.StackFileContent = composeFileContent
|
||||||
|
|
||||||
|
var env []portainer.Pair
|
||||||
|
err = request.RetrieveMultiPartFormJSONValue(r, "Env", &env, true)
|
||||||
|
if err != nil {
|
||||||
|
return portainer.Error("Invalid Env parameter")
|
||||||
|
}
|
||||||
|
payload.Env = env
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -220,6 +234,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||||
Type: portainer.DockerComposeStack,
|
Type: portainer.DockerComposeStack,
|
||||||
EndpointID: endpoint.ID,
|
EndpointID: endpoint.ID,
|
||||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||||
|
Env: payload.Env,
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
|
|
@ -97,6 +97,7 @@ type swarmStackFromGitRepositoryPayload struct {
|
||||||
SwarmID string
|
SwarmID string
|
||||||
Env []portainer.Pair
|
Env []portainer.Pair
|
||||||
RepositoryURL string
|
RepositoryURL string
|
||||||
|
RepositoryReferenceName string
|
||||||
RepositoryAuthentication bool
|
RepositoryAuthentication bool
|
||||||
RepositoryUsername string
|
RepositoryUsername string
|
||||||
RepositoryPassword string
|
RepositoryPassword string
|
||||||
|
@ -156,19 +157,21 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||||
|
|
||||||
gitCloneParams := &cloneRepositoryParameters{
|
gitCloneParams := &cloneRepositoryParameters{
|
||||||
url: payload.RepositoryURL,
|
url: payload.RepositoryURL,
|
||||||
|
referenceName: payload.RepositoryReferenceName,
|
||||||
path: projectPath,
|
path: projectPath,
|
||||||
authentication: payload.RepositoryAuthentication,
|
authentication: payload.RepositoryAuthentication,
|
||||||
username: payload.RepositoryUsername,
|
username: payload.RepositoryUsername,
|
||||||
password: payload.RepositoryPassword,
|
password: payload.RepositoryPassword,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doCleanUp := true
|
||||||
|
defer handler.cleanUp(stack, &doCleanUp)
|
||||||
|
|
||||||
err = handler.cloneGitRepository(gitCloneParams)
|
err = handler.cloneGitRepository(gitCloneParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
doCleanUp := true
|
|
||||||
defer handler.cleanUp(stack, &doCleanUp)
|
|
||||||
|
|
||||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||||
if configErr != nil {
|
if configErr != nil {
|
||||||
return configErr
|
return configErr
|
||||||
|
|
|
@ -2,6 +2,7 @@ package stacks
|
||||||
|
|
||||||
type cloneRepositoryParameters struct {
|
type cloneRepositoryParameters struct {
|
||||||
url string
|
url string
|
||||||
|
referenceName string
|
||||||
path string
|
path string
|
||||||
authentication bool
|
authentication bool
|
||||||
username string
|
username string
|
||||||
|
@ -10,7 +11,7 @@ type cloneRepositoryParameters struct {
|
||||||
|
|
||||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||||
if parameters.authentication {
|
if parameters.authentication {
|
||||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.path, parameters.username, parameters.password)
|
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||||
}
|
}
|
||||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.path)
|
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,5 +54,5 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, &stackFileResponse{StackFileContent: stackFileContent})
|
return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)})
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
|
|
||||||
type updateComposeStackPayload struct {
|
type updateComposeStackPayload struct {
|
||||||
StackFileContent string
|
StackFileContent string
|
||||||
|
Env []portainer.Pair
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *updateComposeStackPayload) Validate(r *http.Request) error {
|
func (payload *updateComposeStackPayload) Validate(r *http.Request) error {
|
||||||
|
@ -112,6 +113,8 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stack.Env = payload.Env
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
_, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
_, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/portainer/portainer/http/response"
|
"github.com/portainer/portainer/http/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DELETE request on /api/tags/:name
|
// DELETE request on /api/tags/:id
|
||||||
func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -9,14 +9,10 @@ import (
|
||||||
"github.com/portainer/portainer/http/security"
|
"github.com/portainer/portainer/http/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler represents an HTTP API handler for managing templates.
|
// Handler represents an HTTP API handler for managing templates.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
SettingsService portainer.SettingsService
|
TemplateService portainer.TemplateService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns a new instance of Handler.
|
// NewHandler returns a new instance of Handler.
|
||||||
|
@ -25,6 +21,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
}
|
}
|
||||||
h.Handle("/templates",
|
h.Handle("/templates",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/templates",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateCreate))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/templates/{id}",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateInspect))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/templates/{id}",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateUpdate))).Methods(http.MethodPut)
|
||||||
|
h.Handle("/templates/{id}",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateDelete))).Methods(http.MethodDelete)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
122
api/http/handler/templates/template_create.go
Normal file
122
api/http/handler/templates/template_create.go
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
"github.com/portainer/portainer/filesystem"
|
||||||
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
|
"github.com/portainer/portainer/http/request"
|
||||||
|
"github.com/portainer/portainer/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type templateCreatePayload struct {
|
||||||
|
// Mandatory
|
||||||
|
Type int
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
AdministratorOnly bool
|
||||||
|
|
||||||
|
// Opt stack/container
|
||||||
|
Name string
|
||||||
|
Logo string
|
||||||
|
Note string
|
||||||
|
Platform string
|
||||||
|
Categories []string
|
||||||
|
Env []portainer.TemplateEnv
|
||||||
|
|
||||||
|
// Mandatory container
|
||||||
|
Image string
|
||||||
|
|
||||||
|
// Mandatory stack
|
||||||
|
Repository portainer.TemplateRepository
|
||||||
|
|
||||||
|
// Opt container
|
||||||
|
Registry string
|
||||||
|
Command string
|
||||||
|
Network string
|
||||||
|
Volumes []portainer.TemplateVolume
|
||||||
|
Ports []string
|
||||||
|
Labels []portainer.Pair
|
||||||
|
Privileged bool
|
||||||
|
Interactive bool
|
||||||
|
RestartPolicy string
|
||||||
|
Hostname string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *templateCreatePayload) Validate(r *http.Request) error {
|
||||||
|
if payload.Type == 0 || (payload.Type != 1 && payload.Type != 2 && payload.Type != 3) {
|
||||||
|
return portainer.Error("Invalid template type. Valid values are: 1 (container), 2 (Swarm stack template) or 3 (Compose stack template).")
|
||||||
|
}
|
||||||
|
if govalidator.IsNull(payload.Title) {
|
||||||
|
return portainer.Error("Invalid template title")
|
||||||
|
}
|
||||||
|
if govalidator.IsNull(payload.Description) {
|
||||||
|
return portainer.Error("Invalid template description")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Type == 1 {
|
||||||
|
if govalidator.IsNull(payload.Image) {
|
||||||
|
return portainer.Error("Invalid template image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Type == 2 || payload.Type == 3 {
|
||||||
|
if govalidator.IsNull(payload.Repository.URL) {
|
||||||
|
return portainer.Error("Invalid template repository URL")
|
||||||
|
}
|
||||||
|
if govalidator.IsNull(payload.Repository.StackFile) {
|
||||||
|
payload.Repository.StackFile = filesystem.ComposeFileDefaultName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST request on /api/templates
|
||||||
|
func (handler *Handler) templateCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
var payload templateCreatePayload
|
||||||
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &portainer.Template{
|
||||||
|
Type: portainer.TemplateType(payload.Type),
|
||||||
|
Title: payload.Title,
|
||||||
|
Description: payload.Description,
|
||||||
|
AdministratorOnly: payload.AdministratorOnly,
|
||||||
|
Name: payload.Name,
|
||||||
|
Logo: payload.Logo,
|
||||||
|
Note: payload.Note,
|
||||||
|
Platform: payload.Platform,
|
||||||
|
Categories: payload.Categories,
|
||||||
|
Env: payload.Env,
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.Type == portainer.ContainerTemplate {
|
||||||
|
template.Image = payload.Image
|
||||||
|
template.Registry = payload.Registry
|
||||||
|
template.Command = payload.Command
|
||||||
|
template.Network = payload.Network
|
||||||
|
template.Volumes = payload.Volumes
|
||||||
|
template.Ports = payload.Ports
|
||||||
|
template.Labels = payload.Labels
|
||||||
|
template.Privileged = payload.Privileged
|
||||||
|
template.Interactive = payload.Interactive
|
||||||
|
template.RestartPolicy = payload.RestartPolicy
|
||||||
|
template.Hostname = payload.Hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.Type == portainer.SwarmStackTemplate || template.Type == portainer.ComposeStackTemplate {
|
||||||
|
template.Repository = payload.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.TemplateService.CreateTemplate(template)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the template inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, template)
|
||||||
|
}
|
25
api/http/handler/templates/template_delete.go
Normal file
25
api/http/handler/templates/template_delete.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
|
"github.com/portainer/portainer/http/request"
|
||||||
|
"github.com/portainer/portainer/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DELETE request on /api/templates/:id
|
||||||
|
func (handler *Handler) templateDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.TemplateService.DeleteTemplate(portainer.TemplateID(id))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the template from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
27
api/http/handler/templates/template_inspect.go
Normal file
27
api/http/handler/templates/template_inspect.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
|
"github.com/portainer/portainer/http/request"
|
||||||
|
"github.com/portainer/portainer/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET request on /api/templates/:id
|
||||||
|
func (handler *Handler) templateInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
templateID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := handler.TemplateService.Template(portainer.TemplateID(templateID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a template with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a template with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, template)
|
||||||
|
}
|
|
@ -1,50 +1,26 @@
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
httperror "github.com/portainer/portainer/http/error"
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
"github.com/portainer/portainer/http/request"
|
|
||||||
"github.com/portainer/portainer/http/response"
|
"github.com/portainer/portainer/http/response"
|
||||||
|
"github.com/portainer/portainer/http/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GET request on /api/templates?key=<key>
|
// GET request on /api/templates
|
||||||
func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
key, err := request.RetrieveQueryParameter(r, "key", false)
|
templates, err := handler.TemplateService.Templates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: key", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates from the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
templatesURL, templateErr := handler.retrieveTemplateURLFromKey(key)
|
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||||
if templateErr != nil {
|
|
||||||
return templateErr
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.Get(templatesURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates via the network", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to read template response", err}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Bytes(w, body, "application/json")
|
filteredTemplates := security.FilterTemplates(templates, securityContext)
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) retrieveTemplateURLFromKey(key string) (string, *httperror.HandlerError) {
|
return response.JSON(w, filteredTemplates)
|
||||||
switch key {
|
|
||||||
case "containers":
|
|
||||||
settings, err := handler.SettingsService.Settings()
|
|
||||||
if err != nil {
|
|
||||||
return "", &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
|
||||||
}
|
|
||||||
return settings.TemplatesURL, nil
|
|
||||||
case "linuxserver.io":
|
|
||||||
return containerTemplatesURLLinuxServerIo, nil
|
|
||||||
}
|
|
||||||
return "", &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: key. Value must be one of: containers or linuxserver.io", request.ErrInvalidQueryParameter}
|
|
||||||
}
|
}
|
||||||
|
|
164
api/http/handler/templates/template_update.go
Normal file
164
api/http/handler/templates/template_update.go
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
|
"github.com/portainer/portainer/http/request"
|
||||||
|
"github.com/portainer/portainer/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type templateUpdatePayload struct {
|
||||||
|
Title *string
|
||||||
|
Description *string
|
||||||
|
AdministratorOnly *bool
|
||||||
|
Name *string
|
||||||
|
Logo *string
|
||||||
|
Note *string
|
||||||
|
Platform *string
|
||||||
|
Categories []string
|
||||||
|
Env []portainer.TemplateEnv
|
||||||
|
Image *string
|
||||||
|
Registry *string
|
||||||
|
Repository portainer.TemplateRepository
|
||||||
|
Command *string
|
||||||
|
Network *string
|
||||||
|
Volumes []portainer.TemplateVolume
|
||||||
|
Ports []string
|
||||||
|
Labels []portainer.Pair
|
||||||
|
Privileged *bool
|
||||||
|
Interactive *bool
|
||||||
|
RestartPolicy *string
|
||||||
|
Hostname *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *templateUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT request on /api/templates/:id
|
||||||
|
func (handler *Handler) templateUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
templateID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := handler.TemplateService.Template(portainer.TemplateID(templateID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a template with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a template with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload templateUpdatePayload
|
||||||
|
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTemplate(template, &payload)
|
||||||
|
|
||||||
|
err = handler.TemplateService.UpdateTemplate(template.ID, template)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to persist template changes inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateContainerProperties(template *portainer.Template, payload *templateUpdatePayload) {
|
||||||
|
if payload.Image != nil {
|
||||||
|
template.Image = *payload.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Registry != nil {
|
||||||
|
template.Registry = *payload.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Command != nil {
|
||||||
|
template.Command = *payload.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Network != nil {
|
||||||
|
template.Network = *payload.Network
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Volumes != nil {
|
||||||
|
template.Volumes = payload.Volumes
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Ports != nil {
|
||||||
|
template.Ports = payload.Ports
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Labels != nil {
|
||||||
|
template.Labels = payload.Labels
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Privileged != nil {
|
||||||
|
template.Privileged = *payload.Privileged
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Interactive != nil {
|
||||||
|
template.Interactive = *payload.Interactive
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.RestartPolicy != nil {
|
||||||
|
template.RestartPolicy = *payload.RestartPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Hostname != nil {
|
||||||
|
template.Hostname = *payload.Hostname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStackProperties(template *portainer.Template, payload *templateUpdatePayload) {
|
||||||
|
if payload.Repository.URL != "" && payload.Repository.StackFile != "" {
|
||||||
|
template.Repository = payload.Repository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTemplate(template *portainer.Template, payload *templateUpdatePayload) {
|
||||||
|
if payload.Title != nil {
|
||||||
|
template.Title = *payload.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Description != nil {
|
||||||
|
template.Description = *payload.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Name != nil {
|
||||||
|
template.Name = *payload.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Logo != nil {
|
||||||
|
template.Logo = *payload.Logo
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Note != nil {
|
||||||
|
template.Note = *payload.Note
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Platform != nil {
|
||||||
|
template.Platform = *payload.Platform
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Categories != nil {
|
||||||
|
template.Categories = payload.Categories
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Env != nil {
|
||||||
|
template.Env = payload.Env
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.AdministratorOnly != nil {
|
||||||
|
template.AdministratorOnly = *payload.AdministratorOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.Type == portainer.ContainerTemplate {
|
||||||
|
updateContainerProperties(template, payload)
|
||||||
|
} else if template.Type == portainer.SwarmStackTemplate || template.Type == portainer.ComposeStackTemplate {
|
||||||
|
updateStackProperties(template, payload)
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,19 +26,47 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http
|
||||||
return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", portainer.ErrAdminCannotRemoveSelf}
|
return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", portainer.ErrAdminCannotRemoveSelf}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = handler.UserService.User(portainer.UserID(userID))
|
user, err := handler.UserService.User(portainer.UserID(userID))
|
||||||
if err == portainer.ErrObjectNotFound {
|
if err == portainer.ErrObjectNotFound {
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err}
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.UserService.DeleteUser(portainer.UserID(userID))
|
if user.Role == portainer.AdministratorRole {
|
||||||
|
return handler.deleteAdminUser(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.deleteUser(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
|
||||||
|
users, err := handler.UserService.Users()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
localAdminCount := 0
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Role == portainer.AdministratorRole && u.Password != "" {
|
||||||
|
localAdminCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if localAdminCount < 2 {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Cannot remove local administrator user", portainer.ErrCannotRemoveLastLocalAdmin}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler.deleteUser(w, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) deleteUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError {
|
||||||
|
err := handler.UserService.DeleteUser(portainer.UserID(user.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID))
|
err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(user.ID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err}
|
||||||
}
|
}
|
||||||
|
|
11
api/http/handler/websocket/websocket_dial.go
Normal file
11
api/http/handler/websocket/websocket_dial.go
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createDial(scheme, host string) (net.Conn, error) {
|
||||||
|
return net.Dial(scheme, host)
|
||||||
|
}
|
16
api/http/handler/websocket/websocket_dial_windows.go
Normal file
16
api/http/handler/websocket/websocket_dial_windows.go
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package websocket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/Microsoft/go-winio"
|
||||||
|
)
|
||||||
|
|
||||||
|
func createDial(scheme, host string) (net.Conn, error) {
|
||||||
|
if scheme == "npipe" {
|
||||||
|
return winio.DialPipe(host, nil)
|
||||||
|
}
|
||||||
|
return net.Dial(scheme, host)
|
||||||
|
}
|
|
@ -80,7 +80,7 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
|
||||||
func (handler *Handler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
|
func (handler *Handler) handleRequest(w http.ResponseWriter, r *http.Request, params *webSocketExecRequestParams) error {
|
||||||
r.Header.Del("Origin")
|
r.Header.Del("Origin")
|
||||||
|
|
||||||
if params.nodeName != "" {
|
if params.nodeName != "" || params.endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||||
return handler.proxyWebsocketRequest(w, r, params)
|
return handler.proxyWebsocketRequest(w, r, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,7 +127,7 @@ func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Req
|
||||||
}
|
}
|
||||||
|
|
||||||
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
|
func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer.Endpoint, execID string) error {
|
||||||
dial, err := createDial(endpoint)
|
dial, err := initDial(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -158,16 +158,15 @@ func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
|
func initDial(endpoint *portainer.Endpoint) (net.Conn, error) {
|
||||||
url, err := url.Parse(endpoint.URL)
|
url, err := url.Parse(endpoint.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var host string
|
host := url.Host
|
||||||
if url.Scheme == "tcp" {
|
|
||||||
host = url.Host
|
if url.Scheme == "unix" || url.Scheme == "npipe" {
|
||||||
} else if url.Scheme == "unix" {
|
|
||||||
host = url.Path
|
host = url.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -176,10 +175,11 @@ func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tls.Dial(url.Scheme, host, tlsConfig)
|
return tls.Dial(url.Scheme, host, tlsConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
return net.Dial(url.Scheme, host)
|
return createDial(url.Scheme, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createExecStartRequest(execID string) (*http.Request, error) {
|
func createExecStartRequest(execID string) (*http.Request, error) {
|
||||||
|
|
|
@ -248,7 +248,7 @@ func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response
|
||||||
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
|
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
|
||||||
switch requestPath := request.URL.Path; requestPath {
|
switch requestPath := request.URL.Path; requestPath {
|
||||||
case "/swarm":
|
case "/swarm":
|
||||||
return p.executeDockerRequest(request)
|
return p.rewriteOperation(request, swarmInspectOperation)
|
||||||
default:
|
default:
|
||||||
// assume /swarm/{action}
|
// assume /swarm/{action}
|
||||||
return p.administratorOperation(request)
|
return p.administratorOperation(request)
|
||||||
|
|
|
@ -58,21 +58,6 @@ func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool
|
||||||
return factory.createDockerReverseProxy(u, enableSignature)
|
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,
|
|
||||||
RegistryService: factory.RegistryService,
|
|
||||||
DockerHubService: factory.DockerHubService,
|
|
||||||
dockerTransport: newSocketTransport(path),
|
|
||||||
}
|
|
||||||
proxy.Transport = transport
|
|
||||||
return proxy
|
|
||||||
}
|
|
||||||
|
|
||||||
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool) *httputil.ReverseProxy {
|
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool) *httputil.ReverseProxy {
|
||||||
proxy := newSingleHostReverseProxyWithHostHeader(u)
|
proxy := newSingleHostReverseProxyWithHostHeader(u)
|
||||||
transport := &proxyTransport{
|
transport := &proxyTransport{
|
||||||
|
|
22
api/http/proxy/factory_local.go
Normal file
22
api/http/proxy/factory_local.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
// +build !windows
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (factory *proxyFactory) newLocalProxy(path string) http.Handler {
|
||||||
|
proxy := &localProxy{}
|
||||||
|
transport := &proxyTransport{
|
||||||
|
enableSignature: false,
|
||||||
|
ResourceControlService: factory.ResourceControlService,
|
||||||
|
TeamMembershipService: factory.TeamMembershipService,
|
||||||
|
SettingsService: factory.SettingsService,
|
||||||
|
RegistryService: factory.RegistryService,
|
||||||
|
DockerHubService: factory.DockerHubService,
|
||||||
|
dockerTransport: newSocketTransport(path),
|
||||||
|
}
|
||||||
|
proxy.Transport = transport
|
||||||
|
return proxy
|
||||||
|
}
|
33
api/http/proxy/factory_local_windows.go
Normal file
33
api/http/proxy/factory_local_windows.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
// +build windows
|
||||||
|
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/Microsoft/go-winio"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (factory *proxyFactory) newLocalProxy(path string) http.Handler {
|
||||||
|
proxy := &localProxy{}
|
||||||
|
transport := &proxyTransport{
|
||||||
|
enableSignature: false,
|
||||||
|
ResourceControlService: factory.ResourceControlService,
|
||||||
|
TeamMembershipService: factory.TeamMembershipService,
|
||||||
|
SettingsService: factory.SettingsService,
|
||||||
|
RegistryService: factory.RegistryService,
|
||||||
|
DockerHubService: factory.DockerHubService,
|
||||||
|
dockerTransport: newNamedPipeTransport(path),
|
||||||
|
}
|
||||||
|
proxy.Transport = transport
|
||||||
|
return proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNamedPipeTransport(namedPipePath string) *http.Transport {
|
||||||
|
return &http.Transport{
|
||||||
|
Dial: func(proto, addr string) (conn net.Conn, err error) {
|
||||||
|
return winio.DialPipe(namedPipePath, nil)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,5 @@
|
||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
@ -9,11 +8,11 @@ import (
|
||||||
httperror "github.com/portainer/portainer/http/error"
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
)
|
)
|
||||||
|
|
||||||
type socketProxy struct {
|
type localProxy struct {
|
||||||
Transport *proxyTransport
|
Transport *proxyTransport
|
||||||
}
|
}
|
||||||
|
|
||||||
func (proxy *socketProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (proxy *localProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// Force URL/domain to http/unixsocket to be able to
|
// Force URL/domain to http/unixsocket to be able to
|
||||||
// use http.Transport RoundTrip to do the requests via the socket
|
// use http.Transport RoundTrip to do the requests via the socket
|
||||||
r.URL.Scheme = "http"
|
r.URL.Scheme = "http"
|
|
@ -51,8 +51,7 @@ func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *porta
|
||||||
}
|
}
|
||||||
return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false), nil
|
return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false), nil
|
||||||
}
|
}
|
||||||
// Assume unix:// scheme
|
return manager.proxyFactory.newLocalProxy(endpointURL.Path), nil
|
||||||
return manager.proxyFactory.newDockerSocketProxy(endpointURL.Path), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||||
|
|
23
api/http/proxy/swarm.go
Normal file
23
api/http/proxy/swarm.go
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swarmInspectOperation extracts the response as a JSON object and rewrites the response based
|
||||||
|
// on the current user role. Sensitive fields are deleted from the response for non-administrator users.
|
||||||
|
func swarmInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||||
|
// SwarmInspect response is a JSON object
|
||||||
|
// https://docs.docker.com/engine/api/v1.30/#operation/SwarmInspect
|
||||||
|
responseObject, err := getResponseAsJSONOBject(response)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !executor.operationContext.isAdmin {
|
||||||
|
delete(responseObject, "JoinTokens")
|
||||||
|
delete(responseObject, "TLSInfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rewriteResponse(response, responseObject, http.StatusOK)
|
||||||
|
}
|
|
@ -133,6 +133,9 @@ func RetrieveNumericQueryParameter(request *http.Request, name string, optional
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
if queryParameter == "" && optional {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
return strconv.Atoi(queryParameter)
|
return strconv.Atoi(queryParameter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,10 +23,3 @@ func Empty(rw http.ResponseWriter) *httperror.HandlerError {
|
||||||
rw.WriteHeader(http.StatusNoContent)
|
rw.WriteHeader(http.StatusNoContent)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bytes write data into rw. It also allows to set the Content-Type header.
|
|
||||||
func Bytes(rw http.ResponseWriter, data []byte, contentType string) *httperror.HandlerError {
|
|
||||||
rw.Header().Set("Content-Type", contentType)
|
|
||||||
rw.Write(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -77,6 +77,24 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
|
||||||
return filteredRegistries
|
return filteredRegistries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterTemplates filters templates based on the user role.
|
||||||
|
// Non-administrato template do not have access to templates where the AdministratorOnly flag is set to true.
|
||||||
|
func FilterTemplates(templates []portainer.Template, context *RestrictedRequestContext) []portainer.Template {
|
||||||
|
filteredTemplates := templates
|
||||||
|
|
||||||
|
if !context.IsAdmin {
|
||||||
|
filteredTemplates = make([]portainer.Template, 0)
|
||||||
|
|
||||||
|
for _, template := range templates {
|
||||||
|
if !template.AdministratorOnly {
|
||||||
|
filteredTemplates = append(filteredTemplates, template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredTemplates
|
||||||
|
}
|
||||||
|
|
||||||
// FilterEndpoints filters endpoints based on user role and team memberships.
|
// FilterEndpoints filters endpoints based on user role and team memberships.
|
||||||
// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
|
// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
|
||||||
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint {
|
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint {
|
||||||
|
|
|
@ -40,6 +40,8 @@ type Server struct {
|
||||||
ComposeStackManager portainer.ComposeStackManager
|
ComposeStackManager portainer.ComposeStackManager
|
||||||
CryptoService portainer.CryptoService
|
CryptoService portainer.CryptoService
|
||||||
SignatureService portainer.DigitalSignatureService
|
SignatureService portainer.DigitalSignatureService
|
||||||
|
JobScheduler portainer.JobScheduler
|
||||||
|
Snapshotter portainer.Snapshotter
|
||||||
DockerHubService portainer.DockerHubService
|
DockerHubService portainer.DockerHubService
|
||||||
EndpointService portainer.EndpointService
|
EndpointService portainer.EndpointService
|
||||||
EndpointGroupService portainer.EndpointGroupService
|
EndpointGroupService portainer.EndpointGroupService
|
||||||
|
@ -55,6 +57,7 @@ type Server struct {
|
||||||
TagService portainer.TagService
|
TagService portainer.TagService
|
||||||
TeamService portainer.TeamService
|
TeamService portainer.TeamService
|
||||||
TeamMembershipService portainer.TeamMembershipService
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
|
TemplateService portainer.TemplateService
|
||||||
UserService portainer.UserService
|
UserService portainer.UserService
|
||||||
Handler *handler.Handler
|
Handler *handler.Handler
|
||||||
SSL bool
|
SSL bool
|
||||||
|
@ -89,6 +92,8 @@ func (server *Server) Start() error {
|
||||||
authHandler.JWTService = server.JWTService
|
authHandler.JWTService = server.JWTService
|
||||||
authHandler.LDAPService = server.LDAPService
|
authHandler.LDAPService = server.LDAPService
|
||||||
authHandler.SettingsService = server.SettingsService
|
authHandler.SettingsService = server.SettingsService
|
||||||
|
authHandler.TeamService = server.TeamService
|
||||||
|
authHandler.TeamMembershipService = server.TeamMembershipService
|
||||||
|
|
||||||
var dockerHubHandler = dockerhub.NewHandler(requestBouncer)
|
var dockerHubHandler = dockerhub.NewHandler(requestBouncer)
|
||||||
dockerHubHandler.DockerHubService = server.DockerHubService
|
dockerHubHandler.DockerHubService = server.DockerHubService
|
||||||
|
@ -98,6 +103,7 @@ func (server *Server) Start() error {
|
||||||
endpointHandler.EndpointGroupService = server.EndpointGroupService
|
endpointHandler.EndpointGroupService = server.EndpointGroupService
|
||||||
endpointHandler.FileService = server.FileService
|
endpointHandler.FileService = server.FileService
|
||||||
endpointHandler.ProxyManager = proxyManager
|
endpointHandler.ProxyManager = proxyManager
|
||||||
|
endpointHandler.Snapshotter = server.Snapshotter
|
||||||
|
|
||||||
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
|
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
|
||||||
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
|
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
|
||||||
|
@ -119,6 +125,7 @@ func (server *Server) Start() error {
|
||||||
settingsHandler.SettingsService = server.SettingsService
|
settingsHandler.SettingsService = server.SettingsService
|
||||||
settingsHandler.LDAPService = server.LDAPService
|
settingsHandler.LDAPService = server.LDAPService
|
||||||
settingsHandler.FileService = server.FileService
|
settingsHandler.FileService = server.FileService
|
||||||
|
settingsHandler.JobScheduler = server.JobScheduler
|
||||||
|
|
||||||
var stackHandler = stacks.NewHandler(requestBouncer)
|
var stackHandler = stacks.NewHandler(requestBouncer)
|
||||||
stackHandler.FileService = server.FileService
|
stackHandler.FileService = server.FileService
|
||||||
|
@ -143,7 +150,7 @@ func (server *Server) Start() error {
|
||||||
var statusHandler = status.NewHandler(requestBouncer, server.Status)
|
var statusHandler = status.NewHandler(requestBouncer, server.Status)
|
||||||
|
|
||||||
var templatesHandler = templates.NewHandler(requestBouncer)
|
var templatesHandler = templates.NewHandler(requestBouncer)
|
||||||
templatesHandler.SettingsService = server.SettingsService
|
templatesHandler.TemplateService = server.TemplateService
|
||||||
|
|
||||||
var uploadHandler = upload.NewHandler(requestBouncer)
|
var uploadHandler = upload.NewHandler(requestBouncer)
|
||||||
uploadHandler.FileService = server.FileService
|
uploadHandler.FileService = server.FileService
|
||||||
|
|
|
@ -102,12 +102,65 @@ func (*Service) AuthenticateUser(username, password string, settings *portainer.
|
||||||
|
|
||||||
err = connection.Bind(userDN, password)
|
err = connection.Bind(userDN, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return portainer.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserGroups is used to retrieve user groups from LDAP/AD.
|
||||||
|
func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings) ([]string, error) {
|
||||||
|
connection, err := createConnection(settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
|
||||||
|
err = connection.Bind(settings.ReaderDN, settings.Password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userDN, err := searchUser(username, connection, settings.SearchSettings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userGroups := getGroups(userDN, connection, settings.GroupSearchSettings)
|
||||||
|
|
||||||
|
return userGroups, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a list of group names for specified user from LDAP/AD
|
||||||
|
func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
|
||||||
|
groups := make([]string, 0)
|
||||||
|
|
||||||
|
for _, searchSettings := range settings {
|
||||||
|
searchRequest := ldap.NewSearchRequest(
|
||||||
|
searchSettings.GroupBaseDN,
|
||||||
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||||
|
fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDN),
|
||||||
|
[]string{"cn"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deliberately skip errors on the search request so that we can jump to other search settings
|
||||||
|
// if any issue arise with the current one.
|
||||||
|
sr, err := conn.Search(searchRequest)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range sr.Entries {
|
||||||
|
for _, attr := range entry.Attributes {
|
||||||
|
groups = append(groups, attr.Values[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
// TestConnectivity is used to test a connection against the LDAP server using the credentials
|
// TestConnectivity is used to test a connection against the LDAP server using the credentials
|
||||||
// specified in the LDAPSettings.
|
// specified in the LDAPSettings.
|
||||||
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
|
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/portainer/libcompose/config"
|
||||||
"github.com/portainer/libcompose/docker"
|
"github.com/portainer/libcompose/docker"
|
||||||
"github.com/portainer/libcompose/docker/client"
|
"github.com/portainer/libcompose/docker/client"
|
||||||
"github.com/portainer/libcompose/docker/ctx"
|
"github.com/portainer/libcompose/docker/ctx"
|
||||||
|
@ -26,29 +27,51 @@ func NewComposeStackManager(dataPath string) *ComposeStackManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createClient(endpoint *portainer.Endpoint) (client.Factory, error) {
|
||||||
|
clientOpts := client.Options{
|
||||||
|
Host: endpoint.URL,
|
||||||
|
APIVersion: portainer.SupportedDockerAPIVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpoint.TLSConfig.TLS {
|
||||||
|
clientOpts.TLS = endpoint.TLSConfig.TLS
|
||||||
|
clientOpts.TLSVerify = !endpoint.TLSConfig.TLSSkipVerify
|
||||||
|
clientOpts.TLSCAFile = endpoint.TLSConfig.TLSCACertPath
|
||||||
|
clientOpts.TLSCertFile = endpoint.TLSConfig.TLSCertPath
|
||||||
|
clientOpts.TLSKeyFile = endpoint.TLSConfig.TLSKeyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.NewDefaultFactory(clientOpts)
|
||||||
|
}
|
||||||
|
|
||||||
// Up will deploy a compose stack (equivalent of docker-compose up)
|
// Up will deploy a compose stack (equivalent of docker-compose up)
|
||||||
func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||||
clientFactory, err := client.NewDefaultFactory(client.Options{
|
|
||||||
TLS: endpoint.TLSConfig.TLS,
|
clientFactory, err := createClient(endpoint)
|
||||||
TLSVerify: endpoint.TLSConfig.TLSSkipVerify,
|
|
||||||
Host: endpoint.URL,
|
|
||||||
TLSCAFile: endpoint.TLSCACertPath,
|
|
||||||
TLSCertFile: endpoint.TLSCertPath,
|
|
||||||
TLSKeyFile: endpoint.TLSKeyPath,
|
|
||||||
APIVersion: "1.24",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
env := make(map[string]string)
|
||||||
|
for _, envvar := range stack.Env {
|
||||||
|
env[envvar.Name] = envvar.Value
|
||||||
|
}
|
||||||
|
|
||||||
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||||
proj, err := docker.NewProject(&ctx.Context{
|
proj, err := docker.NewProject(&ctx.Context{
|
||||||
ConfigDir: manager.dataPath,
|
ConfigDir: manager.dataPath,
|
||||||
Context: project.Context{
|
Context: project.Context{
|
||||||
ComposeFiles: []string{composeFilePath},
|
ComposeFiles: []string{composeFilePath},
|
||||||
EnvironmentLookup: &lookup.EnvfileLookup{
|
EnvironmentLookup: &lookup.ComposableEnvLookup{
|
||||||
|
Lookups: []config.EnvironmentLookup{
|
||||||
|
&lookup.EnvfileLookup{
|
||||||
Path: filepath.Join(stack.ProjectPath, ".env"),
|
Path: filepath.Join(stack.ProjectPath, ".env"),
|
||||||
},
|
},
|
||||||
|
&lookup.MapLookup{
|
||||||
|
Vars: env,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
ProjectName: stack.Name,
|
ProjectName: stack.Name,
|
||||||
},
|
},
|
||||||
ClientFactory: clientFactory,
|
ClientFactory: clientFactory,
|
||||||
|
@ -62,15 +85,7 @@ func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portain
|
||||||
|
|
||||||
// Down will shutdown a compose stack (equivalent of docker-compose down)
|
// Down will shutdown a compose stack (equivalent of docker-compose down)
|
||||||
func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||||
clientFactory, err := client.NewDefaultFactory(client.Options{
|
clientFactory, err := createClient(endpoint)
|
||||||
TLS: endpoint.TLSConfig.TLS,
|
|
||||||
TLSVerify: endpoint.TLSConfig.TLSSkipVerify,
|
|
||||||
Host: endpoint.URL,
|
|
||||||
TLSCAFile: endpoint.TLSCACertPath,
|
|
||||||
TLSCertFile: endpoint.TLSCertPath,
|
|
||||||
TLSKeyFile: endpoint.TLSKeyPath,
|
|
||||||
APIVersion: "1.24",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
169
api/portainer.go
169
api/portainer.go
|
@ -21,6 +21,7 @@ type (
|
||||||
NoAuth *bool
|
NoAuth *bool
|
||||||
NoAnalytics *bool
|
NoAnalytics *bool
|
||||||
Templates *string
|
Templates *string
|
||||||
|
TemplateFile *string
|
||||||
TLS *bool
|
TLS *bool
|
||||||
TLSSkipVerify *bool
|
TLSSkipVerify *bool
|
||||||
TLSCacert *string
|
TLSCacert *string
|
||||||
|
@ -30,12 +31,15 @@ type (
|
||||||
SSLCert *string
|
SSLCert *string
|
||||||
SSLKey *string
|
SSLKey *string
|
||||||
SyncInterval *string
|
SyncInterval *string
|
||||||
|
Snapshot *bool
|
||||||
|
SnapshotInterval *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status represents the application status.
|
// Status represents the application status.
|
||||||
Status struct {
|
Status struct {
|
||||||
Authentication bool `json:"Authentication"`
|
Authentication bool `json:"Authentication"`
|
||||||
EndpointManagement bool `json:"EndpointManagement"`
|
EndpointManagement bool `json:"EndpointManagement"`
|
||||||
|
Snapshot bool `json:"Snapshot"`
|
||||||
Analytics bool `json:"Analytics"`
|
Analytics bool `json:"Analytics"`
|
||||||
Version string `json:"Version"`
|
Version string `json:"Version"`
|
||||||
}
|
}
|
||||||
|
@ -48,6 +52,8 @@ type (
|
||||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||||
StartTLS bool `json:"StartTLS"`
|
StartTLS bool `json:"StartTLS"`
|
||||||
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
|
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
|
||||||
|
GroupSearchSettings []LDAPGroupSearchSettings `json:"GroupSearchSettings"`
|
||||||
|
AutoCreateUsers bool `json:"AutoCreateUsers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLSConfiguration represents a TLS configuration.
|
// TLSConfiguration represents a TLS configuration.
|
||||||
|
@ -66,18 +72,27 @@ type (
|
||||||
UserNameAttribute string `json:"UserNameAttribute"`
|
UserNameAttribute string `json:"UserNameAttribute"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server.
|
||||||
|
LDAPGroupSearchSettings struct {
|
||||||
|
GroupBaseDN string `json:"GroupBaseDN"`
|
||||||
|
GroupFilter string `json:"GroupFilter"`
|
||||||
|
GroupAttribute string `json:"GroupAttribute"`
|
||||||
|
}
|
||||||
|
|
||||||
// Settings represents the application settings.
|
// Settings represents the application settings.
|
||||||
Settings struct {
|
Settings struct {
|
||||||
TemplatesURL string `json:"TemplatesURL"`
|
|
||||||
LogoURL string `json:"LogoURL"`
|
LogoURL string `json:"LogoURL"`
|
||||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
|
||||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
||||||
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
||||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||||
|
SnapshotInterval string `json:"SnapshotInterval"`
|
||||||
|
|
||||||
// Deprecated fields
|
// Deprecated fields
|
||||||
DisplayDonationHeader bool
|
DisplayDonationHeader bool
|
||||||
|
DisplayExternalContributors bool
|
||||||
|
TemplatesURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// User represents a user account.
|
// User represents a user account.
|
||||||
|
@ -176,6 +191,9 @@ type (
|
||||||
// EndpointType represents the type of an endpoint.
|
// EndpointType represents the type of an endpoint.
|
||||||
EndpointType int
|
EndpointType int
|
||||||
|
|
||||||
|
// EndpointStatus represents the status of an endpoint
|
||||||
|
EndpointStatus int
|
||||||
|
|
||||||
// Endpoint represents a Docker endpoint with all the info required
|
// Endpoint represents a Docker endpoint with all the info required
|
||||||
// to connect to it.
|
// to connect to it.
|
||||||
Endpoint struct {
|
Endpoint struct {
|
||||||
|
@ -191,6 +209,8 @@ type (
|
||||||
Extensions []EndpointExtension `json:"Extensions"`
|
Extensions []EndpointExtension `json:"Extensions"`
|
||||||
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
|
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
|
||||||
Tags []string `json:"Tags"`
|
Tags []string `json:"Tags"`
|
||||||
|
Status EndpointStatus `json:"Status"`
|
||||||
|
Snapshots []Snapshot `json:"Snapshots"`
|
||||||
|
|
||||||
// Deprecated fields
|
// Deprecated fields
|
||||||
// Deprecated in DBVersion == 4
|
// Deprecated in DBVersion == 4
|
||||||
|
@ -208,6 +228,21 @@ type (
|
||||||
AuthenticationKey string `json:"AuthenticationKey"`
|
AuthenticationKey string `json:"AuthenticationKey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Snapshot represents a snapshot of a specific endpoint at a specific time
|
||||||
|
Snapshot struct {
|
||||||
|
Time int64 `json:"Time"`
|
||||||
|
DockerVersion string `json:"DockerVersion"`
|
||||||
|
Swarm bool `json:"Swarm"`
|
||||||
|
TotalCPU int `json:"TotalCPU"`
|
||||||
|
TotalMemory int64 `json:"TotalMemory"`
|
||||||
|
RunningContainerCount int `json:"RunningContainerCount"`
|
||||||
|
StoppedContainerCount int `json:"StoppedContainerCount"`
|
||||||
|
VolumeCount int `json:"VolumeCount"`
|
||||||
|
ImageCount int `json:"ImageCount"`
|
||||||
|
ServiceCount int `json:"ServiceCount"`
|
||||||
|
StackCount int `json:"StackCount"`
|
||||||
|
}
|
||||||
|
|
||||||
// EndpointGroupID represents an endpoint group identifier.
|
// EndpointGroupID represents an endpoint group identifier.
|
||||||
EndpointGroupID int
|
EndpointGroupID int
|
||||||
|
|
||||||
|
@ -277,6 +312,79 @@ type (
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TemplateID represents a template identifier.
|
||||||
|
TemplateID int
|
||||||
|
|
||||||
|
// TemplateType represents the type of a template.
|
||||||
|
TemplateType int
|
||||||
|
|
||||||
|
// Template represents an application template.
|
||||||
|
Template struct {
|
||||||
|
// Mandatory container/stack fields
|
||||||
|
ID TemplateID `json:"Id"`
|
||||||
|
Type TemplateType `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
AdministratorOnly bool `json:"administrator_only"`
|
||||||
|
|
||||||
|
// Mandatory container fields
|
||||||
|
Image string `json:"image"`
|
||||||
|
|
||||||
|
// Mandatory stack fields
|
||||||
|
Repository TemplateRepository `json:"repository"`
|
||||||
|
|
||||||
|
// Optional stack/container fields
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Logo string `json:"logo,omitempty"`
|
||||||
|
Env []TemplateEnv `json:"env,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
Categories []string `json:"categories,omitempty"`
|
||||||
|
|
||||||
|
// Optional container fields
|
||||||
|
Registry string `json:"registry,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Network string `json:"network,omitempty"`
|
||||||
|
Volumes []TemplateVolume `json:"volumes,omitempty"`
|
||||||
|
Ports []string `json:"ports,omitempty"`
|
||||||
|
Labels []Pair `json:"labels,omitempty"`
|
||||||
|
Privileged bool `json:"privileged,omitempty"`
|
||||||
|
Interactive bool `json:"interactive,omitempty"`
|
||||||
|
RestartPolicy string `json:"restart_policy,omitempty"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateEnv represents a template environment variable configuration.
|
||||||
|
TemplateEnv struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Default string `json:"default,omitempty"`
|
||||||
|
Preset bool `json:"preset,omitempty"`
|
||||||
|
Select []TemplateEnvSelect `json:"select,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateVolume represents a template volume configuration.
|
||||||
|
TemplateVolume struct {
|
||||||
|
Container string `json:"container"`
|
||||||
|
Bind string `json:"bind,omitempty"`
|
||||||
|
ReadOnly bool `json:"readonly,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateRepository represents the git repository configuration for a template.
|
||||||
|
TemplateRepository struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
StackFile string `json:"stackfile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateEnvSelect represents text/value pair that will be displayed as a choice for the
|
||||||
|
// template user.
|
||||||
|
TemplateEnvSelect struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Default bool `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
// ResourceAccessLevel represents the level of control associated to a resource.
|
// ResourceAccessLevel represents the level of control associated to a resource.
|
||||||
ResourceAccessLevel int
|
ResourceAccessLevel int
|
||||||
|
|
||||||
|
@ -345,6 +453,7 @@ type (
|
||||||
UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error
|
UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error
|
||||||
DeleteEndpoint(ID EndpointID) error
|
DeleteEndpoint(ID EndpointID) error
|
||||||
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
|
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
|
||||||
|
GetNextIdentifier() int
|
||||||
}
|
}
|
||||||
|
|
||||||
// EndpointGroupService represents a service for managing endpoint group data.
|
// EndpointGroupService represents a service for managing endpoint group data.
|
||||||
|
@ -411,6 +520,15 @@ type (
|
||||||
DeleteTag(ID TagID) error
|
DeleteTag(ID TagID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TemplateService represents a service for managing template data.
|
||||||
|
TemplateService interface {
|
||||||
|
Templates() ([]Template, error)
|
||||||
|
Template(ID TemplateID) (*Template, error)
|
||||||
|
CreateTemplate(template *Template) error
|
||||||
|
UpdateTemplate(ID TemplateID, template *Template) error
|
||||||
|
DeleteTemplate(ID TemplateID) error
|
||||||
|
}
|
||||||
|
|
||||||
// CryptoService represents a service for encrypting/hashing data.
|
// CryptoService represents a service for encrypting/hashing data.
|
||||||
CryptoService interface {
|
CryptoService interface {
|
||||||
Hash(data string) (string, error)
|
Hash(data string) (string, error)
|
||||||
|
@ -434,7 +552,7 @@ type (
|
||||||
|
|
||||||
// FileService represents a service for managing files.
|
// FileService represents a service for managing files.
|
||||||
FileService interface {
|
FileService interface {
|
||||||
GetFileContent(filePath string) (string, error)
|
GetFileContent(filePath string) ([]byte, error)
|
||||||
Rename(oldPath, newPath string) error
|
Rename(oldPath, newPath string) error
|
||||||
RemoveDirectory(directoryPath string) error
|
RemoveDirectory(directoryPath string) error
|
||||||
StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error)
|
StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error)
|
||||||
|
@ -452,19 +570,28 @@ type (
|
||||||
|
|
||||||
// GitService represents a service for managing Git.
|
// GitService represents a service for managing Git.
|
||||||
GitService interface {
|
GitService interface {
|
||||||
ClonePublicRepository(repositoryURL, destination string) error
|
ClonePublicRepository(repositoryURL, referenceName string, destination string) error
|
||||||
ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error
|
ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
|
// JobScheduler represents a service to run jobs on a periodic basis.
|
||||||
EndpointWatcher interface {
|
JobScheduler interface {
|
||||||
WatchEndpointFile(endpointFilePath string) error
|
ScheduleEndpointSyncJob(endpointFilePath, interval string) error
|
||||||
|
ScheduleSnapshotJob(interval string) error
|
||||||
|
UpdateSnapshotJob(interval string)
|
||||||
|
Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshotter represents a service used to create endpoint snapshots.
|
||||||
|
Snapshotter interface {
|
||||||
|
CreateSnapshot(endpoint *Endpoint) (*Snapshot, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LDAPService represents a service used to authenticate users against a LDAP/AD.
|
// LDAPService represents a service used to authenticate users against a LDAP/AD.
|
||||||
LDAPService interface {
|
LDAPService interface {
|
||||||
AuthenticateUser(username, password string, settings *LDAPSettings) error
|
AuthenticateUser(username, password string, settings *LDAPSettings) error
|
||||||
TestConnectivity(settings *LDAPSettings) error
|
TestConnectivity(settings *LDAPSettings) error
|
||||||
|
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwarmStackManager represents a service to manage Swarm stacks.
|
// SwarmStackManager represents a service to manage Swarm stacks.
|
||||||
|
@ -484,11 +611,9 @@ type (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// APIVersion is the version number of the Portainer API.
|
// APIVersion is the version number of the Portainer API.
|
||||||
APIVersion = "1.18.1"
|
APIVersion = "1.19.0"
|
||||||
// DBVersion is the version number of the Portainer database.
|
// DBVersion is the version number of the Portainer database.
|
||||||
DBVersion = 12
|
DBVersion = 13
|
||||||
// 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 represents the name of the header available in any agent response
|
||||||
PortainerAgentHeader = "Portainer-Agent"
|
PortainerAgentHeader = "Portainer-Agent"
|
||||||
// PortainerAgentTargetHeader represent the name of the header containing the target node name.
|
// PortainerAgentTargetHeader represent the name of the header containing the target node name.
|
||||||
|
@ -500,6 +625,8 @@ const (
|
||||||
// PortainerAgentSignatureMessage represents the message used to create a digital signature
|
// PortainerAgentSignatureMessage represents the message used to create a digital signature
|
||||||
// to be used when communicating with an agent
|
// to be used when communicating with an agent
|
||||||
PortainerAgentSignatureMessage = "Portainer-App"
|
PortainerAgentSignatureMessage = "Portainer-App"
|
||||||
|
// SupportedDockerAPIVersion is the minimum Docker API version supported by Portainer.
|
||||||
|
SupportedDockerAPIVersion = "1.24"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -582,3 +709,21 @@ const (
|
||||||
// DockerComposeStack represents a stack managed via docker-compose
|
// DockerComposeStack represents a stack managed via docker-compose
|
||||||
DockerComposeStack
|
DockerComposeStack
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ TemplateType = iota
|
||||||
|
// ContainerTemplate represents a container template
|
||||||
|
ContainerTemplate
|
||||||
|
// SwarmStackTemplate represents a template used to deploy a Swarm stack
|
||||||
|
SwarmStackTemplate
|
||||||
|
// ComposeStackTemplate represents a template used to deploy a Compose stack
|
||||||
|
ComposeStackTemplate
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ EndpointStatus = iota
|
||||||
|
// EndpointStatusUp is used to represent an available endpoint
|
||||||
|
EndpointStatusUp
|
||||||
|
// EndpointStatusDown is used to represent an unavailable endpoint
|
||||||
|
EndpointStatusDown
|
||||||
|
)
|
||||||
|
|
565
api/swagger.yaml
565
api/swagger.yaml
|
@ -54,7 +54,7 @@ info:
|
||||||
|
|
||||||
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
|
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
|
||||||
|
|
||||||
version: "1.18.1"
|
version: "1.19.0"
|
||||||
title: "Portainer API"
|
title: "Portainer API"
|
||||||
contact:
|
contact:
|
||||||
email: "info@portainer.io"
|
email: "info@portainer.io"
|
||||||
|
@ -247,7 +247,8 @@ paths:
|
||||||
- name: "URL"
|
- name: "URL"
|
||||||
in: "formData"
|
in: "formData"
|
||||||
type: "string"
|
type: "string"
|
||||||
description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Required if endpoint type is set to 1 or 2."
|
description: "URL or IP address of a Docker host (example: docker.mydomain.tld:2375).\
|
||||||
|
\ Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine)"
|
||||||
- name: "PublicURL"
|
- name: "PublicURL"
|
||||||
in: "formData"
|
in: "formData"
|
||||||
type: "string"
|
type: "string"
|
||||||
|
@ -319,7 +320,7 @@ paths:
|
||||||
summary: "Inspect an endpoint"
|
summary: "Inspect an endpoint"
|
||||||
description: |
|
description: |
|
||||||
Retrieve details abount an endpoint.
|
Retrieve details abount an endpoint.
|
||||||
**Access policy**: administrator
|
**Access policy**: restricted
|
||||||
operationId: "EndpointInspect"
|
operationId: "EndpointInspect"
|
||||||
produces:
|
produces:
|
||||||
- "application/json"
|
- "application/json"
|
||||||
|
@ -2530,32 +2531,189 @@ paths:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- "templates"
|
- "templates"
|
||||||
summary: "Retrieve App templates"
|
summary: "List available templates"
|
||||||
description: |
|
description: |
|
||||||
Retrieve App templates.
|
List available templates.
|
||||||
You can find more information about the format at http://portainer.readthedocs.io/en/stable/templates.html
|
Administrator templates will not be listed for non-administrator users.
|
||||||
**Access policy**: authenticated
|
**Access policy**: restricted
|
||||||
operationId: "TemplateList"
|
operationId: "TemplateList"
|
||||||
produces:
|
produces:
|
||||||
- "application/json"
|
- "application/json"
|
||||||
parameters:
|
parameters:
|
||||||
- name: "key"
|
|
||||||
in: "query"
|
|
||||||
required: true
|
|
||||||
description: "Templates key. Valid values are 'container' or 'linuxserver.io'."
|
|
||||||
type: "string"
|
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Success"
|
description: "Success"
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/definitions/TemplateListResponse"
|
$ref: "#/definitions/TemplateListResponse"
|
||||||
|
500:
|
||||||
|
description: "Server error"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- "templates"
|
||||||
|
summary: "Create a new template"
|
||||||
|
description: |
|
||||||
|
Create a new template.
|
||||||
|
**Access policy**: administrator
|
||||||
|
operationId: "TemplateCreate"
|
||||||
|
consumes:
|
||||||
|
- "application/json"
|
||||||
|
produces:
|
||||||
|
- "application/json"
|
||||||
|
parameters:
|
||||||
|
- in: "body"
|
||||||
|
name: "body"
|
||||||
|
description: "Template details"
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/TemplateCreateRequest"
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Success"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/Template"
|
||||||
400:
|
400:
|
||||||
description: "Invalid request"
|
description: "Invalid request"
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/definitions/GenericError"
|
$ref: "#/definitions/GenericError"
|
||||||
examples:
|
examples:
|
||||||
application/json:
|
application/json:
|
||||||
err: "Invalid query format"
|
err: "Invalid request data format"
|
||||||
|
403:
|
||||||
|
description: "Unauthorized"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Access denied to resource"
|
||||||
|
500:
|
||||||
|
description: "Server error"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
/templates/{id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- "templates"
|
||||||
|
summary: "Inspect a template"
|
||||||
|
description: |
|
||||||
|
Retrieve details about a template.
|
||||||
|
**Access policy**: administrator
|
||||||
|
operationId: "TemplateInspect"
|
||||||
|
produces:
|
||||||
|
- "application/json"
|
||||||
|
parameters:
|
||||||
|
- name: "id"
|
||||||
|
in: "path"
|
||||||
|
description: "Template identifier"
|
||||||
|
required: true
|
||||||
|
type: "integer"
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Success"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/Template"
|
||||||
|
400:
|
||||||
|
description: "Invalid request"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Invalid request"
|
||||||
|
403:
|
||||||
|
description: "Unauthorized"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Access denied to resource"
|
||||||
|
404:
|
||||||
|
description: "Template not found"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Template not found"
|
||||||
|
500:
|
||||||
|
description: "Server error"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- "templates"
|
||||||
|
summary: "Update a template"
|
||||||
|
description: |
|
||||||
|
Update a template.
|
||||||
|
**Access policy**: administrator
|
||||||
|
operationId: "TemplateUpdate"
|
||||||
|
consumes:
|
||||||
|
- "application/json"
|
||||||
|
produces:
|
||||||
|
- "application/json"
|
||||||
|
parameters:
|
||||||
|
- name: "id"
|
||||||
|
in: "path"
|
||||||
|
description: "Template identifier"
|
||||||
|
required: true
|
||||||
|
type: "integer"
|
||||||
|
- in: "body"
|
||||||
|
name: "body"
|
||||||
|
description: "Template details"
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/TemplateUpdateRequest"
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Success"
|
||||||
|
400:
|
||||||
|
description: "Invalid request"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Invalid request data format"
|
||||||
|
403:
|
||||||
|
description: "Unauthorized"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Access denied to resource"
|
||||||
|
404:
|
||||||
|
description: "Template not found"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Template not found"
|
||||||
|
500:
|
||||||
|
description: "Server error"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- "templates"
|
||||||
|
summary: "Remove a template"
|
||||||
|
description: |
|
||||||
|
Remove a template.
|
||||||
|
**Access policy**: administrator
|
||||||
|
operationId: "TemplateDelete"
|
||||||
|
parameters:
|
||||||
|
- name: "id"
|
||||||
|
in: "path"
|
||||||
|
description: "Template identifier"
|
||||||
|
required: true
|
||||||
|
type: "integer"
|
||||||
|
responses:
|
||||||
|
204:
|
||||||
|
description: "Success"
|
||||||
|
400:
|
||||||
|
description: "Invalid request"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Invalid request"
|
||||||
500:
|
500:
|
||||||
description: "Server error"
|
description: "Server error"
|
||||||
schema:
|
schema:
|
||||||
|
@ -2658,7 +2816,7 @@ definitions:
|
||||||
description: "Is analytics enabled"
|
description: "Is analytics enabled"
|
||||||
Version:
|
Version:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "1.18.1"
|
example: "1.19.0"
|
||||||
description: "Portainer API version"
|
description: "Portainer API version"
|
||||||
PublicSettingsInspectResponse:
|
PublicSettingsInspectResponse:
|
||||||
type: "object"
|
type: "object"
|
||||||
|
@ -2739,6 +2897,21 @@ definitions:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "uid"
|
example: "uid"
|
||||||
description: "LDAP attribute which denotes the username"
|
description: "LDAP attribute which denotes the username"
|
||||||
|
LDAPGroupSearchSettings:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
GroupBaseDN:
|
||||||
|
type: "string"
|
||||||
|
example: "dc=ldap,dc=domain,dc=tld"
|
||||||
|
description: "The distinguished name of the element from which the LDAP server will search for groups."
|
||||||
|
GroupFilter:
|
||||||
|
type: "string"
|
||||||
|
example: "(objectClass=account)"
|
||||||
|
description: "The LDAP search filter used to select group elements, optional."
|
||||||
|
GroupAttribute:
|
||||||
|
type: "string"
|
||||||
|
example: "member"
|
||||||
|
description: "LDAP attribute which denotes the group membership."
|
||||||
|
|
||||||
LDAPSettings:
|
LDAPSettings:
|
||||||
type: "object"
|
type: "object"
|
||||||
|
@ -2765,6 +2938,14 @@ definitions:
|
||||||
type: "array"
|
type: "array"
|
||||||
items:
|
items:
|
||||||
$ref: "#/definitions/LDAPSearchSettings"
|
$ref: "#/definitions/LDAPSearchSettings"
|
||||||
|
GroupSearchSettings:
|
||||||
|
type: "array"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/LDAPGroupSearchSettings"
|
||||||
|
AutoCreateUsers:
|
||||||
|
type: "boolean"
|
||||||
|
example: "true"
|
||||||
|
description: "Automatically provision users and assign them to matching LDAP group names"
|
||||||
|
|
||||||
Settings:
|
Settings:
|
||||||
type: "object"
|
type: "object"
|
||||||
|
@ -3602,9 +3783,17 @@ definitions:
|
||||||
type: "array"
|
type: "array"
|
||||||
items:
|
items:
|
||||||
$ref: "#/definitions/Template"
|
$ref: "#/definitions/Template"
|
||||||
Template:
|
TemplateCreateRequest:
|
||||||
type: "object"
|
type: "object"
|
||||||
|
required:
|
||||||
|
- "type"
|
||||||
|
- "title"
|
||||||
|
- "description"
|
||||||
properties:
|
properties:
|
||||||
|
type:
|
||||||
|
type: "integer"
|
||||||
|
example: 1
|
||||||
|
description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)"
|
||||||
title:
|
title:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "Nginx"
|
example: "Nginx"
|
||||||
|
@ -3613,14 +3802,354 @@ definitions:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "High performance web server"
|
example: "High performance web server"
|
||||||
description: "Description of the template"
|
description: "Description of the template"
|
||||||
|
administrator_only:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the template should be available to administrators only"
|
||||||
|
image:
|
||||||
|
type: "string"
|
||||||
|
example: "nginx:latest"
|
||||||
|
description: "Image associated to a container template. Mandatory for a container template"
|
||||||
|
repository:
|
||||||
|
$ref: "#/definitions/TemplateRepository"
|
||||||
|
name:
|
||||||
|
type: "string"
|
||||||
|
example: "mystackname"
|
||||||
|
description: "Default name for the stack/container to be used on deployment"
|
||||||
logo:
|
logo:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "https://cloudinovasi.id/assets/img/logos/nginx.png"
|
example: "https://cloudinovasi.id/assets/img/logos/nginx.png"
|
||||||
description: "URL of the template's logo"
|
description: "URL of the template's logo"
|
||||||
|
env:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of environment variables used during the template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateEnv"
|
||||||
|
note:
|
||||||
|
type: "string"
|
||||||
|
example: "This is my <b>custom</b> template"
|
||||||
|
description: "A note that will be displayed in the UI. Supports HTML content"
|
||||||
|
platform:
|
||||||
|
type: "string"
|
||||||
|
example: "linux"
|
||||||
|
description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform"
|
||||||
|
categories:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of categories associated to the template"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
exampe: "database"
|
||||||
|
registry:
|
||||||
|
type: "string"
|
||||||
|
example: "quay.io"
|
||||||
|
description: "The URL of a registry associated to the image for a container template"
|
||||||
|
command:
|
||||||
|
type: "string"
|
||||||
|
example: "ls -lah"
|
||||||
|
description: "The command that will be executed in a container template"
|
||||||
|
network:
|
||||||
|
type: "string"
|
||||||
|
example: "mynet"
|
||||||
|
description: "Name of a network that will be used on container deployment if it exists inside the environment"
|
||||||
|
volumes:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of volumes used during the container template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateVolume"
|
||||||
|
ports:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of ports exposed by the container"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
example: "8080:80/tcp"
|
||||||
|
labels:
|
||||||
|
type: "array"
|
||||||
|
description: "Container labels"
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/Pair'
|
||||||
|
privileged:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in privileged mode"
|
||||||
|
interactive:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)"
|
||||||
|
restart_policy:
|
||||||
|
type: "string"
|
||||||
|
example: "on-failure"
|
||||||
|
description: "Container restart policy"
|
||||||
|
hostname:
|
||||||
|
type: "string"
|
||||||
|
example: "mycontainer"
|
||||||
|
description: "Container hostname"
|
||||||
|
TemplateUpdateRequest:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: "integer"
|
||||||
|
example: 1
|
||||||
|
description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)"
|
||||||
|
title:
|
||||||
|
type: "string"
|
||||||
|
example: "Nginx"
|
||||||
|
description: "Title of the template"
|
||||||
|
description:
|
||||||
|
type: "string"
|
||||||
|
example: "High performance web server"
|
||||||
|
description: "Description of the template"
|
||||||
|
administrator_only:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the template should be available to administrators only"
|
||||||
image:
|
image:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "nginx:latest"
|
example: "nginx:latest"
|
||||||
description: "The Docker image associated to the template"
|
description: "Image associated to a container template. Mandatory for a container template"
|
||||||
|
repository:
|
||||||
|
$ref: "#/definitions/TemplateRepository"
|
||||||
|
name:
|
||||||
|
type: "string"
|
||||||
|
example: "mystackname"
|
||||||
|
description: "Default name for the stack/container to be used on deployment"
|
||||||
|
logo:
|
||||||
|
type: "string"
|
||||||
|
example: "https://cloudinovasi.id/assets/img/logos/nginx.png"
|
||||||
|
description: "URL of the template's logo"
|
||||||
|
env:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of environment variables used during the template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateEnv"
|
||||||
|
note:
|
||||||
|
type: "string"
|
||||||
|
example: "This is my <b>custom</b> template"
|
||||||
|
description: "A note that will be displayed in the UI. Supports HTML content"
|
||||||
|
platform:
|
||||||
|
type: "string"
|
||||||
|
example: "linux"
|
||||||
|
description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform"
|
||||||
|
categories:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of categories associated to the template"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
exampe: "database"
|
||||||
|
registry:
|
||||||
|
type: "string"
|
||||||
|
example: "quay.io"
|
||||||
|
description: "The URL of a registry associated to the image for a container template"
|
||||||
|
command:
|
||||||
|
type: "string"
|
||||||
|
example: "ls -lah"
|
||||||
|
description: "The command that will be executed in a container template"
|
||||||
|
network:
|
||||||
|
type: "string"
|
||||||
|
example: "mynet"
|
||||||
|
description: "Name of a network that will be used on container deployment if it exists inside the environment"
|
||||||
|
volumes:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of volumes used during the container template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateVolume"
|
||||||
|
ports:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of ports exposed by the container"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
example: "8080:80/tcp"
|
||||||
|
labels:
|
||||||
|
type: "array"
|
||||||
|
description: "Container labels"
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/Pair'
|
||||||
|
privileged:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in privileged mode"
|
||||||
|
interactive:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)"
|
||||||
|
restart_policy:
|
||||||
|
type: "string"
|
||||||
|
example: "on-failure"
|
||||||
|
description: "Container restart policy"
|
||||||
|
hostname:
|
||||||
|
type: "string"
|
||||||
|
example: "mycontainer"
|
||||||
|
description: "Container hostname"
|
||||||
|
Template:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: "integer"
|
||||||
|
example: 1
|
||||||
|
description: "Template identifier"
|
||||||
|
type:
|
||||||
|
type: "integer"
|
||||||
|
example: 1
|
||||||
|
description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)"
|
||||||
|
title:
|
||||||
|
type: "string"
|
||||||
|
example: "Nginx"
|
||||||
|
description: "Title of the template"
|
||||||
|
description:
|
||||||
|
type: "string"
|
||||||
|
example: "High performance web server"
|
||||||
|
description: "Description of the template"
|
||||||
|
administrator_only:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the template should be available to administrators only"
|
||||||
|
image:
|
||||||
|
type: "string"
|
||||||
|
example: "nginx:latest"
|
||||||
|
description: "Image associated to a container template. Mandatory for a container template"
|
||||||
|
repository:
|
||||||
|
$ref: "#/definitions/TemplateRepository"
|
||||||
|
name:
|
||||||
|
type: "string"
|
||||||
|
example: "mystackname"
|
||||||
|
description: "Default name for the stack/container to be used on deployment"
|
||||||
|
logo:
|
||||||
|
type: "string"
|
||||||
|
example: "https://cloudinovasi.id/assets/img/logos/nginx.png"
|
||||||
|
description: "URL of the template's logo"
|
||||||
|
env:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of environment variables used during the template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateEnv"
|
||||||
|
note:
|
||||||
|
type: "string"
|
||||||
|
example: "This is my <b>custom</b> template"
|
||||||
|
description: "A note that will be displayed in the UI. Supports HTML content"
|
||||||
|
platform:
|
||||||
|
type: "string"
|
||||||
|
example: "linux"
|
||||||
|
description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform"
|
||||||
|
categories:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of categories associated to the template"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
exampe: "database"
|
||||||
|
registry:
|
||||||
|
type: "string"
|
||||||
|
example: "quay.io"
|
||||||
|
description: "The URL of a registry associated to the image for a container template"
|
||||||
|
command:
|
||||||
|
type: "string"
|
||||||
|
example: "ls -lah"
|
||||||
|
description: "The command that will be executed in a container template"
|
||||||
|
network:
|
||||||
|
type: "string"
|
||||||
|
example: "mynet"
|
||||||
|
description: "Name of a network that will be used on container deployment if it exists inside the environment"
|
||||||
|
volumes:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of volumes used during the container template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateVolume"
|
||||||
|
ports:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of ports exposed by the container"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
example: "8080:80/tcp"
|
||||||
|
labels:
|
||||||
|
type: "array"
|
||||||
|
description: "Container labels"
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/Pair'
|
||||||
|
privileged:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in privileged mode"
|
||||||
|
interactive:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)"
|
||||||
|
restart_policy:
|
||||||
|
type: "string"
|
||||||
|
example: "on-failure"
|
||||||
|
description: "Container restart policy"
|
||||||
|
hostname:
|
||||||
|
type: "string"
|
||||||
|
example: "mycontainer"
|
||||||
|
description: "Container hostname"
|
||||||
|
TemplateVolume:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
container:
|
||||||
|
type: "string"
|
||||||
|
example: "/data"
|
||||||
|
description: "Path inside the container"
|
||||||
|
bind:
|
||||||
|
type: "string"
|
||||||
|
example: "/tmp"
|
||||||
|
description: "Path on the host"
|
||||||
|
readonly:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the volume used should be readonly"
|
||||||
|
TemplateEnv:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: "string"
|
||||||
|
example: "MYSQL_ROOT_PASSWORD"
|
||||||
|
description: "name of the environment variable"
|
||||||
|
label:
|
||||||
|
type: "string"
|
||||||
|
example: "Root password"
|
||||||
|
description: "Text for the label that will be generated in the UI"
|
||||||
|
description:
|
||||||
|
type: "string"
|
||||||
|
example: "MySQL root account password"
|
||||||
|
description: "Content of the tooltip that will be generated in the UI"
|
||||||
|
default:
|
||||||
|
type: "string"
|
||||||
|
example: "default_value"
|
||||||
|
description: "Default value that will be set for the variable"
|
||||||
|
preset:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "If set to true, will not generate any input for this variable in the UI"
|
||||||
|
select:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of name/value that will be used to generate a dropdown in the UI"
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/TemplateEnvSelect'
|
||||||
|
TemplateEnvSelect:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
text:
|
||||||
|
type: "string"
|
||||||
|
example: "text value"
|
||||||
|
description: "Some text that will displayed as a choice"
|
||||||
|
value:
|
||||||
|
type: "string"
|
||||||
|
example: "value"
|
||||||
|
description: "A value that will be associated to the choice"
|
||||||
|
default:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Will set this choice as the default choice"
|
||||||
|
TemplateRepository:
|
||||||
|
type: "object"
|
||||||
|
required:
|
||||||
|
- "URL"
|
||||||
|
properties:
|
||||||
|
URL:
|
||||||
|
type: "string"
|
||||||
|
example: "https://github.com/portainer/portainer-compose"
|
||||||
|
description: "URL of a git repository used to deploy a stack template. Mandatory for a Swarm/Compose stack template"
|
||||||
|
stackfile:
|
||||||
|
type: "string"
|
||||||
|
example: "./subfolder/docker-compose.yml"
|
||||||
|
description: "Path to the stack file inside the git repository"
|
||||||
StackMigrateRequest:
|
StackMigrateRequest:
|
||||||
type: "object"
|
type: "object"
|
||||||
required:
|
required:
|
||||||
|
@ -3655,6 +4184,10 @@ definitions:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "https://github.com/openfaas/faas"
|
example: "https://github.com/openfaas/faas"
|
||||||
description: "URL of a Git repository hosting the Stack file. Required when using the 'repository' deployment method."
|
description: "URL of a Git repository hosting the Stack file. Required when using the 'repository' deployment method."
|
||||||
|
RepositoryReferenceName:
|
||||||
|
type: "string"
|
||||||
|
example: "refs/heads/master"
|
||||||
|
description: "Reference name of a Git repository hosting the Stack file. Used in 'repository' deployment method."
|
||||||
ComposeFilePathInRepository:
|
ComposeFilePathInRepository:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "docker-compose.yml"
|
example: "docker-compose.yml"
|
||||||
|
|
|
@ -15,6 +15,7 @@ angular.module('portainer', [
|
||||||
'angular-json-tree',
|
'angular-json-tree',
|
||||||
'angular-loading-bar',
|
'angular-loading-bar',
|
||||||
'angular-clipboard',
|
'angular-clipboard',
|
||||||
|
'ngFileSaver',
|
||||||
'luegg.directives',
|
'luegg.directives',
|
||||||
'portainer.templates',
|
'portainer.templates',
|
||||||
'portainer.app',
|
'portainer.app',
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
angular.module('portainer.agent').component('volumeBrowserDatatable', {
|
||||||
|
templateUrl: 'app/agent/components/volume-browser/volume-browser-datatable/volumeBrowserDatatable.html',
|
||||||
|
controller: 'GenericDatatableController',
|
||||||
|
bindings: {
|
||||||
|
titleText: '@',
|
||||||
|
titleIcon: '@',
|
||||||
|
dataset: '<',
|
||||||
|
tableKey: '@',
|
||||||
|
orderBy: '@',
|
||||||
|
reverseOrder: '<'
|
||||||
|
},
|
||||||
|
require: {
|
||||||
|
volumeBrowser: '^^volumeBrowser'
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,90 @@
|
||||||
|
<div class="datatable">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-body classes="no-padding">
|
||||||
|
<div class="toolBar">
|
||||||
|
<div class="toolBarTitle">
|
||||||
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="searchBar">
|
||||||
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<a ng-click="$ctrl.changeOrderBy('Name')">
|
||||||
|
Name
|
||||||
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
|
||||||
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a ng-click="$ctrl.changeOrderBy('Size')">
|
||||||
|
Size
|
||||||
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && !$ctrl.state.reverseOrder"></i>
|
||||||
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && $ctrl.state.reverseOrder"></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<a ng-click="$ctrl.changeOrderBy('ModTime')">
|
||||||
|
Last modification
|
||||||
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ModTime' && !$ctrl.state.reverseOrder"></i>
|
||||||
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ModTime' && $ctrl.state.reverseOrder"></i>
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr ng-if="$ctrl.volumeBrowser.state.path !== '/'">
|
||||||
|
<td colspan="4">
|
||||||
|
<a ng-click="$ctrl.volumeBrowser.up()"><i class="fa fa-level-up-alt space-right"></i>Go to parent</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr ng-repeat="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder))">
|
||||||
|
<td>
|
||||||
|
<span ng-if="item.edit">
|
||||||
|
<input class="input-sm" type="text" ng-model="item.newName" on-enter-key="$ctrl.volumeBrowser.rename(item.Name, item.newName); item.edit = false;" auto-focus />
|
||||||
|
<a class="interactive" ng-click="item.edit = false;"><i class="fa fa-times"></i></a>
|
||||||
|
<a class="interactive" ng-click="$ctrl.volumeBrowser.rename(item.Name, item.newName); item.edit = false;"><i class="fa fa-check-square"></i></a>
|
||||||
|
</span>
|
||||||
|
<span ng-if="!item.edit && item.Dir">
|
||||||
|
<a ng-click="$ctrl.volumeBrowser.browse(item.Name)"><i class="fa fa-folder space-right" aria-hidden="true"></i>{{ item.Name }}</a>
|
||||||
|
</span>
|
||||||
|
<span ng-if="!item.edit && !item.Dir">
|
||||||
|
<i class="fa fa-file space-right" aria-hidden="true"></i>{{ item.Name }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.Size | humansize }}</td>
|
||||||
|
<td>
|
||||||
|
{{ item.ModTime | getisodatefromtimestamp }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<btn class="btn btn-xs btn-primary space-right" ng-click="$ctrl.volumeBrowser.download(item.Name)" ng-if="!item.Dir">
|
||||||
|
<i class="fa fa-download" aria-hidden="true"></i> Download
|
||||||
|
</btn>
|
||||||
|
<btn class="btn btn-xs btn-primary space-right" ng-click="item.newName = item.Name; item.edit = true">
|
||||||
|
<i class="fa fa-edit" aria-hidden="true"></i> Rename
|
||||||
|
</btn>
|
||||||
|
<btn class="btn btn-xs btn-danger" ng-click="$ctrl.volumeBrowser.delete(item.Name)">
|
||||||
|
<i class="fa fa-trash" aria-hidden="true"></i> Delete
|
||||||
|
</btn>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr ng-if="!$ctrl.dataset">
|
||||||
|
<td colspan="5" class="text-center text-muted">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
||||||
|
<td colspan="5" class="text-center text-muted">No files found.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
8
app/agent/components/volume-browser/volume-browser.js
Normal file
8
app/agent/components/volume-browser/volume-browser.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
angular.module('portainer.agent').component('volumeBrowser', {
|
||||||
|
templateUrl: 'app/agent/components/volume-browser/volumeBrowser.html',
|
||||||
|
controller: 'VolumeBrowserController',
|
||||||
|
bindings: {
|
||||||
|
volumeId: '<',
|
||||||
|
nodeName: '<'
|
||||||
|
}
|
||||||
|
});
|
5
app/agent/components/volume-browser/volumeBrowser.html
Normal file
5
app/agent/components/volume-browser/volumeBrowser.html
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<volume-browser-datatable
|
||||||
|
title-text="Volume browser" title-icon="fa-file"
|
||||||
|
dataset="$ctrl.files" table-key="volume_browser"
|
||||||
|
order-by="Dir"
|
||||||
|
></volume-browser-datatable>
|
115
app/agent/components/volume-browser/volumeBrowserController.js
Normal file
115
app/agent/components/volume-browser/volumeBrowserController.js
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
angular.module('portainer.agent')
|
||||||
|
.controller('VolumeBrowserController', ['HttpRequestHelper', 'VolumeBrowserService', 'FileSaver', 'Blob', 'ModalService', 'Notifications',
|
||||||
|
function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService, Notifications) {
|
||||||
|
var ctrl = this;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
path: '/'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.rename = function(file, newName) {
|
||||||
|
var filePath = this.state.path === '/' ? file : this.state.path + '/' + file;
|
||||||
|
var newFilePath = this.state.path === '/' ? newName : this.state.path + '/' + newName;
|
||||||
|
|
||||||
|
VolumeBrowserService.rename(this.volumeId, filePath, newFilePath)
|
||||||
|
.then(function success() {
|
||||||
|
Notifications.success('File successfully renamed', newFilePath);
|
||||||
|
return VolumeBrowserService.ls(ctrl.volumeId, ctrl.state.path);
|
||||||
|
})
|
||||||
|
.then(function success(data) {
|
||||||
|
ctrl.files = data;
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to rename file');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.delete = function(file) {
|
||||||
|
var filePath = this.state.path === '/' ? file : this.state.path + '/' + file;
|
||||||
|
|
||||||
|
ModalService.confirmDeletion(
|
||||||
|
'Are you sure that you want to delete ' + filePath + ' ?',
|
||||||
|
function onConfirm(confirmed) {
|
||||||
|
if(!confirmed) { return; }
|
||||||
|
deleteFile(filePath);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.download = function(file) {
|
||||||
|
var filePath = this.state.path === '/' ? file : this.state.path + '/' + file;
|
||||||
|
VolumeBrowserService.get(this.volumeId, filePath)
|
||||||
|
.then(function success(data) {
|
||||||
|
var downloadData = new Blob([data.file], { type: 'text/plain;charset=utf-8' });
|
||||||
|
FileSaver.saveAs(downloadData, file);
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to download file');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.up = function() {
|
||||||
|
var parentFolder = parentPath(this.state.path);
|
||||||
|
browse(parentFolder);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.browse = function(folder) {
|
||||||
|
var path = buildPath(this.state.path, folder);
|
||||||
|
browse(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
function deleteFile(file) {
|
||||||
|
VolumeBrowserService.delete(ctrl.volumeId, file)
|
||||||
|
.then(function success() {
|
||||||
|
Notifications.success('File successfully deleted', file);
|
||||||
|
return VolumeBrowserService.ls(ctrl.volumeId, ctrl.state.path);
|
||||||
|
})
|
||||||
|
.then(function success(data) {
|
||||||
|
ctrl.files = data;
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to delete file');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function browse(path) {
|
||||||
|
VolumeBrowserService.ls(ctrl.volumeId, path)
|
||||||
|
.then(function success(data) {
|
||||||
|
ctrl.state.path = path;
|
||||||
|
ctrl.files = data;
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to browse volume');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPath(path) {
|
||||||
|
if (path.lastIndexOf('/') === 0) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
var split = _.split(path, '/');
|
||||||
|
return _.join(_.slice(split, 0, split.length - 1), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPath(parent, file) {
|
||||||
|
if (parent === '/') {
|
||||||
|
return parent + file;
|
||||||
|
}
|
||||||
|
return parent + '/' + file;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
this.$onInit = function() {
|
||||||
|
HttpRequestHelper.setPortainerAgentTargetHeader(this.nodeName);
|
||||||
|
VolumeBrowserService.ls(this.volumeId, this.state.path)
|
||||||
|
.then(function success(data) {
|
||||||
|
ctrl.files = data;
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to browse volume');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
}]);
|
22
app/agent/rest/browse.js
Normal file
22
app/agent/rest/browse.js
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
angular.module('portainer.agent')
|
||||||
|
.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
||||||
|
'use strict';
|
||||||
|
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:id/:action', {
|
||||||
|
endpointId: EndpointProvider.endpointID
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ls: {
|
||||||
|
method: 'GET', isArray: true, params: { id: '@id', action: 'ls' }
|
||||||
|
},
|
||||||
|
get: {
|
||||||
|
method: 'GET', params: { id: '@id', action: 'get' },
|
||||||
|
transformResponse: browseGetResponse
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
method: 'DELETE', params: { id: '@id', action: 'delete' }
|
||||||
|
},
|
||||||
|
rename: {
|
||||||
|
method: 'PUT', params: { id: '@id', action: 'rename' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}]);
|
9
app/agent/rest/response/browse.js
Normal file
9
app/agent/rest/response/browse.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
// The get action of the Browse service returns a file.
|
||||||
|
// ngResource will transform it as an array of chars.
|
||||||
|
// This functions simply creates a response object and assign
|
||||||
|
// the data to a field.
|
||||||
|
function browseGetResponse(data) {
|
||||||
|
var response = {};
|
||||||
|
response.file = data;
|
||||||
|
return response;
|
||||||
|
}
|
27
app/agent/services/volumeBrowserService.js
Normal file
27
app/agent/services/volumeBrowserService.js
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
angular.module('portainer.agent')
|
||||||
|
.factory('VolumeBrowserService', ['$q', 'Browse', function VolumeBrowserServiceFactory($q, Browse) {
|
||||||
|
'use strict';
|
||||||
|
var service = {};
|
||||||
|
|
||||||
|
service.ls = function(volumeId, path) {
|
||||||
|
return Browse.ls({ 'id': volumeId, 'path': path }).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.get = function(volumeId, path) {
|
||||||
|
return Browse.get({ 'id': volumeId, 'path': path }).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.delete = function(volumeId, path) {
|
||||||
|
return Browse.delete({ 'id': volumeId, 'path': path }).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.rename = function(volumeId, path, newPath) {
|
||||||
|
var payload = {
|
||||||
|
CurrentFilePath: path,
|
||||||
|
NewFilePath: newPath
|
||||||
|
};
|
||||||
|
return Browse.rename({ 'id': volumeId }, payload).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
return service;
|
||||||
|
}]);
|
|
@ -5,11 +5,6 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actionBar">
|
<div class="actionBar">
|
||||||
<button type="button" class="btn btn-sm btn-danger"
|
<button type="button" class="btn btn-sm btn-danger"
|
||||||
|
@ -20,12 +15,12 @@
|
||||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add container
|
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add container
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
<div class="searchBar">
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover table-filters">
|
<table class="table table-hover table-filters nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.azure').component('containergroupsDatatable', {
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
|
||||||
removeAction: '<'
|
removeAction: '<'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<containergroups-datatable
|
<containergroups-datatable
|
||||||
title-text="Containers" title-icon="fa-server"
|
title-text="Containers" title-icon="fa-server"
|
||||||
dataset="containerGroups" table-key="containergroups"
|
dataset="containerGroups" table-key="containergroups"
|
||||||
order-by="Name" show-text-filter="true"
|
order-by="Name"
|
||||||
remove-action="deleteAction"
|
remove-action="deleteAction"
|
||||||
></containergroups-datatable>
|
></containergroups-datatable>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -184,6 +184,17 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var imageImport = {
|
||||||
|
name: 'docker.images.import',
|
||||||
|
url: '/import',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
templateUrl: 'app/docker/views/images/import/importimage.html',
|
||||||
|
controller: 'ImportImageController'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var networks = {
|
var networks = {
|
||||||
name: 'docker.networks',
|
name: 'docker.networks',
|
||||||
url: '/networks',
|
url: '/networks',
|
||||||
|
@ -361,36 +372,6 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var templates = {
|
|
||||||
name: 'docker.templates',
|
|
||||||
url: '/templates',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: 'app/docker/views/templates/templates.html',
|
|
||||||
controller: 'TemplatesController'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
key: 'containers',
|
|
||||||
hide_descriptions: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var templatesLinuxServer = {
|
|
||||||
name: 'docker.templates.linuxserver',
|
|
||||||
url: '/linuxserver',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: 'app/docker/views/templates/templates.html',
|
|
||||||
controller: 'TemplatesController'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
key: 'linuxserver.io',
|
|
||||||
hide_descriptions: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var volumes = {
|
var volumes = {
|
||||||
name: 'docker.volumes',
|
name: 'docker.volumes',
|
||||||
url: '/volumes',
|
url: '/volumes',
|
||||||
|
@ -413,6 +394,17 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var volumeBrowse = {
|
||||||
|
name: 'docker.volumes.volume.browse',
|
||||||
|
url: '/browse',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
templateUrl: 'app/docker/views/volumes/browse/browsevolume.html',
|
||||||
|
controller: 'BrowseVolumeController'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
var volumeCreation = {
|
var volumeCreation = {
|
||||||
name: 'docker.volumes.new',
|
name: 'docker.volumes.new',
|
||||||
url: '/new',
|
url: '/new',
|
||||||
|
@ -441,6 +433,7 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
$stateRegistryProvider.register(images);
|
$stateRegistryProvider.register(images);
|
||||||
$stateRegistryProvider.register(image);
|
$stateRegistryProvider.register(image);
|
||||||
$stateRegistryProvider.register(imageBuild);
|
$stateRegistryProvider.register(imageBuild);
|
||||||
|
$stateRegistryProvider.register(imageImport);
|
||||||
$stateRegistryProvider.register(networks);
|
$stateRegistryProvider.register(networks);
|
||||||
$stateRegistryProvider.register(network);
|
$stateRegistryProvider.register(network);
|
||||||
$stateRegistryProvider.register(networkCreation);
|
$stateRegistryProvider.register(networkCreation);
|
||||||
|
@ -458,9 +451,8 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
$stateRegistryProvider.register(tasks);
|
$stateRegistryProvider.register(tasks);
|
||||||
$stateRegistryProvider.register(task);
|
$stateRegistryProvider.register(task);
|
||||||
$stateRegistryProvider.register(taskLogs);
|
$stateRegistryProvider.register(taskLogs);
|
||||||
$stateRegistryProvider.register(templates);
|
|
||||||
$stateRegistryProvider.register(templatesLinuxServer);
|
|
||||||
$stateRegistryProvider.register(volumes);
|
$stateRegistryProvider.register(volumes);
|
||||||
$stateRegistryProvider.register(volume);
|
$stateRegistryProvider.register(volume);
|
||||||
|
$stateRegistryProvider.register(volumeBrowse);
|
||||||
$stateRegistryProvider.register(volumeCreation);
|
$stateRegistryProvider.register(volumeCreation);
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -5,11 +5,6 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actionBar">
|
<div class="actionBar">
|
||||||
<button type="button" class="btn btn-sm btn-danger"
|
<button type="button" class="btn btn-sm btn-danger"
|
||||||
|
@ -20,12 +15,12 @@
|
||||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add config
|
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add config
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
<div class="searchBar">
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('configsDatatable', {
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
|
||||||
showOwnershipColumn: '<',
|
showOwnershipColumn: '<',
|
||||||
removeAction: '<'
|
removeAction: '<'
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Network</th>
|
<th>Network</th>
|
||||||
|
|
|
@ -5,18 +5,13 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="searchBar">
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th ng-repeat="header in $ctrl.headerset">
|
<th ng-repeat="header in $ctrl.headerset">
|
||||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('containerProcessesDatatable', {
|
||||||
headerset: '<',
|
headerset: '<',
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<'
|
||||||
showTextFilter: '<'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -86,6 +86,7 @@ function ($state, ContainerService, ModalService, Notifications, HttpRequestHelp
|
||||||
function removeSelectedContainers(containers, cleanVolumes) {
|
function removeSelectedContainers(containers, cleanVolumes) {
|
||||||
var actionCount = containers.length;
|
var actionCount = containers.length;
|
||||||
angular.forEach(containers, function (container) {
|
angular.forEach(containers, function (container) {
|
||||||
|
HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
|
||||||
ContainerService.remove(container, cleanVolumes)
|
ContainerService.remove(container, cleanVolumes)
|
||||||
.then(function success() {
|
.then(function success() {
|
||||||
Notifications.success('Container successfully removed', container.Names[0]);
|
Notifications.success('Container successfully removed', container.Names[0]);
|
||||||
|
|
|
@ -6,8 +6,56 @@
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
<div class="settings">
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
<span class="setting" ng-class="{ 'setting-active': $ctrl.columnVisibility.state.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.columnVisibility.state.open">
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
<span uib-dropdown-toggle ><i class="fa fa-columns space-right" aria-hidden="true"></i>Columns</span>
|
||||||
|
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||||
|
<div class="tableMenu">
|
||||||
|
<div class="menuHeader">
|
||||||
|
Show / Hide Columns
|
||||||
|
</div>
|
||||||
|
<div class="menuContent">
|
||||||
|
<div class="md-checkbox">
|
||||||
|
<input id="col_vis_state" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.state.display"/>
|
||||||
|
<label for="col_vis_state" ng-bind="$ctrl.columnVisibility.columns.state.label"></label>
|
||||||
|
</div>
|
||||||
|
<div class="md-checkbox">
|
||||||
|
<input id="col_vis_actions" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.actions.display"/>
|
||||||
|
<label for="col_vis_actions" ng-bind="$ctrl.columnVisibility.columns.actions.label"></label>
|
||||||
|
</div>
|
||||||
|
<div class="md-checkbox">
|
||||||
|
<input id="col_vis_stack" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.stack.display"/>
|
||||||
|
<label for="col_vis_stack" ng-bind="$ctrl.columnVisibility.columns.stack.label"></label>
|
||||||
|
</div>
|
||||||
|
<div class="md-checkbox">
|
||||||
|
<input id="col_vis_image" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.image.display"/>
|
||||||
|
<label for="col_vis_image" ng-bind="$ctrl.columnVisibility.columns.image.label"></label>
|
||||||
|
</div>
|
||||||
|
<div class="md-checkbox">
|
||||||
|
<input id="col_vis_created" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.created.display"/>
|
||||||
|
<label for="col_vis_created" ng-bind="$ctrl.columnVisibility.columns.created.label"></label>
|
||||||
|
</div>
|
||||||
|
<div class="md-checkbox">
|
||||||
|
<input id="col_vis_ip" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.ip.display"/>
|
||||||
|
<label for="col_vis_ip" ng-bind="$ctrl.columnVisibility.columns.ip.label"></label>
|
||||||
|
</div>
|
||||||
|
<div class="md-checkbox" ng-if="$ctrl.showHostColumn">
|
||||||
|
<input id="col_vis_host" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.host.display"/>
|
||||||
|
<label for="col_vis_host" ng-bind="$ctrl.columnVisibility.columns.host.label"></label>
|
||||||
|
</div>
|
||||||
|
<div class="md-checkbox">
|
||||||
|
<input id="col_vis_ports" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.ports.display"/>
|
||||||
|
<label for="col_vis_ports" ng-bind="$ctrl.columnVisibility.columns.ports.label"></label>
|
||||||
|
</div>
|
||||||
|
<div class="md-checkbox">
|
||||||
|
<input id="col_vis_ownership" ng-click="$ctrl.onColumnVisibilityChange()" type="checkbox" ng-model="$ctrl.columnVisibility.columns.ownership.display"/>
|
||||||
|
<label for="col_vis_ownership" ng-bind="$ctrl.columnVisibility.columns.ownership.label"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.columnVisibility.state.open = false;">Close</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
|
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
|
||||||
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
|
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
|
||||||
|
@ -59,12 +107,12 @@
|
||||||
no-paused-items-selected="$ctrl.state.noPausedItemsSelected"
|
no-paused-items-selected="$ctrl.state.noPausedItemsSelected"
|
||||||
show-add-action="$ctrl.showAddAction"
|
show-add-action="$ctrl.showAddAction"
|
||||||
></containers-datatable-actions>
|
></containers-datatable-actions>
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
<div class="searchBar">
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover table-filters">
|
<table class="table table-hover table-filters nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
@ -78,7 +126,7 @@
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Names' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Names' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
|
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open" ng-show="$ctrl.columnVisibility.columns.state.display">
|
||||||
<a ng-click="$ctrl.changeOrderBy('Status')">
|
<a ng-click="$ctrl.changeOrderBy('Status')">
|
||||||
State
|
State
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
|
||||||
|
@ -105,45 +153,52 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect">
|
<th ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect" ng-show="$ctrl.columnVisibility.columns.actions.display">
|
||||||
Quick actions
|
Quick actions
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th ng-show="$ctrl.columnVisibility.columns.stack.display">
|
||||||
<a ng-click="$ctrl.changeOrderBy('StackName')">
|
<a ng-click="$ctrl.changeOrderBy('StackName')">
|
||||||
Stack
|
Stack
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && !$ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && !$ctrl.state.reverseOrder"></i>
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th ng-show="$ctrl.columnVisibility.columns.image.display">
|
||||||
<a ng-click="$ctrl.changeOrderBy('Image')">
|
<a ng-click="$ctrl.changeOrderBy('Image')">
|
||||||
Image
|
Image
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && !$ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && !$ctrl.state.reverseOrder"></i>
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th ng-show="$ctrl.columnVisibility.columns.created.display">
|
||||||
|
<a ng-click="$ctrl.changeOrderBy('Created')">
|
||||||
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && !$ctrl.state.reverseOrder"></i>
|
||||||
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
|
||||||
|
Created
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th ng-show="$ctrl.columnVisibility.columns.ip.display">
|
||||||
<a ng-click="$ctrl.changeOrderBy('IP')">
|
<a ng-click="$ctrl.changeOrderBy('IP')">
|
||||||
IP Address
|
IP Address
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && !$ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && !$ctrl.state.reverseOrder"></i>
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th ng-if="$ctrl.showHostColumn">
|
<th ng-if="$ctrl.showHostColumn" ng-show="$ctrl.columnVisibility.columns.host.display">
|
||||||
<a ng-click="$ctrl.changeOrderBy('NodeName')">
|
<a ng-click="$ctrl.changeOrderBy('NodeName')">
|
||||||
Host
|
Host
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && !$ctrl.state.reverseOrder"></i>
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th>
|
<th ng-show="$ctrl.columnVisibility.columns.ports.display">
|
||||||
<a ng-click="$ctrl.changeOrderBy('Ports')">
|
<a ng-click="$ctrl.changeOrderBy('Ports')">
|
||||||
Published Ports
|
Published Ports
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && !$ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && !$ctrl.state.reverseOrder"></i>
|
||||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && $ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && $ctrl.state.reverseOrder"></i>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th ng-if="$ctrl.showOwnershipColumn">
|
<th ng-if="$ctrl.showOwnershipColumn" ng-show="$ctrl.columnVisibility.columns.ownership.display">
|
||||||
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
|
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
|
||||||
Ownership
|
Ownership
|
||||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && !$ctrl.state.reverseOrder"></i>
|
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.Ownership' && !$ctrl.state.reverseOrder"></i>
|
||||||
|
@ -161,11 +216,11 @@
|
||||||
</span>
|
</span>
|
||||||
<a ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })" title="{{ item | containername }}">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
|
<a ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })" title="{{ item | containername }}">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td ng-show="$ctrl.columnVisibility.columns.state.display">
|
||||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ item.Status }}</span>
|
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive" uib-tooltip="This container has a health check">{{ item.Status }}</span>
|
||||||
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) === -1" class="label label-{{ item.Status|containerstatusbadge }}">{{ item.Status }}</span>
|
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) === -1" class="label label-{{ item.Status|containerstatusbadge }}">{{ item.Status }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect">
|
<td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect" ng-show="$ctrl.columnVisibility.columns.actions.display">
|
||||||
<div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;">
|
<div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;">
|
||||||
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id, nodeName: item.NodeName})" title="Stats"><i class="fa fa-chart-area space-right" aria-hidden="true"></i></a>
|
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id, nodeName: item.NodeName})" title="Stats"><i class="fa fa-chart-area space-right" aria-hidden="true"></i></a>
|
||||||
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id, nodeName: item.NodeName})" title="Logs"><i class="fa fa-file-alt space-right" aria-hidden="true"></i></a>
|
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id, nodeName: item.NodeName})" title="Logs"><i class="fa fa-file-alt space-right" aria-hidden="true"></i></a>
|
||||||
|
@ -173,17 +228,20 @@
|
||||||
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id, nodeName: item.NodeName})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
|
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id, nodeName: item.NodeName})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
<td ng-show="$ctrl.columnVisibility.columns.stack.display">{{ item.StackName ? item.StackName : '-' }}</td>
|
||||||
<td><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
|
<td ng-show="$ctrl.columnVisibility.columns.image.display"><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
|
||||||
<td>{{ item.IP ? item.IP : '-' }}</td>
|
<td ng-show="$ctrl.columnVisibility.columns.created.display">
|
||||||
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
|
{{item.Created | getisodatefromtimestamp}}
|
||||||
<td>
|
</td>
|
||||||
|
<td ng-show="$ctrl.columnVisibility.columns.ip.display">{{ item.IP ? item.IP : '-' }}</td>
|
||||||
|
<td ng-if="$ctrl.showHostColumn" ng-show="$ctrl.columnVisibility.columns.host.display">{{ item.NodeName ? item.NodeName : '-' }}</td>
|
||||||
|
<td ng-show="$ctrl.columnVisibility.columns.ports.display">
|
||||||
<a ng-if="item.Ports.length > 0" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ $ctrl.state.publicURL || p.host }}:{{p.public}}" target="_blank">
|
<a ng-if="item.Ports.length > 0" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ $ctrl.state.publicURL || p.host }}:{{p.public}}" target="_blank">
|
||||||
<i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ p.public }}:{{ p.private }}
|
<i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ p.public }}:{{ p.private }}
|
||||||
</a>
|
</a>
|
||||||
<span ng-if="item.Ports.length == 0" >-</span>
|
<span ng-if="item.Ports.length == 0" >-</span>
|
||||||
</td>
|
</td>
|
||||||
<td ng-if="$ctrl.showOwnershipColumn">
|
<td ng-if="$ctrl.showOwnershipColumn" ng-show="$ctrl.columnVisibility.columns.ownership.display">
|
||||||
<span>
|
<span>
|
||||||
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
||||||
{{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'public' }}
|
{{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'public' }}
|
||||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('containersDatatable', {
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
|
||||||
showOwnershipColumn: '<',
|
showOwnershipColumn: '<',
|
||||||
showHostColumn: '<',
|
showHostColumn: '<',
|
||||||
showAddAction: '<'
|
showAddAction: '<'
|
||||||
|
|
|
@ -34,6 +34,54 @@ function (PaginationService, DatatableService, EndpointProvider) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.columnVisibility = {
|
||||||
|
state: {
|
||||||
|
open: false
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
state: {
|
||||||
|
label: 'State',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
label: 'Quick Actions',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
stack: {
|
||||||
|
label: 'Stack',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
label: 'Image',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
created: {
|
||||||
|
label: 'Created',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
ip: {
|
||||||
|
label: 'IP Address',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
host: {
|
||||||
|
label: 'Host',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
ports: {
|
||||||
|
label: 'Published Ports',
|
||||||
|
display: true
|
||||||
|
},
|
||||||
|
ownership: {
|
||||||
|
label: 'Ownership',
|
||||||
|
display: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onColumnVisibilityChange = function() {
|
||||||
|
DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility);
|
||||||
|
};
|
||||||
|
|
||||||
this.changeOrderBy = function(orderField) {
|
this.changeOrderBy = function(orderField) {
|
||||||
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
|
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
|
||||||
this.state.orderBy = orderField;
|
this.state.orderBy = orderField;
|
||||||
|
@ -93,13 +141,6 @@ function (PaginationService, DatatableService, EndpointProvider) {
|
||||||
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
|
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.updateDisplayTextFilter = function() {
|
|
||||||
this.state.displayTextFilter = !this.state.displayTextFilter;
|
|
||||||
if (!this.state.displayTextFilter) {
|
|
||||||
delete this.state.textFilter;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.applyFilters = function(value, index, array) {
|
this.applyFilters = function(value, index, array) {
|
||||||
var container = value;
|
var container = value;
|
||||||
var filters = ctrl.filters;
|
var filters = ctrl.filters;
|
||||||
|
@ -181,6 +222,12 @@ function (PaginationService, DatatableService, EndpointProvider) {
|
||||||
this.settings = storedSettings;
|
this.settings = storedSettings;
|
||||||
}
|
}
|
||||||
this.settings.open = false;
|
this.settings.open = false;
|
||||||
|
|
||||||
|
var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
|
||||||
|
if (storedColumnVisibility !== null) {
|
||||||
|
this.columnVisibility = storedColumnVisibility;
|
||||||
|
}
|
||||||
|
this.columnVisibility.state.open = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
function setDefaults(ctrl) {
|
function setDefaults(ctrl) {
|
||||||
|
|
|
@ -5,18 +5,13 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="searchBar">
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
|
@ -7,7 +7,6 @@ angular.module('portainer.docker').component('eventsDatatable', {
|
||||||
dataset: '<',
|
dataset: '<',
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<'
|
||||||
showTextFilter: '<'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,11 +5,6 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actionBar">
|
<div class="actionBar">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
|
@ -28,13 +23,24 @@
|
||||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.images.build">
|
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.images.build">
|
||||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Build a new image
|
<i class="fa fa-plus space-right" aria-hidden="true"></i>Build a new image
|
||||||
</button>
|
</button>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" ng-disabled="$ctrl.exportInProgress" ui-sref="docker.images.import">
|
||||||
|
<i class="fa fa-upload space-right" aria-hidden="true"></i>Import
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" ng-disabled="$ctrl.state.selectedItemCount === 0 || $ctrl.exportInProgress"
|
||||||
|
ng-click="$ctrl.downloadAction($ctrl.state.selectedItems)" button-spinner="$ctrl.exportInProgress">
|
||||||
|
<i class="fa fa-download space-right" aria-hidden="true"></i>
|
||||||
|
<span ng-hide="$ctrl.exportInProgress">Export</span>
|
||||||
|
<span ng-show="$ctrl.exportInProgress">Export in progress...</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
</div>
|
||||||
|
<div class="searchBar">
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover table-filters">
|
<table class="table table-hover table-filters nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th uib-dropdown dropdown-append-to-body auto-close="disabled" popover-placement="bottom-left" is-open="$ctrl.filters.usage.open">
|
<th uib-dropdown dropdown-append-to-body auto-close="disabled" popover-placement="bottom-left" is-open="$ctrl.filters.usage.open">
|
||||||
|
@ -109,11 +115,11 @@
|
||||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
|
||||||
<label for="select_{{ $index }}"></label>
|
<label for="select_{{ $index }}"></label>
|
||||||
</span>
|
</span>
|
||||||
<a ui-sref="docker.images.image({ id: item.Id, nodeName: item.NodeName })" class="monospaced">{{ item.Id | truncate:20 }}</a>
|
<a ui-sref="docker.images.image({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a>
|
||||||
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::item.ContainerCount === 0">Unused</span>
|
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="::item.ContainerCount === 0">Unused</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="label label-primary image-tag" ng-repeat="tag in (item | repotags)">{{ tag }}</span>
|
<span class="label label-primary image-tag" ng-repeat="tag in (item | repotags) track by $index">{{ tag }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.VirtualSize | humansize }}</td>
|
<td>{{ item.VirtualSize | humansize }}</td>
|
||||||
<td>{{ item.Created | getisodatefromtimestamp }}</td>
|
<td>{{ item.Created | getisodatefromtimestamp }}</td>
|
||||||
|
|
|
@ -8,9 +8,10 @@ angular.module('portainer.docker').component('imagesDatatable', {
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
|
||||||
showHostColumn: '<',
|
showHostColumn: '<',
|
||||||
removeAction: '<',
|
removeAction: '<',
|
||||||
forceRemoveAction: '<'
|
downloadAction: '<',
|
||||||
|
forceRemoveAction: '<',
|
||||||
|
exportInProgress: '<'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -52,13 +52,6 @@ function (PaginationService, DatatableService) {
|
||||||
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
|
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
|
||||||
};
|
};
|
||||||
|
|
||||||
this.updateDisplayTextFilter = function() {
|
|
||||||
this.state.displayTextFilter = !this.state.displayTextFilter;
|
|
||||||
if (!this.state.displayTextFilter) {
|
|
||||||
delete this.state.textFilter;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.applyFilters = function(value, index, array) {
|
this.applyFilters = function(value, index, array) {
|
||||||
var image = value;
|
var image = value;
|
||||||
var filters = ctrl.filters;
|
var filters = ctrl.filters;
|
||||||
|
|
|
@ -5,11 +5,6 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actionBar">
|
<div class="actionBar">
|
||||||
<button type="button" class="btn btn-sm btn-danger"
|
<button type="button" class="btn btn-sm btn-danger"
|
||||||
|
@ -20,12 +15,12 @@
|
||||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add network
|
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add network
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
<div class="searchBar">
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('networksDatatable', {
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
|
||||||
showOwnershipColumn: '<',
|
showOwnershipColumn: '<',
|
||||||
showHostColumn: '<',
|
showHostColumn: '<',
|
||||||
removeAction: '<'
|
removeAction: '<'
|
||||||
|
|
|
@ -5,18 +5,13 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="searchBar">
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
|
@ -7,7 +7,6 @@ angular.module('portainer.docker').component('nodeTasksDatatable', {
|
||||||
dataset: '<',
|
dataset: '<',
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<'
|
||||||
showTextFilter: '<'
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,18 +5,13 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="searchBar">
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('nodesDatatable', {
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
|
||||||
showIpAddressColumn: '<',
|
showIpAddressColumn: '<',
|
||||||
accessToNodeDetails: '<'
|
accessToNodeDetails: '<'
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,6 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actionBar">
|
<div class="actionBar">
|
||||||
<button type="button" class="btn btn-sm btn-danger"
|
<button type="button" class="btn btn-sm btn-danger"
|
||||||
|
@ -20,12 +15,12 @@
|
||||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add secret
|
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add secret
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
<div class="searchBar">
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<th>
|
||||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('secretsDatatable', {
|
||||||
tableKey: '@',
|
tableKey: '@',
|
||||||
orderBy: '@',
|
orderBy: '@',
|
||||||
reverseOrder: '<',
|
reverseOrder: '<',
|
||||||
showTextFilter: '<',
|
|
||||||
showOwnershipColumn: '<',
|
showOwnershipColumn: '<',
|
||||||
removeAction: '<'
|
removeAction: '<'
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<div style="background-color: #d5e8f3; padding: 2px">
|
<div style="background-color: #d5e8f3; padding: 2px">
|
||||||
<table class="table table-condensed table-hover">
|
<table class="table table-condensed table-hover nowrap-cells">
|
||||||
<thead style="background-color: #e7f6ff">
|
<thead style="background-color: #e7f6ff">
|
||||||
<tr>
|
<tr>
|
||||||
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open" style="width: 10%;">
|
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open" style="width: 10%;">
|
||||||
|
|
|
@ -5,11 +5,6 @@
|
||||||
<div class="toolBarTitle">
|
<div class="toolBarTitle">
|
||||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||||
</div>
|
</div>
|
||||||
<div class="settings">
|
|
||||||
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
|
|
||||||
<i class="fa fa-search" aria-hidden="true"></i> Search
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<services-datatable-actions
|
<services-datatable-actions
|
||||||
selected-items="$ctrl.state.selectedItems"
|
selected-items="$ctrl.state.selectedItems"
|
||||||
|
@ -17,12 +12,12 @@
|
||||||
show-add-action="$ctrl.showAddAction"
|
show-add-action="$ctrl.showAddAction"
|
||||||
show-update-action="$ctrl.showUpdateAction"
|
show-update-action="$ctrl.showUpdateAction"
|
||||||
></services-datatable-actions>
|
></services-datatable-actions>
|
||||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
<div class="searchBar">
|
||||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover nowrap-cells">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:55px;">
|
<th style="width:55px;">
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue