diff --git a/.codefresh/codefresh_branch.yml b/.codefresh/codefresh_branch.yml index 4c5fb1222..0d6e52555 100644 --- a/.codefresh/codefresh_branch.yml +++ b/.codefresh/codefresh_branch.yml @@ -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` diff --git a/.codefresh/codefresh_pullrequest.yml b/.codefresh/codefresh_pullrequest.yml index e3e2652c0..d48b9870f 100644 --- a/.codefresh/codefresh_pullrequest.yml +++ b/.codefresh/codefresh_pullrequest.yml @@ -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` diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index e898d4ec2..a6a921acf 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -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 diff --git a/api/bolt/endpoint/endpoint.go b/api/bolt/endpoint/endpoint.go index 79672eb36..6dfff48df 100644 --- a/api/bolt/endpoint/endpoint.go +++ b/api/bolt/endpoint/endpoint.go @@ -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 { diff --git a/api/bolt/migrator/migrate_dbversion12.go b/api/bolt/migrator/migrate_dbversion12.go new file mode 100644 index 000000000..0d2bf0554 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion12.go @@ -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) +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 5e32366ff..9c61fc2c7 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -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) } diff --git a/api/bolt/template/template.go b/api/bolt/template/template.go new file mode 100644 index 000000000..d60f64c0e --- /dev/null +++ b/api/bolt/template/template.go @@ -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) +} diff --git a/api/cli/cli.go b/api/cli/cli.go index b9e00ca66..7c6febac2 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -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 +} diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 419e5fd81..1913e4915 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -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" ) diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 2bd909c22..e2ee01795 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -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" ) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 54b7ebb5b..470c5d136 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -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, diff --git a/api/cron/job_endpoint_snapshot.go b/api/cron/job_endpoint_snapshot.go new file mode 100644 index 000000000..ff7cc333b --- /dev/null +++ b/api/cron/job_endpoint_snapshot.go @@ -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) + } +} diff --git a/api/cron/endpoint_sync.go b/api/cron/job_endpoint_sync.go similarity index 81% rename from api/cron/endpoint_sync.go rename to api/cron/job_endpoint_sync.go index 8d55b4a0e..9fbf595f3 100644 --- a/api/cron/endpoint_sync.go +++ b/api/cron/job_endpoint_sync.go @@ -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) } diff --git a/api/cron/scheduler.go b/api/cron/scheduler.go new file mode 100644 index 000000000..9f65b6d5a --- /dev/null +++ b/api/cron/scheduler.go @@ -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() + } +} diff --git a/api/cron/watcher.go b/api/cron/watcher.go deleted file mode 100644 index 6b44ff5ce..000000000 --- a/api/cron/watcher.go +++ /dev/null @@ -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 -} diff --git a/api/docker/client.go b/api/docker/client.go new file mode 100644 index 000000000..af9f08c46 --- /dev/null +++ b/api/docker/client.go @@ -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 +} diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go new file mode 100644 index 000000000..5f37ad985 --- /dev/null +++ b/api/docker/snapshot.go @@ -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 +} diff --git a/api/docker/snapshotter.go b/api/docker/snapshotter.go new file mode 100644 index 000000000..b8f571d37 --- /dev/null +++ b/api/docker/snapshotter.go @@ -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) +} diff --git a/api/errors.go b/api/errors.go index 9e4eb70e7..37552f104 100644 --- a/api/errors.go +++ b/api/errors.go @@ -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. diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index 1e896971e..aa32bfe54 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -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 } diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 5bff04852..1f5ca322e 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -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 diff --git a/api/git/git.go b/api/git/git.go index 5e58363c7..add688b01 100644 --- a/api/git/git.go +++ b/api/git/git.go @@ -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 } diff --git a/api/http/client/client.go b/api/http/client/client.go index 338aed15d..29ebb7d88 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -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. diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index b146352a8..cf928357b 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -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 +} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index db47b82e2..87be85429 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -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. diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 7827068cb..a369194ab 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -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 diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go index 82d2cb7b9..e382b3f16 100644 --- a/api/http/handler/endpoints/endpoint_inspect.go +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -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) } diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go new file mode 100644 index 000000000..51a18293e --- /dev/null +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -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) +} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 6a593479c..fc1bc1e6c 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -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", diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 0850ec83e..58c020877 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -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. diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index ef76231f0..c2ee2a616 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -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, diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 1e854ec7f..827818fa7 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -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 diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 748b3c155..a343c96fa 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -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)) diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 05084a99d..87c94e6ef 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -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 diff --git a/api/http/handler/stacks/git.go b/api/http/handler/stacks/git.go index 1ac62a443..b3e2448f7 100644 --- a/api/http/handler/stacks/git.go +++ b/api/http/handler/stacks/git.go @@ -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) } diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index a0b644ba0..f8d25af8d 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -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)}) } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 6ffef9112..de1c560ae 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -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 { diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index b1b4fe867..eed7f6410 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -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 { diff --git a/api/http/handler/templates/handler.go b/api/http/handler/templates/handler.go index f4d0c6fcf..db65830c2 100644 --- a/api/http/handler/templates/handler.go +++ b/api/http/handler/templates/handler.go @@ -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 } diff --git a/api/http/handler/templates/template_create.go b/api/http/handler/templates/template_create.go new file mode 100644 index 000000000..f27cd494a --- /dev/null +++ b/api/http/handler/templates/template_create.go @@ -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) +} diff --git a/api/http/handler/templates/template_delete.go b/api/http/handler/templates/template_delete.go new file mode 100644 index 000000000..c23c2d237 --- /dev/null +++ b/api/http/handler/templates/template_delete.go @@ -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) +} diff --git a/api/http/handler/templates/template_inspect.go b/api/http/handler/templates/template_inspect.go new file mode 100644 index 000000000..6b1b4c6a1 --- /dev/null +++ b/api/http/handler/templates/template_inspect.go @@ -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) +} diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go index 188951201..7c31e1c10 100644 --- a/api/http/handler/templates/template_list.go +++ b/api/http/handler/templates/template_list.go @@ -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= +// 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) } diff --git a/api/http/handler/templates/template_update.go b/api/http/handler/templates/template_update.go new file mode 100644 index 000000000..2eff9701f --- /dev/null +++ b/api/http/handler/templates/template_update.go @@ -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) + } +} diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index c183df7a7..90a5b52cc 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -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} } diff --git a/api/http/handler/websocket/websocket_dial.go b/api/http/handler/websocket/websocket_dial.go new file mode 100644 index 000000000..d5c085787 --- /dev/null +++ b/api/http/handler/websocket/websocket_dial.go @@ -0,0 +1,11 @@ +// +build !windows + +package websocket + +import ( + "net" +) + +func createDial(scheme, host string) (net.Conn, error) { + return net.Dial(scheme, host) +} diff --git a/api/http/handler/websocket/websocket_dial_windows.go b/api/http/handler/websocket/websocket_dial_windows.go new file mode 100644 index 000000000..49a9afd28 --- /dev/null +++ b/api/http/handler/websocket/websocket_dial_windows.go @@ -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) +} diff --git a/api/http/handler/websocket/websocket_exec.go b/api/http/handler/websocket/websocket_exec.go index 6e46084a6..80c62b9d9 100644 --- a/api/http/handler/websocket/websocket_exec.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -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) { diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 9effb54d8..37e46c2dd 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -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) diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index f3c43510f..73593f836 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -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{ diff --git a/api/http/proxy/factory_local.go b/api/http/proxy/factory_local.go new file mode 100644 index 000000000..da154e03c --- /dev/null +++ b/api/http/proxy/factory_local.go @@ -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 +} diff --git a/api/http/proxy/factory_local_windows.go b/api/http/proxy/factory_local_windows.go new file mode 100644 index 000000000..5e98e47ac --- /dev/null +++ b/api/http/proxy/factory_local_windows.go @@ -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) + }, + } +} diff --git a/api/http/proxy/socket.go b/api/http/proxy/local.go similarity index 81% rename from api/http/proxy/socket.go rename to api/http/proxy/local.go index 58ccdec27..8a7f5842d 100644 --- a/api/http/proxy/socket.go +++ b/api/http/proxy/local.go @@ -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" diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 88acdf515..eb60a5dc0 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -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) { diff --git a/api/http/proxy/swarm.go b/api/http/proxy/swarm.go new file mode 100644 index 000000000..0048b3a38 --- /dev/null +++ b/api/http/proxy/swarm.go @@ -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) +} diff --git a/api/http/request/request.go b/api/http/request/request.go index 16ba4590b..ee56b0d05 100644 --- a/api/http/request/request.go +++ b/api/http/request/request.go @@ -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) } diff --git a/api/http/response/response.go b/api/http/response/response.go index 1334d4d7e..2451abd24 100644 --- a/api/http/response/response.go +++ b/api/http/response/response.go @@ -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 -} diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 0e00ab568..878a66689 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -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 { diff --git a/api/http/server.go b/api/http/server.go index 7569760dd..4babc109b 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -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 diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go index 7b72b8930..528a92e7f 100644 --- a/api/ldap/ldap.go +++ b/api/ldap/ldap.go @@ -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 { diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go index 5cf1264b6..2759f01cf 100644 --- a/api/libcompose/compose_stack.go +++ b/api/libcompose/compose_stack.go @@ -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 } diff --git a/api/portainer.go b/api/portainer.go index abb60df79..4e27cf940 100644 --- a/api/portainer.go +++ b/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 +) diff --git a/api/swagger.yaml b/api/swagger.yaml index 1172d1706..e8a562ff6 100644 --- a/api/swagger.yaml +++ b/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 custom 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 custom 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 custom 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" diff --git a/app/__module.js b/app/__module.js index e1cf659fa..00be9300d 100644 --- a/app/__module.js +++ b/app/__module.js @@ -15,6 +15,7 @@ angular.module('portainer', [ 'angular-json-tree', 'angular-loading-bar', 'angular-clipboard', + 'ngFileSaver', 'luegg.directives', 'portainer.templates', 'portainer.app', diff --git a/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js b/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js new file mode 100644 index 000000000..e3139974d --- /dev/null +++ b/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js @@ -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' + } +}); diff --git a/app/agent/components/volume-browser/volume-browser-datatable/volumeBrowserDatatable.html b/app/agent/components/volume-browser/volume-browser-datatable/volumeBrowserDatatable.html new file mode 100644 index 000000000..7599d7caf --- /dev/null +++ b/app/agent/components/volume-browser/volume-browser-datatable/volumeBrowserDatatable.html @@ -0,0 +1,90 @@ +
+ + +
+
+ {{ $ctrl.titleText }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Size + + + + + + Last modification + + + + + Actions +
+ Go to parent +
+ + + + + + + {{ item.Name }} + + + {{ item.Name }} + + {{ item.Size | humansize }} + {{ item.ModTime | getisodatefromtimestamp }} + + + Download + + + Rename + + + Delete + +
Loading...
No files found.
+
+
+
+
diff --git a/app/agent/components/volume-browser/volume-browser.js b/app/agent/components/volume-browser/volume-browser.js new file mode 100644 index 000000000..964803da0 --- /dev/null +++ b/app/agent/components/volume-browser/volume-browser.js @@ -0,0 +1,8 @@ +angular.module('portainer.agent').component('volumeBrowser', { + templateUrl: 'app/agent/components/volume-browser/volumeBrowser.html', + controller: 'VolumeBrowserController', + bindings: { + volumeId: '<', + nodeName: '<' + } +}); diff --git a/app/agent/components/volume-browser/volumeBrowser.html b/app/agent/components/volume-browser/volumeBrowser.html new file mode 100644 index 000000000..643d8c88b --- /dev/null +++ b/app/agent/components/volume-browser/volumeBrowser.html @@ -0,0 +1,5 @@ + diff --git a/app/agent/components/volume-browser/volumeBrowserController.js b/app/agent/components/volume-browser/volumeBrowserController.js new file mode 100644 index 000000000..2fa4426b9 --- /dev/null +++ b/app/agent/components/volume-browser/volumeBrowserController.js @@ -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'); + }); + }; + +}]); diff --git a/app/agent/rest/agent.js b/app/agent/rest/agent.js index 04d974780..a7800717c 100644 --- a/app/agent/rest/agent.js +++ b/app/agent/rest/agent.js @@ -5,6 +5,6 @@ angular.module('portainer.agent') endpointId: EndpointProvider.endpointID }, { - query: {method: 'GET', isArray: true} + query: { method: 'GET', isArray: true } }); }]); diff --git a/app/agent/rest/browse.js b/app/agent/rest/browse.js new file mode 100644 index 000000000..b4e8d53e0 --- /dev/null +++ b/app/agent/rest/browse.js @@ -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' } + } + }); +}]); diff --git a/app/agent/rest/response/browse.js b/app/agent/rest/response/browse.js new file mode 100644 index 000000000..7047777a6 --- /dev/null +++ b/app/agent/rest/response/browse.js @@ -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; +} diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js new file mode 100644 index 000000000..22b020494 --- /dev/null +++ b/app/agent/services/volumeBrowserService.js @@ -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; +}]); diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html index f1113aadb..8ba81dece 100644 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -5,11 +5,6 @@
{{ $ctrl.titleText }}
-
- - Search - -
-