diff --git a/api/cli/cli.go b/api/cli/cli.go
index 31ae297fe..c1792a29d 100644
--- a/api/cli/cli.go
+++ b/api/cli/cli.go
@@ -21,6 +21,7 @@ const (
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")
@@ -47,6 +48,8 @@ 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')),
@@ -95,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
}
@@ -156,3 +164,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 1c674e5ec..1913e4915 100644
--- a/api/cli/defaults.go
+++ b/api/cli/defaults.go
@@ -3,19 +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"
- defaultTemplateFile = "/templates.json"
+ 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 5f74911e6..e2ee01795 100644
--- a/api/cli/defaults_windows.go
+++ b/api/cli/defaults_windows.go
@@ -1,19 +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"
- defaultTemplateFile = "/templates.json"
+ 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 9809828c4..f2acf6615 100644
--- a/api/cmd/portainer/main.go
+++ b/api/cmd/portainer/main.go
@@ -9,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"
@@ -101,25 +102,37 @@ 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 initJobScheduler(endpointService portainer.EndpointService, clientFactory *docker.ClientFactory, flags *portainer.CLIFlags) (portainer.JobScheduler, error) {
+ jobScheduler := cron.NewJobScheduler(endpointService, clientFactory)
+
+ 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,
}
}
@@ -154,6 +167,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
},
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
+ SnapshotInterval: *flags.SnapshotInterval,
}
if *flags.Labels != nil {
@@ -283,6 +297,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://") {
@@ -322,6 +338,8 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: []string{},
+ Status: portainer.EndpointStatusUp,
+ Snapshots: []portainer.Snapshot{},
}
return endpointService.CreateEndpoint(endpoint)
@@ -366,9 +384,21 @@ func main() {
gitService := initGitService()
- authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
+ clientFactory := initClientFactory(digitalSignatureService)
- err := initKeyPair(fileService, digitalSignatureService)
+ jobScheduler, err := initJobScheduler(store.EndpointService, clientFactory, flags)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ jobScheduler.Start()
+
+ endpointManagement := true
+ if *flags.ExternalEndpoints != "" {
+ endpointManagement = false
+ }
+
+ err = initKeyPair(fileService, digitalSignatureService)
if err != nil {
log.Fatal(err)
}
@@ -395,7 +425,7 @@ func main() {
log.Fatal(err)
}
- applicationStatus := initStatus(authorizeEndpointMgmt, flags)
+ applicationStatus := initStatus(endpointManagement, *flags.Snapshot, flags)
err = initEndpoint(flags, store.EndpointService)
if err != nil {
@@ -443,7 +473,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,
@@ -464,6 +494,7 @@ func main() {
LDAPService: ldapService,
GitService: gitService,
SignatureService: digitalSignatureService,
+ JobScheduler: jobScheduler,
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..3847e6bbd
--- /dev/null
+++ b/api/cron/scheduler.go
@@ -0,0 +1,87 @@
+package cron
+
+import (
+ "log"
+
+ "github.com/portainer/portainer"
+ "github.com/portainer/portainer/docker"
+ "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, clientFactory *docker.ClientFactory) *JobScheduler {
+ return &JobScheduler{
+ cron: cron.New(),
+ endpointService: endpointService,
+ snapshotter: docker.NewSnapshotter(clientFactory),
+ }
+}
+
+// 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..4538f981e
--- /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://") {
+ return createUnixSocketClient(endpoint)
+ }
+ return createTCPClient(endpoint)
+}
+
+func createUnixSocketClient(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/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go
index 7827068cb..55f0ed879 100644
--- a/api/http/handler/endpoints/endpoint_create.go
+++ b/api/http/handler/endpoints/endpoint_create.go
@@ -177,6 +177,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)
@@ -213,6 +215,8 @@ 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)
@@ -253,6 +257,8 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload)
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: payload.Tags,
+ Status: portainer.EndpointStatusUp,
+ Snapshots: []portainer.Snapshot{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
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 c2ee2a616..690688079 100644
--- a/api/http/handler/settings/settings_public.go
+++ b/api/http/handler/settings/settings_public.go
@@ -13,6 +13,7 @@ type publicSettingsResponse struct {
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
+ SnapshotInterval string `json:"SnapshotInterval"`
}
// GET request on /api/settings/public
@@ -27,6 +28,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
AuthenticationMethod: settings.AuthenticationMethod,
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
+ SnapshotInterval: settings.SnapshotInterval,
}
return response.JSON(w, publicSettings)
diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go
index 51341658d..827818fa7 100644
--- a/api/http/handler/settings/settings_update.go
+++ b/api/http/handler/settings/settings_update.go
@@ -12,22 +12,20 @@ import (
)
type settingsUpdatePayload struct {
- LogoURL string
+ LogoURL *string
BlackListedLabels []portainer.Pair
- 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 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
@@ -41,15 +39,40 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
- settings := &portainer.Settings{
- LogoURL: payload.LogoURL,
- BlackListedLabels: payload.BlackListedLabels,
- 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/server.go b/api/http/server.go
index 53f438514..a6941d1f3 100644
--- a/api/http/server.go
+++ b/api/http/server.go
@@ -40,6 +40,7 @@ type Server struct {
ComposeStackManager portainer.ComposeStackManager
CryptoService portainer.CryptoService
SignatureService portainer.DigitalSignatureService
+ JobScheduler portainer.JobScheduler
DockerHubService portainer.DockerHubService
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
@@ -120,6 +121,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
diff --git a/api/portainer.go b/api/portainer.go
index 07172ca89..b4a9b4e5f 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -31,12 +31,15 @@ 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"`
}
@@ -75,6 +78,8 @@ type (
LDAPSettings LDAPSettings `json:"LDAPSettings"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
+ SnapshotInterval string `json:"SnapshotInterval"`
+
// Deprecated fields
DisplayDonationHeader bool
DisplayExternalContributors bool
@@ -177,6 +182,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 {
@@ -192,6 +200,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
@@ -209,6 +219,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
@@ -539,9 +564,17 @@ type (
ClonePrivateRepositoryWithBasicAuth(repositoryURL, 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.
@@ -581,6 +614,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 (
@@ -673,3 +708,11 @@ const (
// 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/app/docker/components/dockerSidebarContent/docker-sidebar-content.js b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js
index 6530a4bfc..325a3497d 100644
--- a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js
+++ b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js
@@ -4,9 +4,6 @@ angular.module('portainer.docker').component('dockerSidebarContent', {
'endpointApiVersion': '<',
'swarmManagement': '<',
'standaloneManagement': '<',
- 'adminAccess': '<',
- 'externalContributions': '<',
- 'sidebarToggledOn': '<',
- 'currentState': '<'
+ 'adminAccess': '<'
}
});
diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html
index 39513fef4..d93608e54 100644
--- a/app/docker/views/dashboard/dashboard.html
+++ b/app/docker/views/dashboard/dashboard.html
@@ -1,6 +1,6 @@
+ + Name + + + + | ++ + Group + + + + | ++ + Status + + + + | ++ + Type + + + + | ++ + Last snapshot + + + + | +
---|---|---|---|---|
+ {{ item.Name }} + | +{{ item.GroupName }} | ++ {{ item.Status === 1 ? 'up' : 'down' }} + | ++ + + {{ item.Type | endpointtypename }} + + | ++ + {{ item.Snapshots[0].Time | getisodatefromtimestamp }} + + - + | +
+ | ||||
Loading... | +||||
No endpoint available. | +
+ + You do not have access to any environment. Please contact your administrator. +
+ ++ + Endpoint snapshot is disabled. +
+ +