mirror of https://github.com/portainer/portainer
commit
dc9512f25c
|
@ -18,7 +18,7 @@ steps:
|
|||
- mv api/cmd/portainer/portainer dist/
|
||||
|
||||
get_docker_version:
|
||||
image: alpine
|
||||
image: alpine:3.7
|
||||
working_directory: ${{build_frontend}}
|
||||
commands:
|
||||
- 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/
|
||||
|
||||
get_docker_version:
|
||||
image: alpine
|
||||
image: alpine:3.7
|
||||
working_directory: ${{build_frontend}}
|
||||
commands:
|
||||
- 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/team"
|
||||
"github.com/portainer/portainer/bolt/teammembership"
|
||||
"github.com/portainer/portainer/bolt/template"
|
||||
"github.com/portainer/portainer/bolt/user"
|
||||
"github.com/portainer/portainer/bolt/version"
|
||||
)
|
||||
|
@ -43,6 +44,7 @@ type Store struct {
|
|||
TagService *tag.Service
|
||||
TeamMembershipService *teammembership.Service
|
||||
TeamService *team.Service
|
||||
TemplateService *template.Service
|
||||
UserService *user.Service
|
||||
VersionService *version.Service
|
||||
}
|
||||
|
@ -212,6 +214,12 @@ func (store *Store) initServices() error {
|
|||
}
|
||||
store.TeamService = teamService
|
||||
|
||||
templateService, err := template.NewService(store.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.TemplateService = templateService
|
||||
|
||||
userService, err := user.NewService(store.db)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -82,8 +82,11 @@ func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error {
|
|||
return service.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
id, _ := bucket.NextSequence()
|
||||
endpoint.ID = portainer.EndpointID(id)
|
||||
// We manually manage sequences for endpoints
|
||||
err := bucket.SetSequence(uint64(endpoint.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := internal.MarshalObject(endpoint)
|
||||
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.
|
||||
func (service *Service) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error {
|
||||
return service.db.Update(func(tx *bolt.Tx) error {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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{}
|
||||
|
||||
const (
|
||||
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
|
||||
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
|
||||
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix://, npipe:// or tcp://")
|
||||
errSocketOrNamedPipeNotFound = portainer.Error("Unable to locate Unix socket or named pipe")
|
||||
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")
|
||||
errInvalidSnapshotInterval = portainer.Error("Invalid snapshot interval")
|
||||
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
|
||||
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
|
||||
errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file")
|
||||
|
@ -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(),
|
||||
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(),
|
||||
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(),
|
||||
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')),
|
||||
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()
|
||||
|
@ -73,7 +78,12 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
|||
return errEndpointExcludeExternal
|
||||
}
|
||||
|
||||
err := validateEndpointURL(*flags.EndpointURL)
|
||||
err := validateTemplateFile(*flags.TemplateFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateEndpointURL(*flags.EndpointURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -88,6 +98,11 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
|||
return err
|
||||
}
|
||||
|
||||
err = validateSnapshotInterval(*flags.SnapshotInterval)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") {
|
||||
return errNoAuthExcludeAdminPassword
|
||||
}
|
||||
|
@ -101,15 +116,16 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
|||
|
||||
func validateEndpointURL(endpointURL string) error {
|
||||
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
|
||||
}
|
||||
|
||||
if strings.HasPrefix(endpointURL, "unix://") {
|
||||
if strings.HasPrefix(endpointURL, "unix://") || strings.HasPrefix(endpointURL, "npipe://") {
|
||||
socketPath := strings.TrimPrefix(endpointURL, "unix://")
|
||||
socketPath = strings.TrimPrefix(socketPath, "npipe://")
|
||||
if _, err := os.Stat(socketPath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errSocketNotFound
|
||||
return errSocketOrNamedPipeNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
@ -130,6 +146,16 @@ func validateExternalEndpoints(externalEndpoints string) error {
|
|||
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 {
|
||||
if syncInterval != defaultSyncInterval {
|
||||
_, err := time.ParseDuration(syncInterval)
|
||||
|
@ -139,3 +165,13 @@ func validateSyncInterval(syncInterval string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSnapshotInterval(snapshotInterval string) error {
|
||||
if snapshotInterval != defaultSnapshotInterval {
|
||||
_, err := time.ParseDuration(snapshotInterval)
|
||||
if err != nil {
|
||||
return errInvalidSnapshotInterval
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -3,18 +3,21 @@
|
|||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultSSL = "false"
|
||||
defaultSSLCertPath = "/certs/portainer.crt"
|
||||
defaultSSLKeyPath = "/certs/portainer.key"
|
||||
defaultSyncInterval = "60s"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "/data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "/certs/ca.pem"
|
||||
defaultTLSCertPath = "/certs/cert.pem"
|
||||
defaultTLSKeyPath = "/certs/key.pem"
|
||||
defaultSSL = "false"
|
||||
defaultSSLCertPath = "/certs/portainer.crt"
|
||||
defaultSSLKeyPath = "/certs/portainer.key"
|
||||
defaultSyncInterval = "60s"
|
||||
defaultSnapshot = "true"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultTemplateFile = "/templates.json"
|
||||
)
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
package cli
|
||||
|
||||
const (
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultSSL = "false"
|
||||
defaultSSLCertPath = "C:\\certs\\portainer.crt"
|
||||
defaultSSLKeyPath = "C:\\certs\\portainer.key"
|
||||
defaultSyncInterval = "60s"
|
||||
defaultBindAddress = ":9000"
|
||||
defaultDataDirectory = "C:\\data"
|
||||
defaultAssetsDirectory = "./"
|
||||
defaultNoAuth = "false"
|
||||
defaultNoAnalytics = "false"
|
||||
defaultTLS = "false"
|
||||
defaultTLSSkipVerify = "false"
|
||||
defaultTLSCACertPath = "C:\\certs\\ca.pem"
|
||||
defaultTLSCertPath = "C:\\certs\\cert.pem"
|
||||
defaultTLSKeyPath = "C:\\certs\\key.pem"
|
||||
defaultSSL = "false"
|
||||
defaultSSLCertPath = "C:\\certs\\portainer.crt"
|
||||
defaultSSLKeyPath = "C:\\certs\\portainer.key"
|
||||
defaultSyncInterval = "60s"
|
||||
defaultSnapshot = "true"
|
||||
defaultSnapshotInterval = "5m"
|
||||
defaultTemplateFile = "/templates.json"
|
||||
)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package main // import "github.com/portainer/portainer"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
|
@ -8,6 +9,7 @@ import (
|
|||
"github.com/portainer/portainer/cli"
|
||||
"github.com/portainer/portainer/cron"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
"github.com/portainer/portainer/docker"
|
||||
"github.com/portainer/portainer/exec"
|
||||
"github.com/portainer/portainer/filesystem"
|
||||
"github.com/portainer/portainer/git"
|
||||
|
@ -100,25 +102,41 @@ func initGitService() portainer.GitService {
|
|||
return &git.Service{}
|
||||
}
|
||||
|
||||
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
|
||||
authorizeEndpointMgmt := true
|
||||
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 initClientFactory(signatureService portainer.DigitalSignatureService) *docker.ClientFactory {
|
||||
return docker.NewClientFactory(signatureService)
|
||||
}
|
||||
|
||||
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{
|
||||
Analytics: !*flags.NoAnalytics,
|
||||
Authentication: !*flags.NoAuth,
|
||||
EndpointManagement: authorizeEndpointMgmt,
|
||||
EndpointManagement: endpointManagement,
|
||||
Snapshot: snapshot,
|
||||
Version: portainer.APIVersion,
|
||||
}
|
||||
}
|
||||
|
@ -143,23 +161,21 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
|||
_, err := settingsService.Settings()
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
settings := &portainer.Settings{
|
||||
LogoURL: *flags.Logo,
|
||||
DisplayExternalContributors: false,
|
||||
AuthenticationMethod: portainer.AuthenticationInternal,
|
||||
LogoURL: *flags.Logo,
|
||||
AuthenticationMethod: portainer.AuthenticationInternal,
|
||||
LDAPSettings: portainer.LDAPSettings{
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
AutoCreateUsers: true,
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
SearchSettings: []portainer.LDAPSearchSettings{
|
||||
portainer.LDAPSearchSettings{},
|
||||
},
|
||||
GroupSearchSettings: []portainer.LDAPGroupSearchSettings{
|
||||
portainer.LDAPGroupSearchSettings{},
|
||||
},
|
||||
},
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
}
|
||||
|
||||
if *flags.Templates != "" {
|
||||
settings.TemplatesURL = *flags.Templates
|
||||
} else {
|
||||
settings.TemplatesURL = portainer.DefaultTemplatesURL
|
||||
SnapshotInterval: *flags.SnapshotInterval,
|
||||
}
|
||||
|
||||
if *flags.Labels != nil {
|
||||
|
@ -176,6 +192,58 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
|||
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 {
|
||||
endpoints, err := endpointService.Endpoints()
|
||||
if err != nil {
|
||||
|
@ -213,7 +281,7 @@ func initKeyPair(fileService portainer.FileService, signatureService portainer.D
|
|||
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{
|
||||
TLS: *flags.TLS,
|
||||
TLSSkipVerify: *flags.TLSSkipVerify,
|
||||
|
@ -227,7 +295,9 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain
|
|||
tlsConfiguration.TLS = true
|
||||
}
|
||||
|
||||
endpointID := endpointService.GetNextIdentifier()
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(endpointID),
|
||||
Name: "primary",
|
||||
URL: *flags.EndpointURL,
|
||||
GroupID: portainer.EndpointGroupID(1),
|
||||
|
@ -237,6 +307,8 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain
|
|||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
Tags: []string{},
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.Snapshot{},
|
||||
}
|
||||
|
||||
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://") {
|
||||
_, err := client.ExecutePingOperation(endpointURL, nil)
|
||||
if err != nil {
|
||||
|
@ -266,7 +338,9 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo
|
|||
}
|
||||
}
|
||||
|
||||
endpointID := endpointService.GetNextIdentifier()
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(endpointID),
|
||||
Name: "primary",
|
||||
URL: endpointURL,
|
||||
GroupID: portainer.EndpointGroupID(1),
|
||||
|
@ -276,12 +350,28 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo
|
|||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
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)
|
||||
}
|
||||
|
||||
func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService) error {
|
||||
func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) error {
|
||||
if *flags.EndpointURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
@ -297,9 +387,9 @@ func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointS
|
|||
}
|
||||
|
||||
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() {
|
||||
|
@ -312,21 +402,35 @@ func main() {
|
|||
|
||||
jwtService := initJWTService(!*flags.NoAuth)
|
||||
|
||||
cryptoService := initCryptoService()
|
||||
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
|
||||
ldapService := initLDAPService()
|
||||
|
||||
gitService := initGitService()
|
||||
|
||||
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
|
||||
cryptoService := initCryptoService()
|
||||
|
||||
digitalSignatureService := initDigitalSignatureService()
|
||||
|
||||
err := initKeyPair(fileService, digitalSignatureService)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -334,6 +438,11 @@ func main() {
|
|||
|
||||
composeStackManager := initComposeStackManager(*flags.Data)
|
||||
|
||||
err = initTemplates(store.TemplateService, fileService, *flags.Templates, *flags.TemplateFile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = initSettings(store.SettingsService, flags)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
@ -344,9 +453,9 @@ func main() {
|
|||
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 {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
@ -357,7 +466,7 @@ func main() {
|
|||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
adminPasswordHash, err = cryptoService.Hash(content)
|
||||
adminPasswordHash, err = cryptoService.Hash(string(content))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
@ -392,7 +501,7 @@ func main() {
|
|||
BindAddress: *flags.Addr,
|
||||
AssetsPath: *flags.Assets,
|
||||
AuthDisabled: *flags.NoAuth,
|
||||
EndpointManagement: authorizeEndpointMgmt,
|
||||
EndpointManagement: endpointManagement,
|
||||
UserService: store.UserService,
|
||||
TeamService: store.TeamService,
|
||||
TeamMembershipService: store.TeamMembershipService,
|
||||
|
@ -404,6 +513,7 @@ func main() {
|
|||
DockerHubService: store.DockerHubService,
|
||||
StackService: store.StackService,
|
||||
TagService: store.TagService,
|
||||
TemplateService: store.TemplateService,
|
||||
SwarmStackManager: swarmStackManager,
|
||||
ComposeStackManager: composeStackManager,
|
||||
CryptoService: cryptoService,
|
||||
|
@ -412,6 +522,8 @@ func main() {
|
|||
LDAPService: ldapService,
|
||||
GitService: gitService,
|
||||
SignatureService: digitalSignatureService,
|
||||
JobScheduler: jobScheduler,
|
||||
Snapshotter: snapshotter,
|
||||
SSL: *flags.SSL,
|
||||
SSLCert: *flags.SSLCert,
|
||||
SSLKey: *flags.SSLKey,
|
||||
|
|
|
@ -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"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
|
@ -12,7 +11,6 @@ import (
|
|||
|
||||
type (
|
||||
endpointSyncJob struct {
|
||||
logger *log.Logger
|
||||
endpointService portainer.EndpointService
|
||||
endpointFilePath string
|
||||
}
|
||||
|
@ -41,15 +39,14 @@ const (
|
|||
|
||||
func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob {
|
||||
return endpointSyncJob{
|
||||
logger: log.New(os.Stderr, "", log.LstdFlags),
|
||||
endpointService: endpointService,
|
||||
endpointFilePath: endpointFilePath,
|
||||
}
|
||||
}
|
||||
|
||||
func endpointSyncError(err error, logger *log.Logger) bool {
|
||||
func endpointSyncError(err error) bool {
|
||||
if err != nil {
|
||||
logger.Printf("Endpoint synchronization error: %s", err)
|
||||
log.Printf("cron error: synchronization job error (err=%s)\n", err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
@ -140,23 +137,23 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
|
|||
if fidx != -1 {
|
||||
endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx])
|
||||
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)
|
||||
}
|
||||
} 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])
|
||||
}
|
||||
}
|
||||
|
||||
for idx, endpoint := range fileEndpoints {
|
||||
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
|
||||
}
|
||||
sidx := endpointExists(&fileEndpoints[idx], storedEndpoints)
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
@ -170,13 +167,13 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
|
|||
|
||||
func (job endpointSyncJob) Sync() error {
|
||||
data, err := ioutil.ReadFile(job.endpointFilePath)
|
||||
if endpointSyncError(err, job.logger) {
|
||||
if endpointSyncError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
var fileEndpoints []fileEndpoint
|
||||
err = json.Unmarshal(data, &fileEndpoints)
|
||||
if endpointSyncError(err, job.logger) {
|
||||
if endpointSyncError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -185,7 +182,7 @@ func (job endpointSyncJob) Sync() error {
|
|||
}
|
||||
|
||||
storedEndpoints, err := job.endpointService.Endpoints()
|
||||
if endpointSyncError(err, job.logger) {
|
||||
if endpointSyncError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -194,16 +191,16 @@ func (job endpointSyncJob) Sync() error {
|
|||
sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints)
|
||||
if sync.requireSync() {
|
||||
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
|
||||
if endpointSyncError(err, job.logger) {
|
||||
if endpointSyncError(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
|
||||
}
|
||||
|
||||
func (job endpointSyncJob) Run() {
|
||||
job.logger.Println("Endpoint synchronization job started.")
|
||||
log.Println("cron: synchronization job started")
|
||||
err := job.Sync()
|
||||
endpointSyncError(err, job.logger)
|
||||
endpointSyncError(err)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -10,10 +10,11 @@ const (
|
|||
|
||||
// User errors.
|
||||
const (
|
||||
ErrUserAlreadyExists = Error("User already exists")
|
||||
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed")
|
||||
ErrAdminAlreadyInitialized = Error("An administrator user already exists")
|
||||
ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator")
|
||||
ErrUserAlreadyExists = Error("User already exists")
|
||||
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed")
|
||||
ErrAdminAlreadyInitialized = Error("An administrator user already exists")
|
||||
ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator")
|
||||
ErrCannotRemoveLastLocalAdmin = Error("Cannot remove the last local administrator account")
|
||||
)
|
||||
|
||||
// Team errors.
|
||||
|
|
|
@ -169,7 +169,7 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
|
|||
return make(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(raw), &config)
|
||||
err = json.Unmarshal(raw, &config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
@ -176,14 +176,14 @@ func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileT
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetFileContent returns a string content from file.
|
||||
func (service *Service) GetFileContent(filePath string) (string, error) {
|
||||
// GetFileContent returns the content of a file as bytes.
|
||||
func (service *Service) GetFileContent(filePath string) ([]byte, error) {
|
||||
content, err := ioutil.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return string(content), nil
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Rename renames a file or directory
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"gopkg.in/src-d/go-git.v4"
|
||||
"gopkg.in/src-d/go-git.v4/plumbing"
|
||||
)
|
||||
|
||||
// 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
|
||||
// destination folder.
|
||||
func (service *Service) ClonePublicRepository(repositoryURL, destination string) error {
|
||||
return cloneRepository(repositoryURL, destination)
|
||||
func (service *Service) ClonePublicRepository(repositoryURL, referenceName string, destination string) error {
|
||||
return cloneRepository(repositoryURL, referenceName, destination)
|
||||
}
|
||||
|
||||
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
|
||||
// destination folder. It will use the specified username and password for basic HTTP authentication.
|
||||
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error {
|
||||
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error {
|
||||
credentials := username + ":" + url.PathEscape(password)
|
||||
repositoryURL = strings.Replace(repositoryURL, "://", "://"+credentials+"@", 1)
|
||||
return cloneRepository(repositoryURL, destination)
|
||||
return cloneRepository(repositoryURL, referenceName, destination)
|
||||
}
|
||||
|
||||
func cloneRepository(repositoryURL, destination string) error {
|
||||
_, err := git.PlainClone(destination, false, &git.CloneOptions{
|
||||
func cloneRepository(repositoryURL, referenceName string, destination string) error {
|
||||
options := &git.CloneOptions{
|
||||
URL: repositoryURL,
|
||||
})
|
||||
}
|
||||
|
||||
if referenceName != "" {
|
||||
options.ReferenceName = plumbing.ReferenceName(referenceName)
|
||||
}
|
||||
|
||||
_, err := git.PlainClone(destination, false, options)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
@ -61,6 +62,27 @@ func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portain
|
|||
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
|
||||
// using the specified host and optional TLS configuration.
|
||||
// It uses a new Http.Client for each operation.
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"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}
|
||||
}
|
||||
|
||||
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()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
||||
}
|
||||
|
||||
if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 {
|
||||
err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, &settings.LDAPSettings)
|
||||
if err != nil {
|
||||
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}
|
||||
}
|
||||
u, err := handler.UserService.UserByUsername(payload.Username)
|
||||
if err != nil && err != portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
|
||||
}
|
||||
|
||||
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{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
@ -20,12 +20,14 @@ const (
|
|||
// Handler is the HTTP handler used to handle authentication operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
authDisabled bool
|
||||
UserService portainer.UserService
|
||||
CryptoService portainer.CryptoService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
SettingsService portainer.SettingsService
|
||||
authDisabled bool
|
||||
UserService portainer.UserService
|
||||
CryptoService portainer.CryptoService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
SettingsService portainer.SettingsService
|
||||
TeamService portainer.TeamService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage authentication operations.
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
package endpoints
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/crypto"
|
||||
|
@ -56,6 +57,9 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||
return portainer.Error("Invalid Tags parameter")
|
||||
}
|
||||
payload.Tags = tags
|
||||
if payload.Tags == nil {
|
||||
payload.Tags = make([]string, 0)
|
||||
}
|
||||
|
||||
useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true)
|
||||
payload.TLS = useTLS
|
||||
|
@ -109,7 +113,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
|
|||
}
|
||||
payload.AzureAuthenticationKey = azureAuthenticationKey
|
||||
default:
|
||||
url, err := request.RetrieveMultiPartFormValue(r, "URL", false)
|
||||
url, err := request.RetrieveMultiPartFormValue(r, "URL", true)
|
||||
if err != nil {
|
||||
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}
|
||||
}
|
||||
|
||||
endpointID := handler.EndpointService.GetNextIdentifier()
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(endpointID),
|
||||
Name: payload.Name,
|
||||
URL: payload.URL,
|
||||
Type: portainer.AzureEnvironment,
|
||||
|
@ -177,6 +183,8 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
|
|||
Extensions: []portainer.EndpointExtension{},
|
||||
AzureCredentials: credentials,
|
||||
Tags: payload.Tags,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.Snapshot{},
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
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{
|
||||
ID: portainer.EndpointID(endpointID),
|
||||
Name: payload.Name,
|
||||
URL: payload.URL,
|
||||
Type: endpointType,
|
||||
|
@ -213,11 +228,13 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
|
|||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
Tags: payload.Tags,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.Snapshot{},
|
||||
}
|
||||
|
||||
err := handler.EndpointService.CreateEndpoint(endpoint)
|
||||
err := handler.snapshotAndPersistEndpoint(endpoint)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return endpoint, nil
|
||||
|
@ -239,7 +256,9 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload)
|
|||
endpointType = portainer.AgentOnDockerEnvironment
|
||||
}
|
||||
|
||||
endpointID := handler.EndpointService.GetNextIdentifier()
|
||||
endpoint := &portainer.Endpoint{
|
||||
ID: portainer.EndpointID(endpointID),
|
||||
Name: payload.Name,
|
||||
URL: payload.URL,
|
||||
Type: endpointType,
|
||||
|
@ -253,34 +272,49 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload)
|
|||
AuthorizedTeams: []portainer.TeamID{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
Tags: payload.Tags,
|
||||
}
|
||||
|
||||
err = handler.EndpointService.CreateEndpoint(endpoint)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err}
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.Snapshot{},
|
||||
}
|
||||
|
||||
filesystemError := handler.storeTLSFiles(endpoint, payload)
|
||||
if err != nil {
|
||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
||||
return nil, filesystemError
|
||||
}
|
||||
|
||||
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
|
||||
endpointCreationError := handler.snapshotAndPersistEndpoint(endpoint)
|
||||
if endpointCreationError != nil {
|
||||
return nil, endpointCreationError
|
||||
}
|
||||
|
||||
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 {
|
||||
folder := strconv.Itoa(int(endpoint.ID))
|
||||
|
||||
if !payload.TLSSkipVerify {
|
||||
caCertPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCA, payload.TLSCACertFile)
|
||||
if err != nil {
|
||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err}
|
||||
}
|
||||
endpoint.TLSConfig.TLSCACertPath = caCertPath
|
||||
|
@ -289,14 +323,12 @@ func (handler *Handler) storeTLSFiles(endpoint *portainer.Endpoint, payload *end
|
|||
if !payload.TLSSkipClientVerify {
|
||||
certPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileCert, payload.TLSCertFile)
|
||||
if err != nil {
|
||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err}
|
||||
}
|
||||
endpoint.TLSConfig.TLSCertPath = certPath
|
||||
|
||||
keyPath, err := handler.FileService.StoreTLSFileFromBytes(folder, portainer.TLSFileKey, payload.TLSKeyFile)
|
||||
if err != nil {
|
||||
handler.EndpointService.DeleteEndpoint(endpoint.ID)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err}
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
*mux.Router
|
||||
authorizeEndpointManagement bool
|
||||
requestBouncer *security.RequestBouncer
|
||||
EndpointService portainer.EndpointService
|
||||
EndpointGroupService portainer.EndpointGroupService
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
Snapshotter portainer.Snapshotter
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage endpoint operations.
|
||||
|
@ -36,14 +38,17 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo
|
|||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
authorizeEndpointManagement: authorizeEndpointManagement,
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
|
||||
h.Handle("/endpoints",
|
||||
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",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
|
||||
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}",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/endpoints/{id}/access",
|
||||
|
|
|
@ -15,6 +15,7 @@ type Handler struct {
|
|||
SettingsService portainer.SettingsService
|
||||
LDAPService portainer.LDAPService
|
||||
FileService portainer.FileService
|
||||
JobScheduler portainer.JobScheduler
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage settings operations.
|
||||
|
|
|
@ -10,7 +10,6 @@ import (
|
|||
|
||||
type publicSettingsResponse struct {
|
||||
LogoURL string `json:"LogoURL"`
|
||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||
|
@ -25,7 +24,6 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
|
|||
|
||||
publicSettings := &publicSettingsResponse{
|
||||
LogoURL: settings.LogoURL,
|
||||
DisplayExternalContributors: settings.DisplayExternalContributors,
|
||||
AuthenticationMethod: settings.AuthenticationMethod,
|
||||
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
|
||||
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
|
||||
|
|
|
@ -12,27 +12,20 @@ import (
|
|||
)
|
||||
|
||||
type settingsUpdatePayload struct {
|
||||
TemplatesURL string
|
||||
LogoURL string
|
||||
LogoURL *string
|
||||
BlackListedLabels []portainer.Pair
|
||||
DisplayExternalContributors bool
|
||||
AuthenticationMethod int
|
||||
LDAPSettings portainer.LDAPSettings
|
||||
AllowBindMountsForRegularUsers bool
|
||||
AllowPrivilegedModeForRegularUsers bool
|
||||
AuthenticationMethod *int
|
||||
LDAPSettings *portainer.LDAPSettings
|
||||
AllowBindMountsForRegularUsers *bool
|
||||
AllowPrivilegedModeForRegularUsers *bool
|
||||
SnapshotInterval *string
|
||||
}
|
||||
|
||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.TemplatesURL) || !govalidator.IsURL(payload.TemplatesURL) {
|
||||
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 {
|
||||
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)")
|
||||
}
|
||||
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 nil
|
||||
|
@ -46,17 +39,40 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
|||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
settings := &portainer.Settings{
|
||||
TemplatesURL: payload.TemplatesURL,
|
||||
LogoURL: payload.LogoURL,
|
||||
BlackListedLabels: payload.BlackListedLabels,
|
||||
DisplayExternalContributors: payload.DisplayExternalContributors,
|
||||
LDAPSettings: payload.LDAPSettings,
|
||||
AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers,
|
||||
AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers,
|
||||
settings, err := handler.SettingsService.Settings()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
|
||||
}
|
||||
|
||||
if payload.AuthenticationMethod != nil {
|
||||
settings.AuthenticationMethod = portainer.AuthenticationMethod(*payload.AuthenticationMethod)
|
||||
}
|
||||
|
||||
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)
|
||||
if tlsError != nil {
|
||||
return tlsError
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
type composeStackFromFileContentPayload struct {
|
||||
Name string
|
||||
StackFileContent string
|
||||
Env []portainer.Pair
|
||||
}
|
||||
|
||||
func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) error {
|
||||
|
@ -54,6 +55,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
|||
Type: portainer.DockerComposeStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||
Env: payload.Env,
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
|
@ -88,10 +90,12 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
|||
type composeStackFromGitRepositoryPayload struct {
|
||||
Name string
|
||||
RepositoryURL string
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
ComposeFilePathInRepository string
|
||||
Env []portainer.Pair
|
||||
}
|
||||
|
||||
func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
||||
|
@ -135,6 +139,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
|||
Type: portainer.DockerComposeStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFilePathInRepository,
|
||||
Env: payload.Env,
|
||||
}
|
||||
|
||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
|
@ -142,19 +147,21 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
|||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
}
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
if err != nil {
|
||||
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)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
|
@ -177,6 +184,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
|||
type composeStackFromFileUploadPayload struct {
|
||||
Name string
|
||||
StackFileContent []byte
|
||||
Env []portainer.Pair
|
||||
}
|
||||
|
||||
func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||
|
@ -192,6 +200,12 @@ func (payload *composeStackFromFileUploadPayload) Validate(r *http.Request) erro
|
|||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -220,6 +234,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
|||
Type: portainer.DockerComposeStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||
Env: payload.Env,
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
|
|
|
@ -97,6 +97,7 @@ type swarmStackFromGitRepositoryPayload struct {
|
|||
SwarmID string
|
||||
Env []portainer.Pair
|
||||
RepositoryURL string
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
|
@ -156,19 +157,21 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
|||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
}
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
if err != nil {
|
||||
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)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
|
|
|
@ -2,6 +2,7 @@ package stacks
|
|||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
|
@ -10,7 +11,7 @@ type cloneRepositoryParameters struct {
|
|||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
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 response.JSON(w, &stackFileResponse{StackFileContent: stackFileContent})
|
||||
return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)})
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
|
||||
type updateComposeStackPayload struct {
|
||||
StackFileContent string
|
||||
Env []portainer.Pair
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
stack.Env = payload.Env
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
_, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
"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 {
|
||||
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
|
|
|
@ -9,14 +9,10 @@ import (
|
|||
"github.com/portainer/portainer/http/security"
|
||||
)
|
||||
|
||||
const (
|
||||
containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json"
|
||||
)
|
||||
|
||||
// Handler represents an HTTP API handler for managing templates.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
SettingsService portainer.SettingsService
|
||||
TemplateService portainer.TemplateService
|
||||
}
|
||||
|
||||
// NewHandler returns a new instance of Handler.
|
||||
|
@ -25,6 +21,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
|||
Router: mux.NewRouter(),
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/portainer/http/error"
|
||||
"github.com/portainer/portainer/http/request"
|
||||
"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 {
|
||||
key, err := request.RetrieveQueryParameter(r, "key", false)
|
||||
templates, err := handler.TemplateService.Templates()
|
||||
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)
|
||||
if templateErr != nil {
|
||||
return templateErr
|
||||
}
|
||||
|
||||
resp, err := http.Get(templatesURL)
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates via the network", 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 &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
return response.Bytes(w, body, "application/json")
|
||||
}
|
||||
|
||||
func (handler *Handler) retrieveTemplateURLFromKey(key string) (string, *httperror.HandlerError) {
|
||||
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}
|
||||
filteredTemplates := security.FilterTemplates(templates, securityContext)
|
||||
|
||||
return response.JSON(w, filteredTemplates)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
}
|
||||
|
||||
_, err = handler.UserService.User(portainer.UserID(userID))
|
||||
user, err := handler.UserService.User(portainer.UserID(userID))
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
// +build !windows
|
||||
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"net"
|
||||
)
|
||||
|
||||
func createDial(scheme, host string) (net.Conn, error) {
|
||||
return net.Dial(scheme, host)
|
||||
}
|
|
@ -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 {
|
||||
r.Header.Del("Origin")
|
||||
|
||||
if params.nodeName != "" {
|
||||
if params.nodeName != "" || params.endpoint.Type == portainer.AgentOnDockerEnvironment {
|
||||
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 {
|
||||
dial, err := createDial(endpoint)
|
||||
dial, err := initDial(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -158,16 +158,15 @@ func hijackExecStartOperation(websocketConn *websocket.Conn, endpoint *portainer
|
|||
return nil
|
||||
}
|
||||
|
||||
func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
|
||||
func initDial(endpoint *portainer.Endpoint) (net.Conn, error) {
|
||||
url, err := url.Parse(endpoint.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var host string
|
||||
if url.Scheme == "tcp" {
|
||||
host = url.Host
|
||||
} else if url.Scheme == "unix" {
|
||||
host := url.Host
|
||||
|
||||
if url.Scheme == "unix" || url.Scheme == "npipe" {
|
||||
host = url.Path
|
||||
}
|
||||
|
||||
|
@ -176,10 +175,11 @@ func createDial(endpoint *portainer.Endpoint) (net.Conn, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -248,7 +248,7 @@ func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response
|
|||
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/swarm":
|
||||
return p.executeDockerRequest(request)
|
||||
return p.rewriteOperation(request, swarmInspectOperation)
|
||||
default:
|
||||
// assume /swarm/{action}
|
||||
return p.administratorOperation(request)
|
||||
|
|
|
@ -58,21 +58,6 @@ func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool
|
|||
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 {
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(u)
|
||||
transport := &proxyTransport{
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
||||
// unixSocketHandler represents a handler to proxy HTTP requests via a unix:// socket
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
|
@ -9,11 +8,11 @@ import (
|
|||
httperror "github.com/portainer/portainer/http/error"
|
||||
)
|
||||
|
||||
type socketProxy struct {
|
||||
type localProxy struct {
|
||||
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
|
||||
// use http.Transport RoundTrip to do the requests via the socket
|
||||
r.URL.Scheme = "http"
|
|
@ -51,8 +51,7 @@ func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *porta
|
|||
}
|
||||
return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false), nil
|
||||
}
|
||||
// Assume unix:// scheme
|
||||
return manager.proxyFactory.newDockerSocketProxy(endpointURL.Path), nil
|
||||
return manager.proxyFactory.newLocalProxy(endpointURL.Path), nil
|
||||
}
|
||||
|
||||
func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
|
|
|
@ -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 {
|
||||
return 0, err
|
||||
}
|
||||
if queryParameter == "" && optional {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.Atoi(queryParameter)
|
||||
}
|
||||
|
||||
|
|
|
@ -23,10 +23,3 @@ func Empty(rw http.ResponseWriter) *httperror.HandlerError {
|
|||
rw.WriteHeader(http.StatusNoContent)
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
|
|
|
@ -40,6 +40,8 @@ type Server struct {
|
|||
ComposeStackManager portainer.ComposeStackManager
|
||||
CryptoService portainer.CryptoService
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
JobScheduler portainer.JobScheduler
|
||||
Snapshotter portainer.Snapshotter
|
||||
DockerHubService portainer.DockerHubService
|
||||
EndpointService portainer.EndpointService
|
||||
EndpointGroupService portainer.EndpointGroupService
|
||||
|
@ -55,6 +57,7 @@ type Server struct {
|
|||
TagService portainer.TagService
|
||||
TeamService portainer.TeamService
|
||||
TeamMembershipService portainer.TeamMembershipService
|
||||
TemplateService portainer.TemplateService
|
||||
UserService portainer.UserService
|
||||
Handler *handler.Handler
|
||||
SSL bool
|
||||
|
@ -89,6 +92,8 @@ func (server *Server) Start() error {
|
|||
authHandler.JWTService = server.JWTService
|
||||
authHandler.LDAPService = server.LDAPService
|
||||
authHandler.SettingsService = server.SettingsService
|
||||
authHandler.TeamService = server.TeamService
|
||||
authHandler.TeamMembershipService = server.TeamMembershipService
|
||||
|
||||
var dockerHubHandler = dockerhub.NewHandler(requestBouncer)
|
||||
dockerHubHandler.DockerHubService = server.DockerHubService
|
||||
|
@ -98,6 +103,7 @@ func (server *Server) Start() error {
|
|||
endpointHandler.EndpointGroupService = server.EndpointGroupService
|
||||
endpointHandler.FileService = server.FileService
|
||||
endpointHandler.ProxyManager = proxyManager
|
||||
endpointHandler.Snapshotter = server.Snapshotter
|
||||
|
||||
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
|
||||
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
|
||||
|
@ -119,6 +125,7 @@ func (server *Server) Start() error {
|
|||
settingsHandler.SettingsService = server.SettingsService
|
||||
settingsHandler.LDAPService = server.LDAPService
|
||||
settingsHandler.FileService = server.FileService
|
||||
settingsHandler.JobScheduler = server.JobScheduler
|
||||
|
||||
var stackHandler = stacks.NewHandler(requestBouncer)
|
||||
stackHandler.FileService = server.FileService
|
||||
|
@ -143,7 +150,7 @@ func (server *Server) Start() error {
|
|||
var statusHandler = status.NewHandler(requestBouncer, server.Status)
|
||||
|
||||
var templatesHandler = templates.NewHandler(requestBouncer)
|
||||
templatesHandler.SettingsService = server.SettingsService
|
||||
templatesHandler.TemplateService = server.TemplateService
|
||||
|
||||
var uploadHandler = upload.NewHandler(requestBouncer)
|
||||
uploadHandler.FileService = server.FileService
|
||||
|
|
|
@ -102,12 +102,65 @@ func (*Service) AuthenticateUser(username, password string, settings *portainer.
|
|||
|
||||
err = connection.Bind(userDN, password)
|
||||
if err != nil {
|
||||
return err
|
||||
return portainer.ErrUnauthorized
|
||||
}
|
||||
|
||||
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
|
||||
// specified in the LDAPSettings.
|
||||
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/portainer/libcompose/config"
|
||||
"github.com/portainer/libcompose/docker"
|
||||
"github.com/portainer/libcompose/docker/client"
|
||||
"github.com/portainer/libcompose/docker/ctx"
|
||||
|
@ -26,28 +27,50 @@ 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)
|
||||
func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
clientFactory, err := client.NewDefaultFactory(client.Options{
|
||||
TLS: endpoint.TLSConfig.TLS,
|
||||
TLSVerify: endpoint.TLSConfig.TLSSkipVerify,
|
||||
Host: endpoint.URL,
|
||||
TLSCAFile: endpoint.TLSCACertPath,
|
||||
TLSCertFile: endpoint.TLSCertPath,
|
||||
TLSKeyFile: endpoint.TLSKeyPath,
|
||||
APIVersion: "1.24",
|
||||
})
|
||||
|
||||
clientFactory, err := createClient(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
env := make(map[string]string)
|
||||
for _, envvar := range stack.Env {
|
||||
env[envvar.Name] = envvar.Value
|
||||
}
|
||||
|
||||
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
proj, err := docker.NewProject(&ctx.Context{
|
||||
ConfigDir: manager.dataPath,
|
||||
Context: project.Context{
|
||||
ComposeFiles: []string{composeFilePath},
|
||||
EnvironmentLookup: &lookup.EnvfileLookup{
|
||||
Path: filepath.Join(stack.ProjectPath, ".env"),
|
||||
EnvironmentLookup: &lookup.ComposableEnvLookup{
|
||||
Lookups: []config.EnvironmentLookup{
|
||||
&lookup.EnvfileLookup{
|
||||
Path: filepath.Join(stack.ProjectPath, ".env"),
|
||||
},
|
||||
&lookup.MapLookup{
|
||||
Vars: env,
|
||||
},
|
||||
},
|
||||
},
|
||||
ProjectName: stack.Name,
|
||||
},
|
||||
|
@ -62,15 +85,7 @@ func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portain
|
|||
|
||||
// Down will shutdown a compose stack (equivalent of docker-compose down)
|
||||
func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
clientFactory, err := client.NewDefaultFactory(client.Options{
|
||||
TLS: endpoint.TLSConfig.TLS,
|
||||
TLSVerify: endpoint.TLSConfig.TLSSkipVerify,
|
||||
Host: endpoint.URL,
|
||||
TLSCAFile: endpoint.TLSCACertPath,
|
||||
TLSCertFile: endpoint.TLSCertPath,
|
||||
TLSKeyFile: endpoint.TLSKeyPath,
|
||||
APIVersion: "1.24",
|
||||
})
|
||||
clientFactory, err := createClient(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
183
api/portainer.go
183
api/portainer.go
|
@ -21,6 +21,7 @@ type (
|
|||
NoAuth *bool
|
||||
NoAnalytics *bool
|
||||
Templates *string
|
||||
TemplateFile *string
|
||||
TLS *bool
|
||||
TLSSkipVerify *bool
|
||||
TLSCacert *string
|
||||
|
@ -30,24 +31,29 @@ type (
|
|||
SSLCert *string
|
||||
SSLKey *string
|
||||
SyncInterval *string
|
||||
Snapshot *bool
|
||||
SnapshotInterval *string
|
||||
}
|
||||
|
||||
// Status represents the application status.
|
||||
Status struct {
|
||||
Authentication bool `json:"Authentication"`
|
||||
EndpointManagement bool `json:"EndpointManagement"`
|
||||
Snapshot bool `json:"Snapshot"`
|
||||
Analytics bool `json:"Analytics"`
|
||||
Version string `json:"Version"`
|
||||
}
|
||||
|
||||
// LDAPSettings represents the settings used to connect to a LDAP server.
|
||||
LDAPSettings struct {
|
||||
ReaderDN string `json:"ReaderDN"`
|
||||
Password string `json:"Password"`
|
||||
URL string `json:"URL"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
StartTLS bool `json:"StartTLS"`
|
||||
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
|
||||
ReaderDN string `json:"ReaderDN"`
|
||||
Password string `json:"Password"`
|
||||
URL string `json:"URL"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
StartTLS bool `json:"StartTLS"`
|
||||
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
|
||||
GroupSearchSettings []LDAPGroupSearchSettings `json:"GroupSearchSettings"`
|
||||
AutoCreateUsers bool `json:"AutoCreateUsers"`
|
||||
}
|
||||
|
||||
// TLSConfiguration represents a TLS configuration.
|
||||
|
@ -66,18 +72,27 @@ type (
|
|||
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 struct {
|
||||
TemplatesURL string `json:"TemplatesURL"`
|
||||
LogoURL string `json:"LogoURL"`
|
||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
||||
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||
SnapshotInterval string `json:"SnapshotInterval"`
|
||||
|
||||
// Deprecated fields
|
||||
DisplayDonationHeader bool
|
||||
DisplayDonationHeader bool
|
||||
DisplayExternalContributors bool
|
||||
TemplatesURL string
|
||||
}
|
||||
|
||||
// User represents a user account.
|
||||
|
@ -176,6 +191,9 @@ type (
|
|||
// EndpointType represents the type of an endpoint.
|
||||
EndpointType int
|
||||
|
||||
// EndpointStatus represents the status of an endpoint
|
||||
EndpointStatus int
|
||||
|
||||
// Endpoint represents a Docker endpoint with all the info required
|
||||
// to connect to it.
|
||||
Endpoint struct {
|
||||
|
@ -191,6 +209,8 @@ type (
|
|||
Extensions []EndpointExtension `json:"Extensions"`
|
||||
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
|
||||
Tags []string `json:"Tags"`
|
||||
Status EndpointStatus `json:"Status"`
|
||||
Snapshots []Snapshot `json:"Snapshots"`
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated in DBVersion == 4
|
||||
|
@ -208,6 +228,21 @@ type (
|
|||
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 int
|
||||
|
||||
|
@ -277,6 +312,79 @@ type (
|
|||
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 int
|
||||
|
||||
|
@ -345,6 +453,7 @@ type (
|
|||
UpdateEndpoint(ID EndpointID, endpoint *Endpoint) error
|
||||
DeleteEndpoint(ID EndpointID) error
|
||||
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
|
||||
GetNextIdentifier() int
|
||||
}
|
||||
|
||||
// EndpointGroupService represents a service for managing endpoint group data.
|
||||
|
@ -411,6 +520,15 @@ type (
|
|||
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 interface {
|
||||
Hash(data string) (string, error)
|
||||
|
@ -434,7 +552,7 @@ type (
|
|||
|
||||
// FileService represents a service for managing files.
|
||||
FileService interface {
|
||||
GetFileContent(filePath string) (string, error)
|
||||
GetFileContent(filePath string) ([]byte, error)
|
||||
Rename(oldPath, newPath string) error
|
||||
RemoveDirectory(directoryPath string) error
|
||||
StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error)
|
||||
|
@ -452,19 +570,28 @@ type (
|
|||
|
||||
// GitService represents a service for managing Git.
|
||||
GitService interface {
|
||||
ClonePublicRepository(repositoryURL, destination string) error
|
||||
ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error
|
||||
ClonePublicRepository(repositoryURL, referenceName string, destination string) error
|
||||
ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error
|
||||
}
|
||||
|
||||
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
|
||||
EndpointWatcher interface {
|
||||
WatchEndpointFile(endpointFilePath string) error
|
||||
// JobScheduler represents a service to run jobs on a periodic basis.
|
||||
JobScheduler interface {
|
||||
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 interface {
|
||||
AuthenticateUser(username, password string, settings *LDAPSettings) error
|
||||
TestConnectivity(settings *LDAPSettings) error
|
||||
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
|
||||
}
|
||||
|
||||
// SwarmStackManager represents a service to manage Swarm stacks.
|
||||
|
@ -484,11 +611,9 @@ type (
|
|||
|
||||
const (
|
||||
// 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 = 12
|
||||
// DefaultTemplatesURL represents the default URL for the templates definitions.
|
||||
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
|
||||
DBVersion = 13
|
||||
// PortainerAgentHeader represents the name of the header available in any agent response
|
||||
PortainerAgentHeader = "Portainer-Agent"
|
||||
// PortainerAgentTargetHeader represent the name of the header containing the target node name.
|
||||
|
@ -500,6 +625,8 @@ const (
|
|||
// PortainerAgentSignatureMessage represents the message used to create a digital signature
|
||||
// to be used when communicating with an agent
|
||||
PortainerAgentSignatureMessage = "Portainer-App"
|
||||
// SupportedDockerAPIVersion is the minimum Docker API version supported by Portainer.
|
||||
SupportedDockerAPIVersion = "1.24"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -582,3 +709,21 @@ const (
|
|||
// DockerComposeStack represents a stack managed via docker-compose
|
||||
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).
|
||||
|
||||
version: "1.18.1"
|
||||
version: "1.19.0"
|
||||
title: "Portainer API"
|
||||
contact:
|
||||
email: "info@portainer.io"
|
||||
|
@ -247,7 +247,8 @@ paths:
|
|||
- name: "URL"
|
||||
in: "formData"
|
||||
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"
|
||||
in: "formData"
|
||||
type: "string"
|
||||
|
@ -319,7 +320,7 @@ paths:
|
|||
summary: "Inspect an endpoint"
|
||||
description: |
|
||||
Retrieve details abount an endpoint.
|
||||
**Access policy**: administrator
|
||||
**Access policy**: restricted
|
||||
operationId: "EndpointInspect"
|
||||
produces:
|
||||
- "application/json"
|
||||
|
@ -2530,32 +2531,189 @@ paths:
|
|||
get:
|
||||
tags:
|
||||
- "templates"
|
||||
summary: "Retrieve App templates"
|
||||
summary: "List available templates"
|
||||
description: |
|
||||
Retrieve App templates.
|
||||
You can find more information about the format at http://portainer.readthedocs.io/en/stable/templates.html
|
||||
**Access policy**: authenticated
|
||||
List available templates.
|
||||
Administrator templates will not be listed for non-administrator users.
|
||||
**Access policy**: restricted
|
||||
operationId: "TemplateList"
|
||||
produces:
|
||||
- "application/json"
|
||||
parameters:
|
||||
- name: "key"
|
||||
in: "query"
|
||||
required: true
|
||||
description: "Templates key. Valid values are 'container' or 'linuxserver.io'."
|
||||
type: "string"
|
||||
responses:
|
||||
200:
|
||||
description: "Success"
|
||||
schema:
|
||||
$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:
|
||||
description: "Invalid request"
|
||||
schema:
|
||||
$ref: "#/definitions/GenericError"
|
||||
examples:
|
||||
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:
|
||||
description: "Server error"
|
||||
schema:
|
||||
|
@ -2658,7 +2816,7 @@ definitions:
|
|||
description: "Is analytics enabled"
|
||||
Version:
|
||||
type: "string"
|
||||
example: "1.18.1"
|
||||
example: "1.19.0"
|
||||
description: "Portainer API version"
|
||||
PublicSettingsInspectResponse:
|
||||
type: "object"
|
||||
|
@ -2739,6 +2897,21 @@ definitions:
|
|||
type: "string"
|
||||
example: "uid"
|
||||
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:
|
||||
type: "object"
|
||||
|
@ -2765,6 +2938,14 @@ definitions:
|
|||
type: "array"
|
||||
items:
|
||||
$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:
|
||||
type: "object"
|
||||
|
@ -3602,9 +3783,17 @@ definitions:
|
|||
type: "array"
|
||||
items:
|
||||
$ref: "#/definitions/Template"
|
||||
Template:
|
||||
TemplateCreateRequest:
|
||||
type: "object"
|
||||
required:
|
||||
- "type"
|
||||
- "title"
|
||||
- "description"
|
||||
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"
|
||||
|
@ -3613,14 +3802,354 @@ definitions:
|
|||
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"
|
||||
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:
|
||||
type: "string"
|
||||
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:
|
||||
type: "object"
|
||||
required:
|
||||
|
@ -3655,6 +4184,10 @@ definitions:
|
|||
type: "string"
|
||||
example: "https://github.com/openfaas/faas"
|
||||
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:
|
||||
type: "string"
|
||||
example: "docker-compose.yml"
|
||||
|
|
|
@ -15,6 +15,7 @@ angular.module('portainer', [
|
|||
'angular-json-tree',
|
||||
'angular-loading-bar',
|
||||
'angular-clipboard',
|
||||
'ngFileSaver',
|
||||
'luegg.directives',
|
||||
'portainer.templates',
|
||||
'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>
|
|
@ -0,0 +1,8 @@
|
|||
angular.module('portainer.agent').component('volumeBrowser', {
|
||||
templateUrl: 'app/agent/components/volume-browser/volumeBrowser.html',
|
||||
controller: 'VolumeBrowserController',
|
||||
bindings: {
|
||||
volumeId: '<',
|
||||
nodeName: '<'
|
||||
}
|
||||
});
|
|
@ -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>
|
|
@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
}]);
|
|
@ -5,6 +5,6 @@ angular.module('portainer.agent')
|
|||
endpointId: EndpointProvider.endpointID
|
||||
},
|
||||
{
|
||||
query: {method: 'GET', isArray: true}
|
||||
query: { method: 'GET', isArray: true }
|
||||
});
|
||||
}]);
|
||||
|
|
|
@ -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' }
|
||||
}
|
||||
});
|
||||
}]);
|
|
@ -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;
|
||||
}
|
|
@ -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">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="actionBar">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover table-filters">
|
||||
<table class="table table-hover table-filters nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.azure').component('containergroupsDatatable', {
|
|||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<',
|
||||
removeAction: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<containergroups-datatable
|
||||
title-text="Containers" title-icon="fa-server"
|
||||
dataset="containerGroups" table-key="containergroups"
|
||||
order-by="Name" show-text-filter="true"
|
||||
order-by="Name"
|
||||
remove-action="deleteAction"
|
||||
></containergroups-datatable>
|
||||
</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 = {
|
||||
name: 'docker.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 = {
|
||||
name: 'docker.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 = {
|
||||
name: 'docker.volumes.new',
|
||||
url: '/new',
|
||||
|
@ -441,6 +433,7 @@ angular.module('portainer.docker', ['portainer.app'])
|
|||
$stateRegistryProvider.register(images);
|
||||
$stateRegistryProvider.register(image);
|
||||
$stateRegistryProvider.register(imageBuild);
|
||||
$stateRegistryProvider.register(imageImport);
|
||||
$stateRegistryProvider.register(networks);
|
||||
$stateRegistryProvider.register(network);
|
||||
$stateRegistryProvider.register(networkCreation);
|
||||
|
@ -458,9 +451,8 @@ angular.module('portainer.docker', ['portainer.app'])
|
|||
$stateRegistryProvider.register(tasks);
|
||||
$stateRegistryProvider.register(task);
|
||||
$stateRegistryProvider.register(taskLogs);
|
||||
$stateRegistryProvider.register(templates);
|
||||
$stateRegistryProvider.register(templatesLinuxServer);
|
||||
$stateRegistryProvider.register(volumes);
|
||||
$stateRegistryProvider.register(volume);
|
||||
$stateRegistryProvider.register(volumeBrowse);
|
||||
$stateRegistryProvider.register(volumeCreation);
|
||||
}]);
|
||||
|
|
|
@ -5,11 +5,6 @@
|
|||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="actionBar">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('configsDatatable', {
|
|||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<',
|
||||
showOwnershipColumn: '<',
|
||||
removeAction: '<'
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
</form>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Network</th>
|
||||
|
|
|
@ -5,18 +5,13 @@
|
|||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th ng-repeat="header in $ctrl.headerset">
|
||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('containerProcessesDatatable', {
|
|||
headerset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<'
|
||||
reverseOrder: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -86,6 +86,7 @@ function ($state, ContainerService, ModalService, Notifications, HttpRequestHelp
|
|||
function removeSelectedContainers(containers, cleanVolumes) {
|
||||
var actionCount = containers.length;
|
||||
angular.forEach(containers, function (container) {
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(container.NodeName);
|
||||
ContainerService.remove(container, cleanVolumes)
|
||||
.then(function success() {
|
||||
Notifications.success('Container successfully removed', container.Names[0]);
|
||||
|
|
|
@ -6,8 +6,56 @@
|
|||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 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">
|
||||
<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 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>
|
||||
|
@ -59,12 +107,12 @@
|
|||
no-paused-items-selected="$ctrl.state.noPausedItemsSelected"
|
||||
show-add-action="$ctrl.showAddAction"
|
||||
></containers-datatable-actions>
|
||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover table-filters">
|
||||
<table class="table table-hover table-filters nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</a>
|
||||
</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')">
|
||||
State
|
||||
<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>
|
||||
</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
|
||||
</th>
|
||||
<th>
|
||||
<th ng-show="$ctrl.columnVisibility.columns.stack.display">
|
||||
<a ng-click="$ctrl.changeOrderBy('StackName')">
|
||||
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-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<th ng-show="$ctrl.columnVisibility.columns.image.display">
|
||||
<a ng-click="$ctrl.changeOrderBy('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-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Image' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</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')">
|
||||
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-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'IP' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th ng-if="$ctrl.showHostColumn">
|
||||
<th ng-if="$ctrl.showHostColumn" ng-show="$ctrl.columnVisibility.columns.host.display">
|
||||
<a ng-click="$ctrl.changeOrderBy('NodeName')">
|
||||
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-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'NodeName' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<th ng-show="$ctrl.columnVisibility.columns.ports.display">
|
||||
<a ng-click="$ctrl.changeOrderBy('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-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Ports' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</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')">
|
||||
Ownership
|
||||
<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>
|
||||
<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 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 }}">{{ item.Status }}</span>
|
||||
</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;">
|
||||
<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>
|
||||
|
@ -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>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ item.StackName ? item.StackName : '-' }}</td>
|
||||
<td><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
|
||||
<td>{{ item.IP ? item.IP : '-' }}</td>
|
||||
<td ng-if="$ctrl.showHostColumn">{{ item.NodeName ? item.NodeName : '-' }}</td>
|
||||
<td>
|
||||
<td ng-show="$ctrl.columnVisibility.columns.stack.display">{{ item.StackName ? item.StackName : '-' }}</td>
|
||||
<td ng-show="$ctrl.columnVisibility.columns.image.display"><a ui-sref="docker.images.image({ id: item.Image })">{{ item.Image | trimshasum }}</a></td>
|
||||
<td ng-show="$ctrl.columnVisibility.columns.created.display">
|
||||
{{item.Created | getisodatefromtimestamp}}
|
||||
</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">
|
||||
<i class="fa fa-external-link-alt" aria-hidden="true"></i> {{ p.public }}:{{ p.private }}
|
||||
</a>
|
||||
<span ng-if="item.Ports.length == 0" >-</span>
|
||||
</td>
|
||||
<td ng-if="$ctrl.showOwnershipColumn">
|
||||
<td ng-if="$ctrl.showOwnershipColumn" ng-show="$ctrl.columnVisibility.columns.ownership.display">
|
||||
<span>
|
||||
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
|
||||
{{ item.ResourceControl.Ownership ? item.ResourceControl.Ownership : item.ResourceControl.Ownership = 'public' }}
|
||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('containersDatatable', {
|
|||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<',
|
||||
showOwnershipColumn: '<',
|
||||
showHostColumn: '<',
|
||||
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.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
|
||||
this.state.orderBy = orderField;
|
||||
|
@ -93,13 +141,6 @@ function (PaginationService, DatatableService, EndpointProvider) {
|
|||
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) {
|
||||
var container = value;
|
||||
var filters = ctrl.filters;
|
||||
|
@ -181,6 +222,12 @@ function (PaginationService, DatatableService, EndpointProvider) {
|
|||
this.settings = storedSettings;
|
||||
}
|
||||
this.settings.open = false;
|
||||
|
||||
var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
|
||||
if (storedColumnVisibility !== null) {
|
||||
this.columnVisibility = storedColumnVisibility;
|
||||
}
|
||||
this.columnVisibility.state.open = false;
|
||||
};
|
||||
|
||||
function setDefaults(ctrl) {
|
||||
|
|
|
@ -5,18 +5,13 @@
|
|||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
|
|
@ -7,7 +7,6 @@ angular.module('portainer.docker').component('eventsDatatable', {
|
|||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<'
|
||||
reverseOrder: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -5,11 +5,6 @@
|
|||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="actionBar">
|
||||
<div class="btn-group">
|
||||
|
@ -28,13 +23,24 @@
|
|||
<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
|
||||
</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 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 table-hover table-filters">
|
||||
<table class="table table-hover table-filters nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<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)"/>
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</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>
|
||||
</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>{{ item.VirtualSize | humansize }}</td>
|
||||
<td>{{ item.Created | getisodatefromtimestamp }}</td>
|
||||
|
|
|
@ -8,9 +8,10 @@ angular.module('portainer.docker').component('imagesDatatable', {
|
|||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<',
|
||||
showHostColumn: '<',
|
||||
removeAction: '<',
|
||||
forceRemoveAction: '<'
|
||||
downloadAction: '<',
|
||||
forceRemoveAction: '<',
|
||||
exportInProgress: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -12,7 +12,7 @@ function (PaginationService, DatatableService) {
|
|||
selectedItemCount: 0,
|
||||
selectedItems: []
|
||||
};
|
||||
|
||||
|
||||
this.filters = {
|
||||
usage: {
|
||||
open: false,
|
||||
|
@ -52,23 +52,16 @@ function (PaginationService, DatatableService) {
|
|||
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) {
|
||||
var image = value;
|
||||
var filters = ctrl.filters;
|
||||
if ((image.ContainerCount === 0 && filters.usage.showUnusedImages)
|
||||
if ((image.ContainerCount === 0 && filters.usage.showUnusedImages)
|
||||
|| (image.ContainerCount !== 0 && filters.usage.showUsedImages)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
this.onUsageFilterChange = function() {
|
||||
var filters = this.filters.usage;
|
||||
var filtered = false;
|
||||
|
@ -87,7 +80,7 @@ function (PaginationService, DatatableService) {
|
|||
this.state.reverseOrder = storedOrder.reverse;
|
||||
this.state.orderBy = storedOrder.orderBy;
|
||||
}
|
||||
|
||||
|
||||
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
|
||||
if (storedFilters !== null) {
|
||||
this.filters = storedFilters;
|
||||
|
|
|
@ -5,11 +5,6 @@
|
|||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="actionBar">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('networksDatatable', {
|
|||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<',
|
||||
showOwnershipColumn: '<',
|
||||
showHostColumn: '<',
|
||||
removeAction: '<'
|
||||
|
|
|
@ -5,18 +5,13 @@
|
|||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
|
|
@ -7,7 +7,6 @@ angular.module('portainer.docker').component('nodeTasksDatatable', {
|
|||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<'
|
||||
reverseOrder: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -5,18 +5,13 @@
|
|||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('nodesDatatable', {
|
|||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<',
|
||||
showIpAddressColumn: '<',
|
||||
accessToNodeDetails: '<'
|
||||
}
|
||||
|
|
|
@ -5,11 +5,6 @@
|
|||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</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 class="actionBar">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
|
||||
<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 table-hover">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
|
|
@ -8,7 +8,6 @@ angular.module('portainer.docker').component('secretsDatatable', {
|
|||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
showTextFilter: '<',
|
||||
showOwnershipColumn: '<',
|
||||
removeAction: '<'
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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">
|
||||
<tr>
|
||||
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open" style="width: 10%;">
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue