From 6fd5ddc80237e0d59df68977c0101eee07d385aa Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Sun, 9 Dec 2018 16:49:27 +1300 Subject: [PATCH] feat(extensions): introduce extension support (#2527) * wip * wip: missing repository & tags removal * feat(registry): private registry management * style(plugin-details): update view * wip * wip * wip * feat(plugins): add license info * feat(plugins): browse feature preview * feat(registry-configure): add the ability to configure registry management * style(app): update text in app * feat(plugins): add plugin version number * feat(plugins): wip plugin upgrade process * feat(plugins): wip plugin upgrade * feat(plugins): add the ability to update a plugin * feat(plugins): init plugins at startup time * feat(plugins): add the ability to remove a plugin * feat(plugins): update to latest plugin definitions * feat(plugins): introduce plugin-tooltip component * refactor(app): relocate plugin files to app/plugins * feat(plugins): introduce PluginDefinitionsURL constant * feat(plugins): update the flags used by the plugins * feat(plugins): wip * feat(plugins): display a label when a plugin has expired * wip * feat(registry-creation): update registry creation logic * refactor(registry-creation): change name/ids for inputs * feat(api): pass registry type to management configuration * feat(api): unstrip /v2 in regsitry proxy * docs(api): add TODO * feat(store): mockup-1 * feat(store): mockup 2 * feat(store): mockup 2 * feat(store): update mockup-2 * feat(app): add unauthenticated event check * update gruntfile * style(support): update support views * style(support): update product views * refactor(extensions): refactor plugins to extensions * feat(extensions): add a deal property * feat(extensions): introduce ExtensionManager * style(extensions): update extension details style * feat(extensions): display license/company when enabling extension * feat(extensions): update extensions views * feat(extensions): use ProductId defined in extension schema * style(app): remove padding left for form section title elements * style(support): use per host model * refactor(extensions): multiple refactors related to extensions mecanism * feat(extensions): update tls file path for registry extension * feat(extensions): update registry management configuration * feat(extensions): send license in header to extension proxy * fix(proxy): fix invalid default loopback address * feat(extensions): add header X-RegistryManagement-ForceNew for specific operations * feat(extensions): add the ability to display screenshots * feat(extensions): center screenshots * style(extensions): tune style * feat(extensions-details): open full screen image on click (#2517) * feat(extension-details): show magnifying glass on images * feat(extensions): support extension logo * feat(extensions): update support logos * refactor(lint): fix lint issues --- api/archive/zip.go | 48 ++++ api/bolt/datastore.go | 8 + api/bolt/extension/extension.go | 86 ++++++++ api/cmd/portainer/main.go | 25 +++ api/errors.go | 5 + api/exec/extension.go | 205 ++++++++++++++++++ api/filesystem/filesystem.go | 48 ++++ .../handler/endpointproxy/proxy_storidge.go | 6 +- api/http/handler/endpoints/endpoint_delete.go | 1 - .../endpoints/endpoint_extension_add.go | 2 + .../endpoints/endpoint_extension_remove.go | 2 + .../handler/extensions/extension_create.go | 79 +++++++ .../handler/extensions/extension_delete.go | 38 ++++ .../handler/extensions/extension_inspect.go | 59 +++++ api/http/handler/extensions/extension_list.go | 55 +++++ .../handler/extensions/extension_update.go | 56 +++++ api/http/handler/extensions/handler.go | 37 ++++ api/http/handler/handler.go | 4 + api/http/handler/registries/handler.go | 18 +- api/http/handler/registries/proxy.go | 78 +++++++ .../handler/registries/registry_configure.go | 137 ++++++++++++ .../handler/registries/registry_create.go | 5 + api/http/proxy/factory.go | 2 +- api/http/proxy/manager.go | 153 +++++++------ api/http/server.go | 11 + api/portainer.go | 100 ++++++++- app/__module.js | 1 + app/app.js | 6 +- app/constants.js | 1 + app/extensions/_module.js | 3 + app/extensions/registry-management/_module.js | 41 ++++ .../registryRepositoriesDatatable.html | 83 +++++++ .../registryRepositoriesDatatable.js | 13 ++ .../registriesRepositoryTagsDatatable.html | 112 ++++++++++ .../registriesRepositoryTagsDatatable.js | 14 ++ .../helpers/localRegistryHelper.js | 38 ++++ .../models/registryRepository.js | 4 + .../models/repositoryTag.js | 12 + .../registry-management/rest/catalog.js | 23 ++ .../registry-management/rest/manifest.js | 61 ++++++ .../registry-management/rest/tags.js | 10 + .../services/registryAPIService.js | 118 ++++++++++ .../configure/configureRegistryController.js | 66 ++++++ .../views/configure/configureregistry.html | 161 ++++++++++++++ .../repositories/edit/registryRepository.html | 88 ++++++++ .../edit/registryRepositoryController.js | 150 +++++++++++++ .../repositories/registryRepositories.html | 37 ++++ .../registryRepositoriesController.js | 33 +++ app/extensions/storidge/__module.js | 1 + app/portainer/__module.js | 42 +++- .../registriesDatatable.html | 6 + .../registriesDatatable.js | 3 +- .../extension-item/extension-item.js | 8 + .../extension-item/extensionItem.html | 46 ++++ .../extension-item/extensionItemController.js | 18 ++ .../extension-list/extension-list.js | 7 + .../extension-list/extensionList.html | 20 ++ .../extension-tooltip/extension-tooltip.html | 1 + .../extension-tooltip/extension-tooltip.js | 3 + .../registry-form-azure.html | 81 +++++++ .../registry-form-azure.js | 9 + .../registry-form-custom.html | 105 +++++++++ .../registry-form-custom.js | 9 + .../registry-form-quay.html | 48 ++++ .../registry-form-quay/registry-form-quay.js | 9 + .../product-list/product-item/product-item.js | 9 + .../product-item/productItem.html | 41 ++++ .../product-item/productItemController.js | 18 ++ .../components/product-list/product-list.js | 10 + .../components/product-list/productList.html | 21 ++ app/portainer/models/extension.js | 17 ++ app/portainer/models/registry.js | 42 ++++ app/portainer/models/status.js | 1 + app/portainer/rest/extension.js | 15 +- app/portainer/rest/legacyExtension.js | 12 + app/portainer/rest/registry.js | 3 +- .../services/api/extensionService.js | 66 +++++- .../services/api/legacyExtensionService.js | 21 ++ app/portainer/services/api/registryService.js | 20 +- app/portainer/services/fileUpload.js | 7 + ...onManager.js => legacyExtensionManager.js} | 9 +- app/portainer/services/modalService.js | 8 + .../views/extensions/extensions.html | 71 ++++++ .../views/extensions/extensionsController.js | 58 +++++ .../views/extensions/inspect/extension.html | 122 +++++++++++ .../extensions/inspect/extensionController.js | 63 ++++++ app/portainer/views/home/home.html | 15 +- app/portainer/views/home/homeController.js | 6 +- .../create/createRegistryController.js | 56 ++--- .../registries/create/createregistry.html | 113 +++------- .../views/registries/registries.html | 3 +- .../views/registries/registriesController.js | 8 +- app/portainer/views/sidebar/sidebar.html | 3 + .../views/support/product/product.html | 84 +++++++ .../support/product/productController.js | 14 ++ app/portainer/views/support/support.html | 52 ++--- .../views/support/supportController.js | 35 +++ assets/css/app.css | 5 + assets/images/support_1.png | Bin 0 -> 4733 bytes assets/images/support_2.png | Bin 0 -> 5753 bytes 100 files changed, 3519 insertions(+), 268 deletions(-) create mode 100644 api/archive/zip.go create mode 100644 api/bolt/extension/extension.go create mode 100644 api/exec/extension.go create mode 100644 api/http/handler/extensions/extension_create.go create mode 100644 api/http/handler/extensions/extension_delete.go create mode 100644 api/http/handler/extensions/extension_inspect.go create mode 100644 api/http/handler/extensions/extension_list.go create mode 100644 api/http/handler/extensions/extension_update.go create mode 100644 api/http/handler/extensions/handler.go create mode 100644 api/http/handler/registries/proxy.go create mode 100644 api/http/handler/registries/registry_configure.go create mode 100644 app/extensions/_module.js create mode 100644 app/extensions/registry-management/_module.js create mode 100644 app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html create mode 100644 app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js create mode 100644 app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html create mode 100644 app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js create mode 100644 app/extensions/registry-management/helpers/localRegistryHelper.js create mode 100644 app/extensions/registry-management/models/registryRepository.js create mode 100644 app/extensions/registry-management/models/repositoryTag.js create mode 100644 app/extensions/registry-management/rest/catalog.js create mode 100644 app/extensions/registry-management/rest/manifest.js create mode 100644 app/extensions/registry-management/rest/tags.js create mode 100644 app/extensions/registry-management/services/registryAPIService.js create mode 100644 app/extensions/registry-management/views/configure/configureRegistryController.js create mode 100644 app/extensions/registry-management/views/configure/configureregistry.html create mode 100644 app/extensions/registry-management/views/repositories/edit/registryRepository.html create mode 100644 app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js create mode 100644 app/extensions/registry-management/views/repositories/registryRepositories.html create mode 100644 app/extensions/registry-management/views/repositories/registryRepositoriesController.js create mode 100644 app/portainer/components/extension-list/extension-item/extension-item.js create mode 100644 app/portainer/components/extension-list/extension-item/extensionItem.html create mode 100644 app/portainer/components/extension-list/extension-item/extensionItemController.js create mode 100644 app/portainer/components/extension-list/extension-list.js create mode 100644 app/portainer/components/extension-list/extensionList.html create mode 100644 app/portainer/components/extension-tooltip/extension-tooltip.html create mode 100644 app/portainer/components/extension-tooltip/extension-tooltip.js create mode 100644 app/portainer/components/forms/registry-form-azure/registry-form-azure.html create mode 100644 app/portainer/components/forms/registry-form-azure/registry-form-azure.js create mode 100644 app/portainer/components/forms/registry-form-custom/registry-form-custom.html create mode 100644 app/portainer/components/forms/registry-form-custom/registry-form-custom.js create mode 100644 app/portainer/components/forms/registry-form-quay/registry-form-quay.html create mode 100644 app/portainer/components/forms/registry-form-quay/registry-form-quay.js create mode 100644 app/portainer/components/product-list/product-item/product-item.js create mode 100644 app/portainer/components/product-list/product-item/productItem.html create mode 100644 app/portainer/components/product-list/product-item/productItemController.js create mode 100644 app/portainer/components/product-list/product-list.js create mode 100644 app/portainer/components/product-list/productList.html create mode 100644 app/portainer/models/extension.js create mode 100644 app/portainer/rest/legacyExtension.js create mode 100644 app/portainer/services/api/legacyExtensionService.js rename app/portainer/services/{extensionManager.js => legacyExtensionManager.js} (86%) create mode 100644 app/portainer/views/extensions/extensions.html create mode 100644 app/portainer/views/extensions/extensionsController.js create mode 100644 app/portainer/views/extensions/inspect/extension.html create mode 100644 app/portainer/views/extensions/inspect/extensionController.js create mode 100644 app/portainer/views/support/product/product.html create mode 100644 app/portainer/views/support/product/productController.js create mode 100644 app/portainer/views/support/supportController.js create mode 100644 assets/images/support_1.png create mode 100644 assets/images/support_2.png diff --git a/api/archive/zip.go b/api/archive/zip.go new file mode 100644 index 000000000..1c46c9f10 --- /dev/null +++ b/api/archive/zip.go @@ -0,0 +1,48 @@ +package archive + +import ( + "archive/zip" + "bytes" + "io" + "io/ioutil" + "os" + "path/filepath" +) + +// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk +func UnzipArchive(archiveData []byte, dest string) error { + zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) + if err != nil { + return err + } + + for _, zipFile := range zipReader.File { + + f, err := zipFile.Open() + if err != nil { + return err + } + defer f.Close() + + data, err := ioutil.ReadAll(f) + if err != nil { + return err + } + + fpath := filepath.Join(dest, zipFile.Name) + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode()) + if err != nil { + return err + } + + _, err = io.Copy(outFile, bytes.NewReader(data)) + if err != nil { + return err + } + + outFile.Close() + } + + return nil +} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index ccce5576c..bc899c526 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -10,6 +10,7 @@ import ( "github.com/portainer/portainer/bolt/dockerhub" "github.com/portainer/portainer/bolt/endpoint" "github.com/portainer/portainer/bolt/endpointgroup" + "github.com/portainer/portainer/bolt/extension" "github.com/portainer/portainer/bolt/migrator" "github.com/portainer/portainer/bolt/registry" "github.com/portainer/portainer/bolt/resourcecontrol" @@ -39,6 +40,7 @@ type Store struct { DockerHubService *dockerhub.Service EndpointGroupService *endpointgroup.Service EndpointService *endpoint.Service + ExtensionService *extension.Service RegistryService *registry.Service ResourceControlService *resourcecontrol.Service SettingsService *settings.Service @@ -176,6 +178,12 @@ func (store *Store) initServices() error { } store.EndpointService = endpointService + extensionService, err := extension.NewService(store.db) + if err != nil { + return err + } + store.ExtensionService = extensionService + registryService, err := registry.NewService(store.db) if err != nil { return err diff --git a/api/bolt/extension/extension.go b/api/bolt/extension/extension.go new file mode 100644 index 000000000..e60963f97 --- /dev/null +++ b/api/bolt/extension/extension.go @@ -0,0 +1,86 @@ +package extension + +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 = "extension" +) + +// 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 +} + +// Extension returns a extension by ID +func (service *Service) Extension(ID portainer.ExtensionID) (*portainer.Extension, error) { + var extension portainer.Extension + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &extension) + if err != nil { + return nil, err + } + + return &extension, nil +} + +// Extensions return an array containing all the extensions. +func (service *Service) Extensions() ([]portainer.Extension, error) { + var extensions = make([]portainer.Extension, 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 extension portainer.Extension + err := internal.UnmarshalObject(v, &extension) + if err != nil { + return err + } + extensions = append(extensions, extension) + } + + return nil + }) + + return extensions, err +} + +// Persist persists a extension inside the database. +func (service *Service) Persist(extension *portainer.Extension) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data, err := internal.MarshalObject(extension) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(extension.ID)), data) + }) +} + +// DeleteExtension deletes a Extension. +func (service *Service) DeleteExtension(ID portainer.ExtensionID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index cd1106159..f4f366d5f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -471,6 +471,24 @@ func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobServ return docker.NewJobService(dockerClientFactory) } +func initExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) (portainer.ExtensionManager, error) { + extensionManager := exec.NewExtensionManager(fileService, extensionService) + + extensions, err := extensionService.Extensions() + if err != nil { + return nil, err + } + + for _, extension := range extensions { + err := extensionManager.EnableExtension(&extension, extension.License.LicenseKey) + if err != nil { + return nil, err + } + } + + return extensionManager, nil +} + func terminateIfNoAdminCreated(userService portainer.UserService) { timer1 := time.NewTimer(5 * time.Minute) <-timer1.C @@ -509,6 +527,11 @@ func main() { log.Fatal(err) } + extensionManager, err := initExtensionManager(fileService, store.ExtensionService) + if err != nil { + log.Fatal(err) + } + clientFactory := initClientFactory(digitalSignatureService) jobService := initJobService(clientFactory) @@ -619,6 +642,7 @@ func main() { TeamMembershipService: store.TeamMembershipService, EndpointService: store.EndpointService, EndpointGroupService: store.EndpointGroupService, + ExtensionService: store.ExtensionService, ResourceControlService: store.ResourceControlService, SettingsService: store.SettingsService, RegistryService: store.RegistryService, @@ -630,6 +654,7 @@ func main() { WebhookService: store.WebhookService, SwarmStackManager: swarmStackManager, ComposeStackManager: composeStackManager, + ExtensionManager: extensionManager, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, diff --git a/api/errors.go b/api/errors.go index 6aeec542e..ef11d5522 100644 --- a/api/errors.go +++ b/api/errors.go @@ -88,6 +88,11 @@ const ( ErrUndefinedTLSFileType = Error("Undefined TLS file type") ) +// Extension errors. +const ( + ErrExtensionAlreadyEnabled = Error("This extension is already enabled") +) + // Docker errors. const ( ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint") diff --git a/api/exec/extension.go b/api/exec/extension.go new file mode 100644 index 000000000..14dfba49e --- /dev/null +++ b/api/exec/extension.go @@ -0,0 +1,205 @@ +package exec + +import ( + "bytes" + "encoding/json" + "errors" + "os/exec" + "path" + "runtime" + "strconv" + "strings" + + "github.com/orcaman/concurrent-map" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" +) + +var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/" + +var extensionBinaryMap = map[portainer.ExtensionID]string{ + portainer.RegistryManagementExtension: "extension-registry-management", +} + +// ExtensionManager represents a service used to +// manage extension processes. +type ExtensionManager struct { + processes cmap.ConcurrentMap + fileService portainer.FileService + extensionService portainer.ExtensionService +} + +// NewExtensionManager returns a pointer to an ExtensionManager +func NewExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) *ExtensionManager { + return &ExtensionManager{ + processes: cmap.New(), + fileService: fileService, + extensionService: extensionService, + } +} + +func processKey(ID portainer.ExtensionID) string { + return strconv.Itoa(int(ID)) +} + +func buildExtensionURL(extension *portainer.Extension) string { + extensionURL := extensionDownloadBaseURL + extensionURL += extensionBinaryMap[extension.ID] + extensionURL += "-" + runtime.GOOS + "-" + runtime.GOARCH + extensionURL += "-" + extension.Version + extensionURL += ".zip" + return extensionURL +} + +func buildExtensionPath(binaryPath string, extension *portainer.Extension) string { + + extensionFilename := extensionBinaryMap[extension.ID] + extensionFilename += "-" + runtime.GOOS + "-" + runtime.GOARCH + extensionFilename += "-" + extension.Version + + extensionPath := path.Join( + binaryPath, + extensionFilename) + + return extensionPath +} + +// FetchExtensionDefinitions will fetch the list of available +// extension definitions from the official Portainer assets server +func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extension, error) { + extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30) + if err != nil { + return nil, err + } + + var extensions []portainer.Extension + err = json.Unmarshal(extensionData, &extensions) + if err != nil { + return nil, err + } + + return extensions, nil +} + +// EnableExtension will check for the existence of the extension binary on the filesystem +// first. If it does not exist, it will download it from the official Portainer assets server. +// After installing the binary on the filesystem, it will execute the binary in license check +// mode to validate the extension license. If the license is valid, it will then start +// the extension process and register it in the processes map. +func (manager *ExtensionManager) EnableExtension(extension *portainer.Extension, licenseKey string) error { + extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) + extensionBinaryExists, err := manager.fileService.FileExists(extensionBinaryPath) + if err != nil { + return err + } + + if !extensionBinaryExists { + err := manager.downloadExtension(extension) + if err != nil { + return err + } + } + + licenseDetails, err := validateLicense(extensionBinaryPath, licenseKey) + if err != nil { + return err + } + + extension.License = portainer.LicenseInformation{ + LicenseKey: licenseKey, + Company: licenseDetails[0], + Expiration: licenseDetails[1], + } + extension.Version = licenseDetails[2] + + return manager.startExtensionProcess(extension, extensionBinaryPath) +} + +// DisableExtension will retrieve the process associated to the extension +// from the processes map and kill the process. It will then remove the process +// from the processes map and remove the binary associated to the extension +// from the filesystem +func (manager *ExtensionManager) DisableExtension(extension *portainer.Extension) error { + process, ok := manager.processes.Get(processKey(extension.ID)) + if !ok { + return nil + } + + err := process.(*exec.Cmd).Process.Kill() + if err != nil { + return err + } + + manager.processes.Remove(processKey(extension.ID)) + + extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) + return manager.fileService.RemoveDirectory(extensionBinaryPath) +} + +// UpdateExtension will download the new extension binary from the official Portainer assets +// server, disable the previous extension via DisableExtension, trigger a license check +// and then start the extension process and add it to the processes map +func (manager *ExtensionManager) UpdateExtension(extension *portainer.Extension, version string) error { + oldVersion := extension.Version + + extension.Version = version + err := manager.downloadExtension(extension) + if err != nil { + return err + } + + extension.Version = oldVersion + err = manager.DisableExtension(extension) + if err != nil { + return err + } + + extension.Version = version + extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) + + licenseDetails, err := validateLicense(extensionBinaryPath, extension.License.LicenseKey) + if err != nil { + return err + } + + extension.Version = licenseDetails[2] + + return manager.startExtensionProcess(extension, extensionBinaryPath) +} + +func (manager *ExtensionManager) downloadExtension(extension *portainer.Extension) error { + extensionURL := buildExtensionURL(extension) + + data, err := client.Get(extensionURL, 30) + if err != nil { + return err + } + + return manager.fileService.ExtractExtensionArchive(data) +} + +func validateLicense(binaryPath, licenseKey string) ([]string, error) { + licenseCheckProcess := exec.Command(binaryPath, "-license", licenseKey, "-check") + cmdOutput := &bytes.Buffer{} + licenseCheckProcess.Stdout = cmdOutput + + err := licenseCheckProcess.Run() + if err != nil { + return nil, errors.New("Invalid extension license key") + } + + output := string(cmdOutput.Bytes()) + + return strings.Split(output, "|"), nil +} + +func (manager *ExtensionManager) startExtensionProcess(extension *portainer.Extension, binaryPath string) error { + extensionProcess := exec.Command(binaryPath, "-license", extension.License.LicenseKey) + err := extensionProcess.Start() + if err != nil { + return err + } + + manager.processes.Set(processKey(extension.ID), extensionProcess) + return nil +} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index acbb12db5..dcb397eac 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "github.com/portainer/portainer" + "github.com/portainer/portainer/archive" "io" "os" @@ -32,8 +33,13 @@ const ( PrivateKeyFile = "portainer.key" // PublicKeyFile represents the name on disk of the file containing the public key. PublicKeyFile = "portainer.pub" + // BinaryStorePath represents the subfolder where binaries are stored in the file store folder. + BinaryStorePath = "bin" // ScheduleStorePath represents the subfolder where schedule files are stored. ScheduleStorePath = "schedules" + // ExtensionRegistryManagementStorePath represents the subfolder where files related to the + // registry management extension are stored. + ExtensionRegistryManagementStorePath = "extensions" ) // Service represents a service for managing files and directories. @@ -65,9 +71,30 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) { return nil, err } + err = service.createDirectoryInStore(BinaryStorePath) + if err != nil { + return nil, err + } + return service, nil } +// GetBinaryFolder returns the full path to the binary store on the filesystem +func (service *Service) GetBinaryFolder() string { + return path.Join(service.fileStorePath, BinaryStorePath) +} + +// ExtractExtensionArchive extracts the content of an extension archive +// specified as raw data into the binary store on the filesystem +func (service *Service) ExtractExtensionArchive(data []byte) error { + err := archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath)) + if err != nil { + return err + } + + return nil +} + // RemoveDirectory removes a directory on the filesystem. func (service *Service) RemoveDirectory(directoryPath string) error { return os.RemoveAll(directoryPath) @@ -99,6 +126,27 @@ func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string return path.Join(service.fileStorePath, stackStorePath), nil } +// StoreRegistryManagementFileFromBytes creates a subfolder in the +// ExtensionRegistryManagementStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) { + extensionStorePath := path.Join(ExtensionRegistryManagementStorePath, folder) + err := service.createDirectoryInStore(extensionStorePath) + if err != nil { + return "", err + } + + file := path.Join(extensionStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(file, r) + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, file), nil +} + // StoreTLSFileFromBytes creates a folder in the TLSStorePath and stores a new file from bytes. // It returns the path to the newly created file. func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer.TLSFileType, data []byte) (string, error) { diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go index 86375f091..697d74ff5 100644 --- a/api/http/handler/endpointproxy/proxy_storidge.go +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -1,5 +1,7 @@ package endpointproxy +// TODO: legacy extension management + import ( "strconv" @@ -42,9 +44,9 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension) var proxy http.Handler - proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey) + proxy = handler.ProxyManager.GetLegacyExtensionProxy(proxyExtensionKey) if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL) + proxy, err = handler.ProxyManager.CreateLegacyExtensionProxy(proxyExtensionKey, storidgeExtension.URL) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy", err} } diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 865c2055b..3b34db01e 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -42,7 +42,6 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } handler.ProxyManager.DeleteProxy(string(endpointID)) - handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go index 9a9eebbda..7c2f69e3a 100644 --- a/api/http/handler/endpoints/endpoint_extension_add.go +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -1,5 +1,7 @@ package endpoints +// TODO: legacy extension management + import ( "net/http" diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go index 8b265dc6c..3f68955cc 100644 --- a/api/http/handler/endpoints/endpoint_extension_remove.go +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -1,5 +1,7 @@ package endpoints +// TODO: legacy extension management + import ( "net/http" diff --git a/api/http/handler/extensions/extension_create.go b/api/http/handler/extensions/extension_create.go new file mode 100644 index 000000000..b0ce72406 --- /dev/null +++ b/api/http/handler/extensions/extension_create.go @@ -0,0 +1,79 @@ +package extensions + +import ( + "net/http" + "strconv" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type extensionCreatePayload struct { + License string +} + +func (payload *extensionCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.License) { + return portainer.Error("Invalid license") + } + + return nil +} + +func (handler *Handler) extensionCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload extensionCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + extensionIdentifier, err := strconv.Atoi(string(payload.License[0])) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid license format", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + extensions, err := handler.ExtensionService.Extensions() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions status from the database", err} + } + + for _, existingExtension := range extensions { + if existingExtension.ID == extensionID { + return &httperror.HandlerError{http.StatusConflict, "Unable to enable extension", portainer.ErrExtensionAlreadyEnabled} + } + } + + extension := &portainer.Extension{ + ID: extensionID, + } + + extensionDefinitions, err := handler.ExtensionManager.FetchExtensionDefinitions() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err} + } + + for _, def := range extensionDefinitions { + if def.ID == extension.ID { + extension.Version = def.Version + break + } + } + + err = handler.ExtensionManager.EnableExtension(extension, payload.License) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to enable extension", err} + } + + extension.Enabled = true + + err = handler.ExtensionService.Persist(extension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/extensions/extension_delete.go b/api/http/handler/extensions/extension_delete.go new file mode 100644 index 000000000..be9d72bb3 --- /dev/null +++ b/api/http/handler/extensions/extension_delete.go @@ -0,0 +1,38 @@ +package extensions + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +// DELETE request on /api/extensions/:id +func (handler *Handler) extensionDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + extension, err := handler.ExtensionService.Extension(extensionID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + err = handler.ExtensionManager.DisableExtension(extension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete extension", err} + } + + err = handler.ExtensionService.DeleteExtension(extensionID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the extension from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/extensions/extension_inspect.go b/api/http/handler/extensions/extension_inspect.go new file mode 100644 index 000000000..ea5fa0e90 --- /dev/null +++ b/api/http/handler/extensions/extension_inspect.go @@ -0,0 +1,59 @@ +package extensions + +import ( + "encoding/json" + "net/http" + + "github.com/coreos/go-semver/semver" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" +) + +// GET request on /api/extensions/:id +func (handler *Handler) extensionInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err} + } + + var extensions []portainer.Extension + err = json.Unmarshal(extensionData, &extensions) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external extension definitions", err} + } + + var extension portainer.Extension + for _, p := range extensions { + if p.ID == extensionID { + extension = p + break + } + } + + storedExtension, err := handler.ExtensionService.Extension(extensionID) + if err == portainer.ErrObjectNotFound { + return response.JSON(w, extension) + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + extension.Enabled = storedExtension.Enabled + + extensionVer := semver.New(extension.Version) + pVer := semver.New(storedExtension.Version) + + if pVer.LessThan(*extensionVer) { + extension.UpdateAvailable = true + } + + return response.JSON(w, extension) +} diff --git a/api/http/handler/extensions/extension_list.go b/api/http/handler/extensions/extension_list.go new file mode 100644 index 000000000..8f6cacb77 --- /dev/null +++ b/api/http/handler/extensions/extension_list.go @@ -0,0 +1,55 @@ +package extensions + +import ( + "net/http" + + "github.com/coreos/go-semver/semver" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +// GET request on /api/extensions?store= +func (handler *Handler) extensionList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + storeDetails, _ := request.RetrieveBooleanQueryParameter(r, "store", true) + + extensions, err := handler.ExtensionService.Extensions() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions from the database", err} + } + + if storeDetails { + definitions, err := handler.ExtensionManager.FetchExtensionDefinitions() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err} + } + + for idx := range definitions { + associateExtensionData(&definitions[idx], extensions) + } + + extensions = definitions + } + + return response.JSON(w, extensions) +} + +func associateExtensionData(definition *portainer.Extension, extensions []portainer.Extension) { + for _, extension := range extensions { + if extension.ID == definition.ID { + + definition.Enabled = extension.Enabled + definition.License.Company = extension.License.Company + definition.License.Expiration = extension.License.Expiration + + definitionVersion := semver.New(definition.Version) + extensionVersion := semver.New(extension.Version) + if extensionVersion.LessThan(*definitionVersion) { + definition.UpdateAvailable = true + } + + break + } + } +} diff --git a/api/http/handler/extensions/extension_update.go b/api/http/handler/extensions/extension_update.go new file mode 100644 index 000000000..4e80ea1bc --- /dev/null +++ b/api/http/handler/extensions/extension_update.go @@ -0,0 +1,56 @@ +package extensions + +import ( + "net/http" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type extensionUpdatePayload struct { + Version string +} + +func (payload *extensionUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Version) { + return portainer.Error("Invalid extension version") + } + + return nil +} + +func (handler *Handler) extensionUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + var payload extensionUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + extension, err := handler.ExtensionService.Extension(extensionID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + err = handler.ExtensionManager.UpdateExtension(extension, payload.Version) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update extension", err} + } + + err = handler.ExtensionService.Persist(extension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/extensions/handler.go b/api/http/handler/extensions/handler.go new file mode 100644 index 000000000..b7ebbd06b --- /dev/null +++ b/api/http/handler/extensions/handler.go @@ -0,0 +1,37 @@ +package extensions + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle extension operations. +type Handler struct { + *mux.Router + ExtensionService portainer.ExtensionService + ExtensionManager portainer.ExtensionManager +} + +// NewHandler creates a handler to manage extension operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/extensions", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) + h.Handle("/extensions", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) + h.Handle("/extensions/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet) + h.Handle("/extensions/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete) + h.Handle("/extensions/{id}/update", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index f58a290aa..9cb3059df 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/portainer/http/handler/endpointgroups" "github.com/portainer/portainer/http/handler/endpointproxy" "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/extensions" "github.com/portainer/portainer/http/handler/file" "github.com/portainer/portainer/http/handler/motd" "github.com/portainer/portainer/http/handler/registries" @@ -37,6 +38,7 @@ type Handler struct { EndpointProxyHandler *endpointproxy.Handler FileHandler *file.Handler MOTDHandler *motd.Handler + ExtensionHandler *extensions.Handler RegistryHandler *registries.Handler ResourceControlHandler *resourcecontrols.Handler SettingsHandler *settings.Handler @@ -75,6 +77,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } case strings.HasPrefix(r.URL.Path, "/api/motd"): http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/extensions"): + http.StripPrefix("/api", h.ExtensionHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/registries"): http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/resource_controls"): diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 33a161932..a8f3ae24c 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -1,23 +1,27 @@ package registries import ( - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer" - "github.com/portainer/portainer/http/security" - "net/http" "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" ) func hideFields(registry *portainer.Registry) { registry.Password = "" + registry.ManagementConfiguration = nil } // Handler is the HTTP handler used to handle registry operations. type Handler struct { *mux.Router - RegistryService portainer.RegistryService + RegistryService portainer.RegistryService + ExtensionService portainer.ExtensionService + FileService portainer.FileService + ProxyManager *proxy.Manager } // NewHandler creates a handler to manage registry operations. @@ -36,8 +40,12 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) h.Handle("/registries/{id}/access", bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdateAccess))).Methods(http.MethodPut) + h.Handle("/registries/{id}/configure", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost) h.Handle("/registries/{id}", bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) + h.PathPrefix("/registries/{id}/v2").Handler( + bouncer.AdministratorAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) return h } diff --git a/api/http/handler/registries/proxy.go b/api/http/handler/registries/proxy.go new file mode 100644 index 000000000..b83bcc549 --- /dev/null +++ b/api/http/handler/registries/proxy.go @@ -0,0 +1,78 @@ +package registries + +import ( + "encoding/json" + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer" +) + +// request on /api/registries/:id/v2 +func (handler *Handler) proxyRequestsToRegistryAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetExtensionProxy(portainer.RegistryManagementExtension) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register registry proxy", err} + } + } + + managementConfiguration := registry.ManagementConfiguration + if managementConfiguration == nil { + managementConfiguration = createDefaultManagementConfiguration(registry) + } + + encodedConfiguration, err := json.Marshal(managementConfiguration) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to encode management configuration", err} + } + + id := strconv.Itoa(int(registryID)) + r.Header.Set("X-RegistryManagement-Key", id) + r.Header.Set("X-RegistryManagement-URI", registry.URL) + r.Header.Set("X-RegistryManagement-Config", string(encodedConfiguration)) + r.Header.Set("X-PortainerExtension-License", extension.License.LicenseKey) + + http.StripPrefix("/registries/"+id, proxy).ServeHTTP(w, r) + return nil +} + +func createDefaultManagementConfiguration(registry *portainer.Registry) *portainer.RegistryManagementConfiguration { + config := &portainer.RegistryManagementConfiguration{ + Type: registry.Type, + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + } + + if registry.Authentication { + config.Authentication = true + config.Username = registry.Username + config.Password = registry.Password + } + + return config +} diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go new file mode 100644 index 000000000..0f5569141 --- /dev/null +++ b/api/http/handler/registries/registry_configure.go @@ -0,0 +1,137 @@ +package registries + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type registryConfigurePayload struct { + Authentication bool + Username string + Password string + TLS bool + TLSSkipVerify bool + TLSCertFile []byte + TLSKeyFile []byte + TLSCACertFile []byte +} + +func (payload *registryConfigurePayload) Validate(r *http.Request) error { + useAuthentication, _ := request.RetrieveBooleanMultiPartFormValue(r, "Authentication", true) + payload.Authentication = useAuthentication + + if useAuthentication { + username, err := request.RetrieveMultiPartFormValue(r, "Username", false) + if err != nil { + return portainer.Error("Invalid username") + } + payload.Username = username + + password, _ := request.RetrieveMultiPartFormValue(r, "Password", true) + payload.Password = password + } + + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) + payload.TLS = useTLS + + skipTLSVerify, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true) + payload.TLSSkipVerify = skipTLSVerify + + if useTLS && !skipTLSVerify { + cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") + if err != nil { + return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCertFile = cert + + key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") + if err != nil { + return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly") + } + payload.TLSKeyFile = key + + ca, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") + if err != nil { + return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCACertFile = ca + } + + return nil +} + +// POST request on /api/registries/:id/configure +func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + payload := ®istryConfigurePayload{} + err = payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + registry.ManagementConfiguration = &portainer.RegistryManagementConfiguration{ + Type: registry.Type, + } + + if payload.Authentication { + registry.ManagementConfiguration.Authentication = true + registry.ManagementConfiguration.Username = payload.Username + if payload.Username == registry.Username && payload.Password == "" { + registry.ManagementConfiguration.Password = registry.Password + } else { + registry.ManagementConfiguration.Password = payload.Password + } + } + + if payload.TLS { + registry.ManagementConfiguration.TLSConfig = portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: payload.TLSSkipVerify, + } + + if !payload.TLSSkipVerify { + folder := strconv.Itoa(int(registry.ID)) + + certPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "cert.pem", payload.TLSCertFile) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err} + } + registry.ManagementConfiguration.TLSConfig.TLSCertPath = certPath + + keyPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "key.pem", payload.TLSKeyFile) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err} + } + registry.ManagementConfiguration.TLSConfig.TLSKeyPath = keyPath + + cacertPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "ca.pem", payload.TLSCACertFile) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err} + } + registry.ManagementConfiguration.TLSConfig.TLSCACertPath = cacertPath + } + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index ad4acb58a..082f61352 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -12,6 +12,7 @@ import ( type registryCreatePayload struct { Name string + Type int URL string Authentication bool Username string @@ -28,6 +29,9 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error { if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") } + if payload.Type != 1 && payload.Type != 2 && payload.Type != 3 { + return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)") + } return nil } @@ -49,6 +53,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * } registry := &portainer.Registry{ + Type: portainer.RegistryType(payload.Type), Name: payload.Name, URL: payload.URL, Authentication: payload.Authentication, diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 73593f836..90b5ad444 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -25,7 +25,7 @@ type proxyFactory struct { func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { u.Scheme = "http" - return newSingleHostReverseProxyWithHostHeader(u) + return httputil.NewSingleHostReverseProxy(u) } func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index eb60a5dc0..b1e2aa6f4 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -3,18 +3,25 @@ package proxy import ( "net/http" "net/url" - "strings" + "strconv" "github.com/orcaman/concurrent-map" "github.com/portainer/portainer" ) +// TODO: contain code related to legacy extension management + +var extensionPorts = map[portainer.ExtensionID]string{ + portainer.RegistryManagementExtension: "7001", +} + type ( // Manager represents a service used to manage Docker proxies. Manager struct { - proxyFactory *proxyFactory - proxies cmap.ConcurrentMap - extensionProxies cmap.ConcurrentMap + proxyFactory *proxyFactory + proxies cmap.ConcurrentMap + extensionProxies cmap.ConcurrentMap + legacyExtensionProxies cmap.ConcurrentMap } // ManagerParams represents the required parameters to create a new Manager instance. @@ -31,8 +38,9 @@ type ( // NewManager initializes a new proxy Service func NewManager(parameters *ManagerParams) *Manager { return &Manager{ - proxies: cmap.New(), - extensionProxies: cmap.New(), + proxies: cmap.New(), + extensionProxies: cmap.New(), + legacyExtensionProxies: cmap.New(), proxyFactory: &proxyFactory{ ResourceControlService: parameters.ResourceControlService, TeamMembershipService: parameters.TeamMembershipService, @@ -44,6 +52,83 @@ func NewManager(parameters *ManagerParams) *Manager { } } +// GetProxy returns the proxy associated to a key +func (manager *Manager) GetProxy(key string) http.Handler { + proxy, ok := manager.proxies.Get(key) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + proxy, err := manager.createProxy(endpoint) + if err != nil { + return nil, err + } + + manager.proxies.Set(string(endpoint.ID), proxy) + return proxy, nil +} + +// DeleteProxy deletes the proxy associated to a key +func (manager *Manager) DeleteProxy(key string) { + manager.proxies.Remove(key) +} + +// GetExtensionProxy returns an extension proxy associated to an extension identifier +func (manager *Manager) GetExtensionProxy(extensionID portainer.ExtensionID) http.Handler { + proxy, ok := manager.extensionProxies.Get(strconv.Itoa(int(extensionID))) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// CreateExtensionProxy creates a new HTTP reverse proxy for an extension and +// registers it in the extension map associated to the specified extension identifier +func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) { + address := "http://localhost:" + extensionPorts[extensionID] + + extensionURL, err := url.Parse(address) + if err != nil { + return nil, err + } + + proxy := manager.proxyFactory.newHTTPProxy(extensionURL) + manager.extensionProxies.Set(strconv.Itoa(int(extensionID)), proxy) + + return proxy, nil +} + +// DeleteExtensionProxy deletes the extension proxy associated to an extension identifier +func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) { + manager.extensionProxies.Remove(strconv.Itoa(int(extensionID))) +} + +// GetLegacyExtensionProxy returns a legacy extension proxy associated to a key +func (manager *Manager) GetLegacyExtensionProxy(key string) http.Handler { + proxy, ok := manager.legacyExtensionProxies.Get(key) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies. +func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { + extensionURL, err := url.Parse(extensionAPIURL) + if err != nil { + return nil, err + } + + proxy := manager.proxyFactory.newHTTPProxy(extensionURL) + manager.extensionProxies.Set(key, proxy) + return proxy, nil +} + func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration) (http.Handler, error) { if endpointURL.Scheme == "tcp" { if tlsConfig.TLS || tlsConfig.TLSSkipVerify { @@ -69,59 +154,3 @@ func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig) } } - -// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. -// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. -func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - proxy, err := manager.createProxy(endpoint) - if err != nil { - return nil, err - } - - manager.proxies.Set(string(endpoint.ID), proxy) - return proxy, nil -} - -// GetProxy returns the proxy associated to a key -func (manager *Manager) GetProxy(key string) http.Handler { - proxy, ok := manager.proxies.Get(key) - if !ok { - return nil - } - return proxy.(http.Handler) -} - -// DeleteProxy deletes the proxy associated to a key -func (manager *Manager) DeleteProxy(key string) { - manager.proxies.Remove(key) -} - -// CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies. -func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { - extensionURL, err := url.Parse(extensionAPIURL) - if err != nil { - return nil, err - } - - proxy := manager.proxyFactory.newHTTPProxy(extensionURL) - manager.extensionProxies.Set(key, proxy) - return proxy, nil -} - -// GetExtensionProxy returns the extension proxy associated to a key -func (manager *Manager) GetExtensionProxy(key string) http.Handler { - proxy, ok := manager.extensionProxies.Get(key) - if !ok { - return nil - } - return proxy.(http.Handler) -} - -// DeleteExtensionProxies deletes all the extension proxies associated to a key -func (manager *Manager) DeleteExtensionProxies(key string) { - for _, k := range manager.extensionProxies.Keys() { - if strings.Contains(k, key+"_") { - manager.extensionProxies.Remove(k) - } - } -} diff --git a/api/http/server.go b/api/http/server.go index 461a1f4ee..1220d94d1 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -11,6 +11,7 @@ import ( "github.com/portainer/portainer/http/handler/endpointgroups" "github.com/portainer/portainer/http/handler/endpointproxy" "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/extensions" "github.com/portainer/portainer/http/handler/file" "github.com/portainer/portainer/http/handler/motd" "github.com/portainer/portainer/http/handler/registries" @@ -41,6 +42,7 @@ type Server struct { AuthDisabled bool EndpointManagement bool Status *portainer.Status + ExtensionManager portainer.ExtensionManager ComposeStackManager portainer.ComposeStackManager CryptoService portainer.CryptoService SignatureService portainer.DigitalSignatureService @@ -53,6 +55,7 @@ type Server struct { GitService portainer.GitService JWTService portainer.JWTService LDAPService portainer.LDAPService + ExtensionService portainer.ExtensionService RegistryService portainer.RegistryService ResourceControlService portainer.ResourceControlService ScheduleService portainer.ScheduleService @@ -128,8 +131,15 @@ func (server *Server) Start() error { var motdHandler = motd.NewHandler(requestBouncer) + var extensionHandler = extensions.NewHandler(requestBouncer) + extensionHandler.ExtensionService = server.ExtensionService + extensionHandler.ExtensionManager = server.ExtensionManager + var registryHandler = registries.NewHandler(requestBouncer) registryHandler.RegistryService = server.RegistryService + registryHandler.ExtensionService = server.ExtensionService + registryHandler.FileService = server.FileService + registryHandler.ProxyManager = proxyManager var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) resourceControlHandler.ResourceControlService = server.ResourceControlService @@ -203,6 +213,7 @@ func (server *Server) Start() error { EndpointProxyHandler: endpointProxyHandler, FileHandler: fileHandler, MOTDHandler: motdHandler, + ExtensionHandler: extensionHandler, RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, SettingsHandler: settingsHandler, diff --git a/api/portainer.go b/api/portainer.go index ad7891c9d..698faae76 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -165,17 +165,32 @@ type ( // RegistryID represents a registry identifier RegistryID int + // RegistryType represents a type of registry + RegistryType int + // Registry represents a Docker registry with all the info required // to connect to it Registry struct { - ID RegistryID `json:"Id"` - Name string `json:"Name"` - URL string `json:"URL"` - Authentication bool `json:"Authentication"` - Username string `json:"Username"` - Password string `json:"Password,omitempty"` - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + ID RegistryID `json:"Id"` + Type RegistryType `json:"Type"` + Name string `json:"Name"` + URL string `json:"URL"` + Authentication bool `json:"Authentication"` + Username string `json:"Username"` + Password string `json:"Password,omitempty"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` + } + + // RegistryManagementConfiguration represents a configuration that can be used to query + // the registry API via the registry management extension. + RegistryManagementConfiguration struct { + Type RegistryType `json:"Type"` + Authentication bool `json:"Authentication"` + Username string `json:"Username"` + Password string `json:"Password"` + TLSConfig TLSConfiguration `json:"TLSConfig"` } // DockerHub represents all the required information to connect and use the @@ -323,7 +338,8 @@ type ( Labels []Pair `json:"Labels"` } - // EndpointExtension represents a extension associated to an endpoint + // EndpointExtension represents a deprecated form of Portainer extension + // TODO: legacy extension management EndpointExtension struct { Type EndpointExtensionType `json:"Type"` URL string `json:"URL"` @@ -459,6 +475,35 @@ type ( // It can be either a TLS CA file, a TLS certificate file or a TLS key file TLSFileType int + // ExtensionID represents a extension identifier + ExtensionID int + + // Extension represents a Portainer extension + Extension struct { + ID ExtensionID `json:"Id"` + Enabled bool `json:"Enabled"` + Name string `json:"Name,omitempty"` + ShortDescription string `json:"ShortDescription,omitempty"` + Description string `json:"Description,omitempty"` + Price string `json:"Price,omitempty"` + PriceDescription string `json:"PriceDescription,omitempty"` + Deal bool `json:"Deal,omitempty"` + Available bool `json:"Available,omitempty"` + License LicenseInformation `json:"License,omitempty"` + Version string `json:"Version"` + UpdateAvailable bool `json:"UpdateAvailable"` + ProductID int `json:"ProductId,omitempty"` + Images []string `json:"Images,omitempty"` + Logo string `json:"Logo,omitempty"` + } + + // LicenseInformation represents information about an extension license + LicenseInformation struct { + LicenseKey string `json:"LicenseKey,omitempty"` + Company string `json:"Company,omitempty"` + Expiration string `json:"Expiration,omitempty"` + } + // CLIService represents a service for managing CLI CLIService interface { ParseFlags(version string) (*CLIFlags, error) @@ -617,6 +662,14 @@ type ( DeleteTemplate(ID TemplateID) error } + // ExtensionService represents a service for managing extension data + ExtensionService interface { + Extension(ID ExtensionID) (*Extension, error) + Extensions() ([]Extension, error) + Persist(extension *Extension) error + DeleteExtension(ID ExtensionID) error + } + // CryptoService represents a service for encrypting/hashing data CryptoService interface { Hash(data string) (string, error) @@ -649,6 +702,7 @@ type ( DeleteTLSFiles(folder string) error GetStackProjectPath(stackIdentifier string) string StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) + StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) KeyPairFilesExist() (bool, error) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error LoadKeyPair() ([]byte, []byte, error) @@ -656,6 +710,8 @@ type ( FileExists(path string) (bool, error) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) GetScheduleFolder(identifier string) string + ExtractExtensionArchive(data []byte) error + GetBinaryFolder() string } // GitService represents a service for managing Git @@ -709,6 +765,14 @@ type ( JobService interface { ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error } + + // ExtensionManager represents a service used to manage extensions + ExtensionManager interface { + FetchExtensionDefinitions() ([]Extension, error) + EnableExtension(extension *Extension, licenseKey string) error + DisableExtension(extension *Extension) error + UpdateExtension(extension *Extension, version string) error + } ) const ( @@ -718,6 +782,8 @@ const ( DBVersion = 15 // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved MessageOfTheDayURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/motd.html" + // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved + ExtensionDefinitionsURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions.json" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" // PortainerAgentTargetHeader represent the name of the header containing the target node name @@ -838,6 +904,12 @@ const ( ServiceWebhook ) +const ( + _ ExtensionID = iota + // RegistryManagementExtension represents the registry management extension + RegistryManagementExtension +) + const ( _ JobType = iota // ScriptExecutionJobType is a non-system job used to execute a script against a list of @@ -849,3 +921,13 @@ const ( // an external definition store EndpointSyncJobType ) + +const ( + _ RegistryType = iota + // QuayRegistry represents a Quay.io registry + QuayRegistry + // AzureRegistry represents an ACR registry + AzureRegistry + // CustomRegistry represents a custom registry + CustomRegistry +) diff --git a/app/__module.js b/app/__module.js index 429e2acc0..0ffc3c7c6 100644 --- a/app/__module.js +++ b/app/__module.js @@ -22,6 +22,7 @@ angular.module('portainer', [ 'portainer.agent', 'portainer.azure', 'portainer.docker', + 'portainer.extensions', 'extension.storidge', 'rzModule', 'moment-picker' diff --git a/app/app.js b/app/app.js index 3c54bc3bf..021f4d58a 100644 --- a/app/app.js +++ b/app/app.js @@ -43,8 +43,10 @@ function initAuthentication(authManager, Authentication, $rootScope, $state) { // hitting a 401. We're using this instead of the usual combination of // authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector // to have more controls on which URL should trigger the unauthenticated state. - $rootScope.$on('unauthenticated', function () { - $state.go('portainer.auth', {error: 'Your session has expired'}); + $rootScope.$on('unauthenticated', function (event, data) { + if (!_.includes(data.config.url, '/v2/')) { + $state.go('portainer.auth', {error: 'Your session has expired'}); + } }); } diff --git a/app/constants.js b/app/constants.js index 0142d4c18..464089158 100644 --- a/app/constants.js +++ b/app/constants.js @@ -4,6 +4,7 @@ angular.module('portainer') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_MOTD', 'api/motd') +.constant('API_ENDPOINT_EXTENSIONS', 'api/extensions') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') .constant('API_ENDPOINT_SCHEDULES', 'api/schedules') diff --git a/app/extensions/_module.js b/app/extensions/_module.js new file mode 100644 index 000000000..5a936d2cf --- /dev/null +++ b/app/extensions/_module.js @@ -0,0 +1,3 @@ +angular.module('portainer.extensions', [ + 'portainer.extensions.registrymanagement' +]); diff --git a/app/extensions/registry-management/_module.js b/app/extensions/registry-management/_module.js new file mode 100644 index 000000000..44599be3b --- /dev/null +++ b/app/extensions/registry-management/_module.js @@ -0,0 +1,41 @@ +angular.module('portainer.extensions.registrymanagement', []) +.config(['$stateRegistryProvider', function ($stateRegistryProvider) { + 'use strict'; + + var registryConfiguration = { + name: 'portainer.registries.registry.configure', + url: '/configure', + views: { + 'content@': { + templateUrl: 'app/extensions/registry-management/views/configure/configureregistry.html', + controller: 'ConfigureRegistryController' + } + } + }; + + var registryRepositories = { + name: 'portainer.registries.registry.repositories', + url: '/repositories', + views: { + 'content@': { + templateUrl: 'app/extensions/registry-management/views/repositories/registryRepositories.html', + controller: 'RegistryRepositoriesController' + } + } + }; + + var registryRepositoryTags = { + name: 'portainer.registries.registry.repository', + url: '/:repository', + views: { + 'content@': { + templateUrl: 'app/extensions/registry-management/views/repositories/edit/registryRepository.html', + controller: 'RegistryRepositoryController' + } + } + }; + + $stateRegistryProvider.register(registryConfiguration); + $stateRegistryProvider.register(registryRepositories); + $stateRegistryProvider.register(registryRepositoryTags); +}]); diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html new file mode 100644 index 000000000..c416605a3 --- /dev/null +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html @@ -0,0 +1,83 @@ +
+ + +
+
+ {{ $ctrl.titleText }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + Repository + + + + + + Tags count + + + +
+ + + + + {{ item.Name }} + {{ item.TagsCount }}
Loading...
No repository available.
+
+ +
+
+
\ No newline at end of file diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js new file mode 100644 index 000000000..a8cd8a27a --- /dev/null +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.extensions.registrymanagement').component('registryRepositoriesDatatable', { + templateUrl: 'app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<' + } +}); diff --git a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html new file mode 100644 index 000000000..20d127d38 --- /dev/null +++ b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html @@ -0,0 +1,112 @@ +
+ + +
+
+ {{ + $ctrl.titleText }} +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + Os/Architecture + + Image ID + + + + + + Size + + + + Actions
+ + + + + {{ item.Name }} + {{ item.Os }}/{{ item.Architecture }}{{ item.ImageId | truncate:40 }}{{ item.Size | humansize }} + + + Retag + + + + + + + +
Loading...
No tag available.
+
+ +
+
+
\ No newline at end of file diff --git a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js new file mode 100644 index 000000000..5c18c6a1c --- /dev/null +++ b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js @@ -0,0 +1,14 @@ +angular.module('portainer.extensions.registrymanagement').component('registriesRepositoryTagsDatatable', { + templateUrl: 'app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<', + retagAction: '<' + } +}); diff --git a/app/extensions/registry-management/helpers/localRegistryHelper.js b/app/extensions/registry-management/helpers/localRegistryHelper.js new file mode 100644 index 000000000..b4a0bd22a --- /dev/null +++ b/app/extensions/registry-management/helpers/localRegistryHelper.js @@ -0,0 +1,38 @@ +angular.module('portainer.extensions.registrymanagement') + .factory('RegistryV2Helper', [function RegistryV2HelperFactory() { + 'use strict'; + + var helper = {}; + + function historyRawToParsed(rawHistory) { + var history = []; + for (var i = 0; i < rawHistory.length; i++) { + var item = rawHistory[i]; + history.push(angular.fromJson(item.v1Compatibility)); + } + return history; + } + + helper.manifestsToTag = function (manifests) { + var v1 = manifests.v1; + var v2 = manifests.v2; + + var history = historyRawToParsed(v1.history); + var imageId = history[0].id; + var name = v1.tag; + var os = history[0].os; + var arch = v1.architecture; + var size = v2.layers.reduce(function (a, b) { + return { + size: a.size + b.size + }; + }).size; + var digest = v2.digest; + var repositoryName = v1.name; + var fsLayers = v1.fsLayers; + + return new RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, v2); + }; + + return helper; + }]); diff --git a/app/extensions/registry-management/models/registryRepository.js b/app/extensions/registry-management/models/registryRepository.js new file mode 100644 index 000000000..d3f7f02a7 --- /dev/null +++ b/app/extensions/registry-management/models/registryRepository.js @@ -0,0 +1,4 @@ +function RegistryRepositoryViewModel(data) { + this.Name = data.name; + this.TagsCount = data.tags.length; +} \ No newline at end of file diff --git a/app/extensions/registry-management/models/repositoryTag.js b/app/extensions/registry-management/models/repositoryTag.js new file mode 100644 index 000000000..1139bbf13 --- /dev/null +++ b/app/extensions/registry-management/models/repositoryTag.js @@ -0,0 +1,12 @@ +function RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, manifestv2) { + this.Name = name; + this.ImageId = imageId; + this.Os = os; + this.Architecture = arch; + this.Size = size; + this.Digest = digest; + this.RepositoryName = repositoryName; + this.FsLayers = fsLayers; + this.History = history; + this.ManifestV2 = manifestv2; +} \ No newline at end of file diff --git a/app/extensions/registry-management/rest/catalog.js b/app/extensions/registry-management/rest/catalog.js new file mode 100644 index 000000000..1d6f24be5 --- /dev/null +++ b/app/extensions/registry-management/rest/catalog.js @@ -0,0 +1,23 @@ +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryCatalog', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryCatalogFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:action', {}, + { + get: { + method: 'GET', + params: { id: '@id', action: '_catalog' } + }, + ping: { + method: 'GET', + params: { id: '@id' }, timeout: 3500 + }, + pingWithForceNew: { + method: 'GET', + params: { id: '@id' }, timeout: 3500, + headers: { 'X-RegistryManagement-ForceNew': '1' } + } + }, + { + stripTrailingSlashes: false + }); +}]); diff --git a/app/extensions/registry-management/rest/manifest.js b/app/extensions/registry-management/rest/manifest.js new file mode 100644 index 000000000..475a524e6 --- /dev/null +++ b/app/extensions/registry-management/rest/manifest.js @@ -0,0 +1,61 @@ +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryManifests', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryManifestsFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/manifests/:tag', {}, { + get: { + method: 'GET', + params: { + id: '@id', + repository: '@repository', + tag: '@tag' + }, + headers: { + 'Cache-Control': 'no-cache' + }, + transformResponse: function (data, headers) { + var response = angular.fromJson(data); + response.digest = headers('docker-content-digest'); + return response; + } + }, + getV2: { + method: 'GET', + params: { + id: '@id', + repository: '@repository', + tag: '@tag' + }, + headers: { + 'Accept': 'application/vnd.docker.distribution.manifest.v2+json', + 'Cache-Control': 'no-cache' + }, + transformResponse: function (data, headers) { + var response = angular.fromJson(data); + response.digest = headers('docker-content-digest'); + return response; + } + }, + put: { + method: 'PUT', + params: { + id: '@id', + repository: '@repository', + tag: '@tag' + }, + headers: { + 'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json' + }, + transformRequest: function (data) { + return angular.toJson(data, 3); + } + }, + delete: { + method: 'DELETE', + params: { + id: '@id', + repository: '@repository', + tag: '@tag' + } + } + }); +}]); diff --git a/app/extensions/registry-management/rest/tags.js b/app/extensions/registry-management/rest/tags.js new file mode 100644 index 000000000..c880305f8 --- /dev/null +++ b/app/extensions/registry-management/rest/tags.js @@ -0,0 +1,10 @@ +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryTags', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryTagsFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/tags/list', {}, { + get: { + method: 'GET', + params: { id: '@id', repository: '@repository' } + } + }); +}]); diff --git a/app/extensions/registry-management/services/registryAPIService.js b/app/extensions/registry-management/services/registryAPIService.js new file mode 100644 index 000000000..b766ca568 --- /dev/null +++ b/app/extensions/registry-management/services/registryAPIService.js @@ -0,0 +1,118 @@ +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryV2Service', ['$q', 'RegistryCatalog', 'RegistryTags', 'RegistryManifests', 'RegistryV2Helper', +function RegistryV2ServiceFactory($q, RegistryCatalog, RegistryTags, RegistryManifests, RegistryV2Helper) { + 'use strict'; + var service = {}; + + service.ping = function(id, forceNewConfig) { + if (forceNewConfig) { + return RegistryCatalog.pingWithForceNew({ id: id }).$promise; + } + return RegistryCatalog.ping({ id: id }).$promise; + }; + + service.repositories = function (id) { + var deferred = $q.defer(); + + RegistryCatalog.get({ + id: id + }).$promise + .then(function success(data) { + var promises = []; + for (var i = 0; i < data.repositories.length; i++) { + var repository = data.repositories[i]; + promises.push(RegistryTags.get({ + id: id, + repository: repository + }).$promise); + } + return $q.all(promises); + }) + .then(function success(data) { + var repositories = data.map(function (item) { + if (!item.tags) { + return; + } + return new RegistryRepositoryViewModel(item); + }); + repositories = _.without(repositories, undefined); + deferred.resolve(repositories); + }) + .catch(function error(err) { + deferred.reject({ + msg: 'Unable to retrieve repositories', + err: err + }); + }); + + return deferred.promise; + }; + + service.tags = function (id, repository) { + var deferred = $q.defer(); + + RegistryTags.get({ + id: id, + repository: repository + }).$promise + .then(function succes(data) { + deferred.resolve(data.tags); + }).catch(function error(err) { + deferred.reject({ + msg: 'Unable to retrieve tags', + err: err + }); + }); + + return deferred.promise; + }; + + service.tag = function (id, repository, tag) { + var deferred = $q.defer(); + + var promises = { + v1: RegistryManifests.get({ + id: id, + repository: repository, + tag: tag + }).$promise, + v2: RegistryManifests.getV2({ + id: id, + repository: repository, + tag: tag + }).$promise + }; + $q.all(promises) + .then(function success(data) { + var tag = RegistryV2Helper.manifestsToTag(data); + deferred.resolve(tag); + }).catch(function error(err) { + deferred.reject({ + msg: 'Unable to retrieve tag ' + tag, + err: err + }); + }); + + return deferred.promise; + }; + + service.addTag = function (id, repository, tag, manifest) { + delete manifest.digest; + return RegistryManifests.put({ + id: id, + repository: repository, + tag: tag + }, manifest).$promise; + }; + + service.deleteManifest = function (id, repository, digest) { + return RegistryManifests.delete({ + id: id, + repository: repository, + tag: digest + }).$promise; + }; + + return service; +} +]); diff --git a/app/extensions/registry-management/views/configure/configureRegistryController.js b/app/extensions/registry-management/views/configure/configureRegistryController.js new file mode 100644 index 000000000..bbed8be30 --- /dev/null +++ b/app/extensions/registry-management/views/configure/configureRegistryController.js @@ -0,0 +1,66 @@ +angular.module('portainer.extensions.registrymanagement') +.controller('ConfigureRegistryController', ['$scope', '$state', '$transition$', 'RegistryService', 'RegistryV2Service', 'Notifications', +function ($scope, $state, $transition$, RegistryService, RegistryV2Service, Notifications) { + + $scope.state = { + testInProgress: false, + updateInProgress: false, + validConfiguration : false + }; + + $scope.testConfiguration = testConfiguration; + $scope.updateConfiguration = updateConfiguration; + + function testConfiguration() { + $scope.state.testInProgress = true; + + RegistryService.configureRegistry($scope.registry.Id, $scope.model) + .then(function success() { + return RegistryV2Service.ping($scope.registry.Id, true); + }) + .then(function success() { + Notifications.success('Success', 'Valid management configuration'); + $scope.state.validConfiguration = true; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Invalid management configuration'); + }) + .finally(function final() { + $scope.state.testInProgress = false; + }); + } + + function updateConfiguration() { + $scope.state.updateInProgress = true; + + RegistryService.configureRegistry($scope.registry.Id, $scope.model) + .then(function success() { + Notifications.success('Success', 'Registry management configuration updated'); + $state.go('portainer.registries.registry.repositories', { id: $scope.registry.Id }, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update registry management configuration'); + }) + .finally(function final() { + $scope.state.updateInProgress = false; + }); + } + + function initView() { + var registryId = $transition$.params().id; + + RegistryService.registry(registryId) + .then(function success(data) { + var registry = data; + var model = new RegistryManagementConfigurationDefaultModel(registry); + + $scope.registry = registry; + $scope.model = model; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve registry details'); + }); + } + + initView(); +}]); diff --git a/app/extensions/registry-management/views/configure/configureregistry.html b/app/extensions/registry-management/views/configure/configureregistry.html new file mode 100644 index 000000000..48574dc41 --- /dev/null +++ b/app/extensions/registry-management/views/configure/configureregistry.html @@ -0,0 +1,161 @@ + + + + Registries > {{ registry.Name }} > Management configuration + + + +
+
+ + +
+
+ Information +
+
+ + The following configuration will be used to access this registry API to provide Portainer management features. + +
+
+ Registry details +
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Required TLS files +
+ +
+ +
+ +
+ + + {{ model.TLSCACertFile.name }} + + + +
+
+ + +
+ +
+ + + {{ model.TLSCertFile.name }} + + + +
+
+ + +
+ +
+ + + {{ model.TLSKeyFile.name }} + + + +
+
+ +
+
+ +
+ Actions +
+
+
+ + +
+
+
+
+
+
+
diff --git a/app/extensions/registry-management/views/repositories/edit/registryRepository.html b/app/extensions/registry-management/views/repositories/edit/registryRepository.html new file mode 100644 index 000000000..bb94f3247 --- /dev/null +++ b/app/extensions/registry-management/views/repositories/edit/registryRepository.html @@ -0,0 +1,88 @@ + + + + + + + + Registries > + {{ registry.Name }} > + {{ repository.Name }} + + + +
+
+ + + + + + + + + + + + + + + + + + + +
Repository + {{ repository.Name }} + +
Tags count{{ repository.Tags.length }}
Images count{{ repository.Images.length }}
+
+
+
+ +
+ + + + +
+
+ +
+ +
+
+
+ + + + {{ $select.selected }} + + + {{ image }} + + +
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js new file mode 100644 index 000000000..de4521cb1 --- /dev/null +++ b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js @@ -0,0 +1,150 @@ +angular.module('portainer.app') + .controller('RegistryRepositoryController', ['$q', '$scope', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications', + function ($q, $scope, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications) { + + $scope.state = { + actionInProgress: false + }; + $scope.formValues = { + Tag: '' + }; + $scope.tags = []; + $scope.repository = { + Name: [], + Tags: [], + Images: [] + }; + + $scope.$watch('tags.length', function () { + var images = $scope.tags.map(function (item) { + return item.ImageId; + }); + $scope.repository.Images = _.uniq(images); + }); + + $scope.addTag = function () { + var manifest = $scope.tags.find(function (item) { + return item.ImageId === $scope.formValues.SelectedImage; + }).ManifestV2; + RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, $scope.formValues.Tag, manifest) + .then(function success() { + Notifications.success('Success', 'Tag successfully added'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to add tag'); + }); + }; + + $scope.retagAction = function (tag) { + RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, tag.Digest) + .then(function success() { + var promises = []; + var tagsToAdd = $scope.tags.filter(function (item) { + return item.Digest === tag.Digest; + }); + tagsToAdd.map(function (item) { + var tagValue = item.Modified && item.Name !== item.NewName ? item.NewName : item.Name; + promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, tagValue, item.ManifestV2)); + }); + return $q.all(promises); + }) + .then(function success() { + Notifications.success('Success', 'Tag successfully modified'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to modify tag'); + tag.Modified = false; + tag.NewValue = tag.Value; + }); + }; + + $scope.removeTags = function (selectedItems) { + ModalService.confirmDeletion( + 'Are you sure you want to remove the selected tags ?', + function onConfirm(confirmed) { + if (!confirmed) { + return; + } + var promises = []; + var uniqItems = _.uniqBy(selectedItems, 'Digest'); + uniqItems.map(function (item) { + promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest)); + }); + $q.all(promises) + .then(function success() { + var promises = []; + var tagsToReupload = _.differenceBy($scope.tags, selectedItems, 'Name'); + tagsToReupload.map(function (item) { + promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, item.Name, item.ManifestV2)); + }); + return $q.all(promises); + }) + .then(function success() { + Notifications.success('Success', 'Tags successfully deleted'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to delete tags'); + }); + }); + }; + + $scope.removeRepository = function () { + ModalService.confirmDeletion( + 'This action will only remove the manifests linked to this repository. You need to manually trigger a garbage collector pass on your registry to remove orphan layers and really remove the images content. THIS ACTION CAN NOT BE UNDONE', + function onConfirm(confirmed) { + if (!confirmed) { + return; + } + var promises = []; + var uniqItems = _.uniqBy($scope.tags, 'Digest'); + uniqItems.map(function (item) { + promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest)); + }); + $q.all(promises) + .then(function success() { + Notifications.success('Success', 'Repository sucessfully removed'); + $state.go('portainer.registries.registry.repositories', { + id: $scope.registryId + }, { + reload: true + }); + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to delete repository'); + }); + } + ); + }; + + function initView() { + var registryId = $scope.registryId = $transition$.params().id; + var repository = $scope.repository.Name = $transition$.params().repository; + $q.all({ + registry: RegistryService.registry(registryId), + tags: RegistryV2Service.tags(registryId, repository) + }) + .then(function success(data) { + $scope.registry = data.registry; + $scope.repository.Tags = data.tags; + $scope.tags = []; + for (var i = 0; i < data.tags.length; i++) { + var tag = data.tags[i]; + RegistryV2Service.tag(registryId, repository, tag) + .then(function success(data) { + $scope.tags.push(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve tag information'); + }); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve repository information'); + }); + } + + initView(); + } + ]); diff --git a/app/extensions/registry-management/views/repositories/registryRepositories.html b/app/extensions/registry-management/views/repositories/registryRepositories.html new file mode 100644 index 000000000..6ca3664c7 --- /dev/null +++ b/app/extensions/registry-management/views/repositories/registryRepositories.html @@ -0,0 +1,37 @@ + + + + + + + + Registries > {{ registry.Name }} > Repositories + + + +
+ + +

+ + Portainer was not able to use this registry management features. You might need to update the configuration used by Portainer to access this registry. +

+

Note: Portainer registry management features are only supported with registries exposing the v2 registry API.

+ +
+
+
+ +
+
+ + +
+
diff --git a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js new file mode 100644 index 000000000..ccf4fb3de --- /dev/null +++ b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js @@ -0,0 +1,33 @@ +angular.module('portainer.extensions.registrymanagement') +.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications', +function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications) { + + $scope.state = { + displayInvalidConfigurationMessage: false + }; + + function initView() { + var registryId = $transition$.params().id; + + RegistryService.registry(registryId) + .then(function success(data) { + $scope.registry = data; + + RegistryV2Service.ping(registryId, false) + .then(function success() { + return RegistryV2Service.repositories(registryId); + }) + .then(function success(data) { + $scope.repositories = data; + }) + .catch(function error() { + $scope.state.displayInvalidConfigurationMessage = true; + }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve registry details'); + }); + } + + initView(); +}]); diff --git a/app/extensions/storidge/__module.js b/app/extensions/storidge/__module.js index 34a1b007e..9fe339859 100644 --- a/app/extensions/storidge/__module.js +++ b/app/extensions/storidge/__module.js @@ -1,3 +1,4 @@ +// TODO: legacy extension management angular.module('extension.storidge', []) .config(['$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; diff --git a/app/portainer/__module.js b/app/portainer/__module.js index e6ab9f5d2..b79553a52 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -198,6 +198,28 @@ angular.module('portainer.app', []) } }; + var extensions = { + name: 'portainer.extensions', + url: '/extensions', + views: { + 'content@': { + templateUrl: 'app/portainer/views/extensions/extensions.html', + controller: 'ExtensionsController' + } + } + }; + + var extension = { + name: 'portainer.extensions.extension', + url: '/extension/:id', + views: { + 'content@': { + templateUrl: 'app/portainer/views/extensions/inspect/extension.html', + controller: 'ExtensionController' + } + } + }; + var registries = { name: 'portainer.registries', url: '/registries', @@ -335,7 +357,22 @@ angular.module('portainer.app', []) url: '/support', views: { 'content@': { - templateUrl: 'app/portainer/views/support/support.html' + templateUrl: 'app/portainer/views/support/support.html', + controller: 'SupportController' + } + }, + params: { + product: {} + } + }; + + var supportProduct = { + name: 'portainer.support.product', + url: '/product', + views: { + 'content@': { + templateUrl: 'app/portainer/views/support/product/product.html', + controller: 'SupportProductController' } } }; @@ -457,6 +494,8 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(init); $stateRegistryProvider.register(initEndpoint); $stateRegistryProvider.register(initAdmin); + $stateRegistryProvider.register(extensions); + $stateRegistryProvider.register(extension); $stateRegistryProvider.register(registries); $stateRegistryProvider.register(registry); $stateRegistryProvider.register(registryAccess); @@ -470,6 +509,7 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(stack); $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(support); + $stateRegistryProvider.register(supportProduct); $stateRegistryProvider.register(tags); $stateRegistryProvider.register(updatePassword); $stateRegistryProvider.register(users); diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html index 601171eae..900e3432a 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html @@ -61,6 +61,12 @@ Manage access + + Browse + + + Browse ( ) + diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.js b/app/portainer/components/datatables/registries-datatable/registriesDatatable.js index a24a717c8..be93a88bd 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.js +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.js @@ -9,6 +9,7 @@ angular.module('portainer.app').component('registriesDatatable', { orderBy: '@', reverseOrder: '<', accessManagement: '<', - removeAction: '<' + removeAction: '<', + registryManagement: '<' } }); diff --git a/app/portainer/components/extension-list/extension-item/extension-item.js b/app/portainer/components/extension-list/extension-item/extension-item.js new file mode 100644 index 000000000..4e11ab7f3 --- /dev/null +++ b/app/portainer/components/extension-list/extension-item/extension-item.js @@ -0,0 +1,8 @@ +angular.module('portainer.app').component('extensionItem', { + templateUrl: 'app/portainer/components/extension-list/extension-item/extensionItem.html', + controller: 'ExtensionItemController', + bindings: { + model: '<', + currentDate: '<' + } +}); diff --git a/app/portainer/components/extension-list/extension-item/extensionItem.html b/app/portainer/components/extension-list/extension-item/extensionItem.html new file mode 100644 index 000000000..d9d870a08 --- /dev/null +++ b/app/portainer/components/extension-list/extension-item/extensionItem.html @@ -0,0 +1,46 @@ + +
+
+ + + + + + + + + +
+ + + {{ $ctrl.model.Name }} + + + + coming soon + deal + expired + enabled + update available + +
+ + +
+ + + {{ $ctrl.model.ShortDescription }} + + + + Licensed to {{ $ctrl.model.License.Company }} - Expires on {{ $ctrl.model.License.Expiration }} + +
+ +
+ +
+ +
diff --git a/app/portainer/components/extension-list/extension-item/extensionItemController.js b/app/portainer/components/extension-list/extension-item/extensionItemController.js new file mode 100644 index 000000000..cdccfefea --- /dev/null +++ b/app/portainer/components/extension-list/extension-item/extensionItemController.js @@ -0,0 +1,18 @@ +angular.module('portainer.app') +.controller('ExtensionItemController', ['$state', +function ($state) { + + var ctrl = this; + ctrl.$onInit = $onInit; + ctrl.goToExtensionView = goToExtensionView; + + function goToExtensionView() { + $state.go('portainer.extensions.extension', { id: ctrl.model.Id }); + } + + function $onInit() { + if (ctrl.currentDate === ctrl.model.License.Expiration) { + ctrl.model.Expired = true; + } + } +}]); diff --git a/app/portainer/components/extension-list/extension-list.js b/app/portainer/components/extension-list/extension-list.js new file mode 100644 index 000000000..6d35f8a68 --- /dev/null +++ b/app/portainer/components/extension-list/extension-list.js @@ -0,0 +1,7 @@ +angular.module('portainer.app').component('extensionList', { + templateUrl: 'app/portainer/components/extension-list/extensionList.html', + bindings: { + extensions: '<', + currentDate: '<' + } +}); diff --git a/app/portainer/components/extension-list/extensionList.html b/app/portainer/components/extension-list/extensionList.html new file mode 100644 index 000000000..3b76d6024 --- /dev/null +++ b/app/portainer/components/extension-list/extensionList.html @@ -0,0 +1,20 @@ +
+ + + +
+
+ Available extensions +
+
+ +
+ +
+ +
+
+
diff --git a/app/portainer/components/extension-tooltip/extension-tooltip.html b/app/portainer/components/extension-tooltip/extension-tooltip.html new file mode 100644 index 000000000..d2dfec892 --- /dev/null +++ b/app/portainer/components/extension-tooltip/extension-tooltip.html @@ -0,0 +1 @@ + diff --git a/app/portainer/components/extension-tooltip/extension-tooltip.js b/app/portainer/components/extension-tooltip/extension-tooltip.js new file mode 100644 index 000000000..df9c62eba --- /dev/null +++ b/app/portainer/components/extension-tooltip/extension-tooltip.js @@ -0,0 +1,3 @@ +angular.module('portainer.app').component('extensionTooltip', { + templateUrl: 'app/portainer/components/extension-tooltip/extension-tooltip.html' +}); diff --git a/app/portainer/components/forms/registry-form-azure/registry-form-azure.html b/app/portainer/components/forms/registry-form-azure/registry-form-azure.html new file mode 100644 index 000000000..0a9217b87 --- /dev/null +++ b/app/portainer/components/forms/registry-form-azure/registry-form-azure.html @@ -0,0 +1,81 @@ +
+
+ Azure registry details +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

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

This field is required.

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

This field is required.

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

This field is required.

+
+
+
+ + +
+ Actions +
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-azure/registry-form-azure.js b/app/portainer/components/forms/registry-form-azure/registry-form-azure.js new file mode 100644 index 000000000..51208f5ba --- /dev/null +++ b/app/portainer/components/forms/registry-form-azure/registry-form-azure.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormAzure', { + templateUrl: 'app/portainer/components/forms/registry-form-azure/registry-form-azure.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/registry-form-custom/registry-form-custom.html b/app/portainer/components/forms/registry-form-custom/registry-form-custom.html new file mode 100644 index 000000000..bb02e9276 --- /dev/null +++ b/app/portainer/components/forms/registry-form-custom/registry-form-custom.html @@ -0,0 +1,105 @@ +
+
+ Important notice +
+
+ + Docker requires you to connect to a secure registry. + You can find more information about how to connect to an insecure registry in the Docker documentation. + +
+
+ Custom registry details +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

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

This field is required.

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

This field is required.

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

This field is required.

+
+
+
+ +
+ +
+ Actions +
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-custom/registry-form-custom.js b/app/portainer/components/forms/registry-form-custom/registry-form-custom.js new file mode 100644 index 000000000..5d5da8de7 --- /dev/null +++ b/app/portainer/components/forms/registry-form-custom/registry-form-custom.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormCustom', { + templateUrl: 'app/portainer/components/forms/registry-form-custom/registry-form-custom.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/registry-form-quay/registry-form-quay.html b/app/portainer/components/forms/registry-form-quay/registry-form-quay.html new file mode 100644 index 000000000..598081b2e --- /dev/null +++ b/app/portainer/components/forms/registry-form-quay/registry-form-quay.html @@ -0,0 +1,48 @@ +
+
+ Quay account details +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

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

This field is required.

+
+
+
+ + +
+ Actions +
+
+
+ +
+
+ +
diff --git a/app/portainer/components/forms/registry-form-quay/registry-form-quay.js b/app/portainer/components/forms/registry-form-quay/registry-form-quay.js new file mode 100644 index 000000000..e08453454 --- /dev/null +++ b/app/portainer/components/forms/registry-form-quay/registry-form-quay.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormQuay', { + templateUrl: 'app/portainer/components/forms/registry-form-quay/registry-form-quay.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/product-list/product-item/product-item.js b/app/portainer/components/product-list/product-item/product-item.js new file mode 100644 index 000000000..98c177644 --- /dev/null +++ b/app/portainer/components/product-list/product-item/product-item.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('productItem', { + templateUrl: 'app/portainer/components/product-list/product-item/productItem.html', + controller: 'ProductItemController', + bindings: { + model: '<', + currentDate: '<', + goTo: '<' + } +}); diff --git a/app/portainer/components/product-list/product-item/productItem.html b/app/portainer/components/product-list/product-item/productItem.html new file mode 100644 index 000000000..fa72a7092 --- /dev/null +++ b/app/portainer/components/product-list/product-item/productItem.html @@ -0,0 +1,41 @@ + +
+
+ + + + + + +
+ + + {{ $ctrl.model.Name }} + + + + expired + enabled + update available + +
+ + +
+ + + {{ $ctrl.model.ShortDescription }} + + + + Licensed to {{ $ctrl.model.License.Company }} - Expires on {{ $ctrl.model.License.Expiration }} + +
+ +
+ +
+ +
diff --git a/app/portainer/components/product-list/product-item/productItemController.js b/app/portainer/components/product-list/product-item/productItemController.js new file mode 100644 index 000000000..d1d479624 --- /dev/null +++ b/app/portainer/components/product-list/product-item/productItemController.js @@ -0,0 +1,18 @@ +angular.module('portainer.app') +.controller('ProductItemController', ['$state', +function ($state) { + + var ctrl = this; + ctrl.$onInit = $onInit; + ctrl.goToExtensionView = goToExtensionView; + + function goToExtensionView() { + $state.go('portainer.extensions.extension', { id: ctrl.model.Id }); + } + + function $onInit() { + if (ctrl.currentDate === ctrl.model.License.Expiration) { + ctrl.model.Expired = true; + } + } +}]); diff --git a/app/portainer/components/product-list/product-list.js b/app/portainer/components/product-list/product-list.js new file mode 100644 index 000000000..ad3d05025 --- /dev/null +++ b/app/portainer/components/product-list/product-list.js @@ -0,0 +1,10 @@ +angular.module('portainer.app').component('productList', { + templateUrl: 'app/portainer/components/product-list/productList.html', + bindings: { + titleText: '@', + products: '<', + goTo: '<' + // extensions: '<', + // currentDate: '<' + } +}); diff --git a/app/portainer/components/product-list/productList.html b/app/portainer/components/product-list/productList.html new file mode 100644 index 000000000..44606ae28 --- /dev/null +++ b/app/portainer/components/product-list/productList.html @@ -0,0 +1,21 @@ +
+ + + +
+
+ {{ $ctrl.titleText }} +
+
+ +
+ +
+ +
+
+
diff --git a/app/portainer/models/extension.js b/app/portainer/models/extension.js new file mode 100644 index 000000000..12533d0f5 --- /dev/null +++ b/app/portainer/models/extension.js @@ -0,0 +1,17 @@ +function ExtensionViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Enabled = data.Enabled; + this.Description = data.Description; + this.Price = data.Price; + this.PriceDescription = data.PriceDescription; + this.Available = data.Available; + this.Deal = data.Deal; + this.ShortDescription = data.ShortDescription; + this.License = data.License; + this.Version = data.Version; + this.UpdateAvailable = data.UpdateAvailable; + this.ProductId = data.ProductId; + this.Images = data.Images; + this.Logo = data.Logo; +} diff --git a/app/portainer/models/registry.js b/app/portainer/models/registry.js index 44c3051ac..23287da05 100644 --- a/app/portainer/models/registry.js +++ b/app/portainer/models/registry.js @@ -1,5 +1,6 @@ function RegistryViewModel(data) { this.Id = data.Id; + this.Type = data.Type; this.Name = data.Name; this.URL = data.URL; this.Authentication = data.Authentication; @@ -9,3 +10,44 @@ function RegistryViewModel(data) { this.AuthorizedTeams = data.AuthorizedTeams; this.Checked = false; } + +function RegistryManagementConfigurationDefaultModel(registry) { + this.Authentication = false; + this.Password = ''; + this.TLS = false; + this.TLSSkipVerify = false; + this.TLSCACertFile = null; + this.TLSCertFile = null; + this.TLSKeyFile = null; + + if (registry.Type === 1 || registry.Type === 2 ) { + this.Authentication = true; + this.Username = registry.Username; + this.TLS = true; + } + + if (registry.Type === 3 && registry.Authentication) { + this.Authentication = true; + this.Username = registry.Username; + } +} + +function RegistryDefaultModel() { + this.Type = 3; + this.URL = ''; + this.Name = ''; + this.Authentication = false; + this.Username = ''; + this.Password = ''; +} + +function RegistryCreateRequest(model) { + this.Name = model.Name; + this.Type = model.Type; + this.URL = model.URL; + this.Authentication = model.Authentication; + if (model.Authentication) { + this.Username = model.Username; + this.Password = model.Password; + } +} diff --git a/app/portainer/models/status.js b/app/portainer/models/status.js index f1302d391..198622389 100644 --- a/app/portainer/models/status.js +++ b/app/portainer/models/status.js @@ -4,4 +4,5 @@ function StatusViewModel(data) { this.EndpointManagement = data.EndpointManagement; this.Analytics = data.Analytics; this.Version = data.Version; + this.EnabledExtensions = data.EnabledExtensions; } diff --git a/app/portainer/rest/extension.js b/app/portainer/rest/extension.js index 1f9dd75bb..6b4588d68 100644 --- a/app/portainer/rest/extension.js +++ b/app/portainer/rest/extension.js @@ -1,11 +1,12 @@ angular.module('portainer.app') -.factory('Extensions', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function Extensions($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) { +.factory('Extension', ['$resource', 'API_ENDPOINT_EXTENSIONS', + function ExtensionFactory($resource, API_ENDPOINT_EXTENSIONS) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/extensions/:type', { - endpointId: EndpointProvider.endpointID - }, - { - register: { method: 'POST' }, - deregister: { method: 'DELETE', params: { type: '@type' } } + return $resource(API_ENDPOINT_EXTENSIONS + '/:id/:action', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + delete: { method: 'DELETE', params: { id: '@id' } }, + update: { method: 'POST', params: { id: '@id', action: 'update' } } }); }]); diff --git a/app/portainer/rest/legacyExtension.js b/app/portainer/rest/legacyExtension.js new file mode 100644 index 000000000..1da3abdd7 --- /dev/null +++ b/app/portainer/rest/legacyExtension.js @@ -0,0 +1,12 @@ +// TODO: legacy extension management +angular.module('portainer.app') +.factory('LegacyExtensions', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function LegacyExtensions($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/extensions/:type', { + endpointId: EndpointProvider.endpointID + }, + { + register: { method: 'POST' }, + deregister: { method: 'DELETE', params: { type: '@type' } } + }); +}]); diff --git a/app/portainer/rest/registry.js b/app/portainer/rest/registry.js index 56c46a3d0..3c25cbbcf 100644 --- a/app/portainer/rest/registry.js +++ b/app/portainer/rest/registry.js @@ -7,6 +7,7 @@ angular.module('portainer.app') get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, - remove: { method: 'DELETE', params: { id: '@id'} } + remove: { method: 'DELETE', params: { id: '@id'} }, + configure: { method: 'POST', params: { id: '@id', action: 'configure' } } }); }]); diff --git a/app/portainer/services/api/extensionService.js b/app/portainer/services/api/extensionService.js index 3cd08a828..b7087f3fc 100644 --- a/app/portainer/services/api/extensionService.js +++ b/app/portainer/services/api/extensionService.js @@ -1,19 +1,65 @@ angular.module('portainer.app') -.factory('ExtensionService', ['Extensions', function ExtensionServiceFactory(Extensions) { +.factory('ExtensionService', ['$q', 'Extension', function ExtensionServiceFactory($q, Extension) { 'use strict'; var service = {}; - service.registerStoridgeExtension = function(url) { - var payload = { - Type: 1, - URL: url - }; - - return Extensions.register(payload).$promise; + service.enable = function(license) { + return Extension.create({ license: license }).$promise; }; - service.deregisterStoridgeExtension = function() { - return Extensions.deregister({ type: 1 }).$promise; + service.update = function(id, version) { + return Extension.update({ id: id, version: version }).$promise; + }; + + service.delete = function(id) { + return Extension.delete({ id: id }).$promise; + }; + + service.extensions = function(store) { + var deferred = $q.defer(); + + Extension.query({ store: store }).$promise + .then(function success(data) { + var extensions = data.map(function (item) { + return new ExtensionViewModel(item); + }); + deferred.resolve(extensions); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve extensions', err: err}); + }); + + return deferred.promise; + }; + + service.extension = function(id) { + var deferred = $q.defer(); + + Extension.get({ id: id }).$promise + .then(function success(data) { + var extension = new ExtensionViewModel(data); + deferred.resolve(extension); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve extension details', err: err}); + }); + + return deferred.promise; + }; + + service.registryManagementEnabled = function() { + var deferred = $q.defer(); + + service.extensions(false) + .then(function onSuccess(extensions) { + var extensionAvailable = _.find(extensions, { Id: 1, Enabled: true }) ? true : false; + deferred.resolve(extensionAvailable); + }) + .catch(function onError(err) { + deferred.reject(err); + }); + + return deferred.promise; }; return service; diff --git a/app/portainer/services/api/legacyExtensionService.js b/app/portainer/services/api/legacyExtensionService.js new file mode 100644 index 000000000..38d4df65e --- /dev/null +++ b/app/portainer/services/api/legacyExtensionService.js @@ -0,0 +1,21 @@ +// TODO: legacy extension management +angular.module('portainer.app') +.factory('LegacyExtensionService', ['LegacyExtensions', function LegacyExtensionServiceFactory(LegacyExtensions) { + 'use strict'; + var service = {}; + + service.registerStoridgeExtension = function(url) { + var payload = { + Type: 1, + URL: url + }; + + return LegacyExtensions.register(payload).$promise; + }; + + service.deregisterStoridgeExtension = function() { + return LegacyExtensions.deregister({ type: 1 }).$promise; + }; + + return service; +}]); diff --git a/app/portainer/services/api/registryService.js b/app/portainer/services/api/registryService.js index ff5d00f18..1d09c31d8 100644 --- a/app/portainer/services/api/registryService.js +++ b/app/portainer/services/api/registryService.js @@ -1,5 +1,5 @@ angular.module('portainer.app') -.factory('RegistryService', ['$q', 'Registries', 'DockerHubService', 'RegistryHelper', 'ImageHelper', function RegistryServiceFactory($q, Registries, DockerHubService, RegistryHelper, ImageHelper) { +.factory('RegistryService', ['$q', 'Registries', 'DockerHubService', 'RegistryHelper', 'ImageHelper', 'FileUploadService', function RegistryServiceFactory($q, Registries, DockerHubService, RegistryHelper, ImageHelper, FileUploadService) { 'use strict'; var service = {}; @@ -54,17 +54,13 @@ angular.module('portainer.app') return Registries.update({ id: registry.Id }, registry).$promise; }; - service.createRegistry = function(name, URL, authentication, username, password) { - var payload = { - Name: name, - URL: URL, - Authentication: authentication - }; - if (authentication) { - payload.Username = username; - payload.Password = password; - } - return Registries.create({}, payload).$promise; + service.configureRegistry = function(id, registryManagementConfigurationModel) { + return FileUploadService.configureRegistry(id, registryManagementConfigurationModel); + }; + + service.createRegistry = function(model) { + var payload = new RegistryCreateRequest(model); + return Registries.create(payload).$promise; }; service.retrieveRegistryFromRepository = function(repository) { diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index d00271dcb..512445fc2 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -80,6 +80,13 @@ angular.module('portainer.app') }); }; + service.configureRegistry = function(registryId, registryManagementConfigurationModel) { + return Upload.upload({ + url: 'api/registries/' + registryId + '/configure', + data: registryManagementConfigurationModel + }); + }; + service.executeEndpointJob = function (imageName, file, endpointId, nodeName) { return Upload.upload({ url: 'api/endpoints/' + endpointId + '/job?method=file&nodeName=' + nodeName, diff --git a/app/portainer/services/extensionManager.js b/app/portainer/services/legacyExtensionManager.js similarity index 86% rename from app/portainer/services/extensionManager.js rename to app/portainer/services/legacyExtensionManager.js index e9f622746..f56549ab5 100644 --- a/app/portainer/services/extensionManager.js +++ b/app/portainer/services/legacyExtensionManager.js @@ -1,6 +1,7 @@ +// TODO: legacy extension management angular.module('portainer.app') -.factory('ExtensionManager', ['$q', 'PluginService', 'SystemService', 'ExtensionService', -function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionService) { +.factory('LegacyExtensionManager', ['$q', 'PluginService', 'SystemService', 'LegacyExtensionService', +function ExtensionManagerFactory($q, PluginService, SystemService, LegacyExtensionService) { 'use strict'; var service = {}; @@ -60,7 +61,7 @@ function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionServ .then(function success(data) { var managerIP = data.Swarm.NodeAddr; var storidgeAPIURL = 'tcp://' + managerIP + ':8282'; - return ExtensionService.registerStoridgeExtension(storidgeAPIURL); + return LegacyExtensionService.registerStoridgeExtension(storidgeAPIURL); }) .then(function success(data) { deferred.resolve(data); @@ -73,7 +74,7 @@ function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionServ } function deregisterStoridgeExtension() { - return ExtensionService.deregisterStoridgeExtension(); + return LegacyExtensionService.deregisterStoridgeExtension(); } return service; diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index f08a02962..082dcf493 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -25,6 +25,14 @@ angular.module('portainer.app') return buttons; }; + service.enlargeImage = function(image) { + bootbox.dialog({ + message: '', + className: 'image-zoom-modal', + onEscape: true + }); + }; + service.confirm = function(options){ var box = bootbox.confirm({ title: options.title, diff --git a/app/portainer/views/extensions/extensions.html b/app/portainer/views/extensions/extensions.html new file mode 100644 index 000000000..2e78e557b --- /dev/null +++ b/app/portainer/views/extensions/extensions.html @@ -0,0 +1,71 @@ + + + Portainer extensions + + + + +

+ Content to be defined +

+
+
+ +
+
+ + + + +
+
+ Enable extension +
+ +
+
+ + Ensure that you have a valid license. + +
+
+ +
+ +
+ +
+
+ +
+
+
+

Invalid license format.

+
+
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+ +
+
+ +
+
diff --git a/app/portainer/views/extensions/extensionsController.js b/app/portainer/views/extensions/extensionsController.js new file mode 100644 index 000000000..fb6b87244 --- /dev/null +++ b/app/portainer/views/extensions/extensionsController.js @@ -0,0 +1,58 @@ +angular.module('portainer.app') +.controller('ExtensionsController', ['$scope', '$state', 'ExtensionService', 'Notifications', +function ($scope, $state, ExtensionService, Notifications) { + + $scope.state = { + actionInProgress: false, + currentDate: moment().format('YYYY-MM-dd') + }; + + $scope.formValues = { + License: '' + }; + + function initView() { + ExtensionService.extensions(true) + .then(function onSuccess(data) { + $scope.extensions = data; + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to access extension store'); + }); + } + + $scope.enableExtension = function() { + var license = $scope.formValues.License; + + $scope.state.actionInProgress = true; + ExtensionService.enable(license) + .then(function onSuccess() { + Notifications.success('Extension successfully enabled'); + $state.reload(); + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to enable extension'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + + $scope.isValidLicenseFormat = function(form) { + var valid = true; + + if (!$scope.formValues.License) { + return; + } + + if (isNaN($scope.formValues.License[0])) { + valid = false; + } + + form.extension_license.$setValidity('invalidLicense', valid); + }; + + + initView(); +}]); diff --git a/app/portainer/views/extensions/inspect/extension.html b/app/portainer/views/extensions/inspect/extension.html new file mode 100644 index 000000000..1e5e3d3c4 --- /dev/null +++ b/app/portainer/views/extensions/inspect/extension.html @@ -0,0 +1,122 @@ + + + + Portainer extensions > {{ extension.Name }} + + + +
+
+ + + +
+ +
+ +
+
+ {{ extension.Name }} extension +
+ +
+ By Portainer.io +
+
+ +
+
+ {{ extension.ShortDescription }} +
+
+
+ +
+ +
+ +
+ {{ extension.Enabled ? 'Enabled' : extension.Price }} +
+ +
+ {{ extension.PriceDescription }} +
+ +
+ +
+ +
+
+ + + +
+ Coming soon +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+ + +
+ + Description + +
+
+ + {{ extension.Description }} + +
+
+
+
+
+ +
+
+ + +
+ + Screenshots + +
+
+
+ +
+
+
+
+
+
diff --git a/app/portainer/views/extensions/inspect/extensionController.js b/app/portainer/views/extensions/inspect/extensionController.js new file mode 100644 index 000000000..2f293e690 --- /dev/null +++ b/app/portainer/views/extensions/inspect/extensionController.js @@ -0,0 +1,63 @@ +angular.module('portainer.app') +.controller('ExtensionController', ['$q', '$scope', '$transition$', '$state', 'ExtensionService', 'Notifications', 'ModalService', +function ($q, $scope, $transition$, $state, ExtensionService, Notifications, ModalService) { + + $scope.state = { + updateInProgress: false, + deleteInProgress: false + }; + + $scope.formValues = { + instances: 1 + }; + + $scope.updateExtension = updateExtension; + $scope.deleteExtension = deleteExtension; + $scope.enlargeImage = enlargeImage; + + function enlargeImage(image) { + ModalService.enlargeImage(image); + } + + function deleteExtension(extension) { + $scope.state.deleteInProgress = true; + ExtensionService.delete(extension.Id) + .then(function onSuccess() { + Notifications.success('Extension successfully deleted'); + $state.reload(); + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to delete extension'); + }) + .finally(function final() { + $scope.state.deleteInProgress = false; + }); + } + + function updateExtension(extension) { + $scope.state.updateInProgress = true; + ExtensionService.update(extension.Id, extension.Version) + .then(function onSuccess() { + Notifications.success('Extension successfully updated'); + $state.reload(); + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to update extension'); + }) + .finally(function final() { + $scope.state.updateInProgress = false; + }); + } + + function initView() { + ExtensionService.extension($transition$.params().id) + .then(function onSuccess(extension) { + $scope.extension = extension; + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to retrieve extension information'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/home/home.html b/app/portainer/views/home/home.html index 49641ec85..b12d8e749 100644 --- a/app/portainer/views/home/home.html +++ b/app/portainer/views/home/home.html @@ -17,22 +17,13 @@ + ng-if="!isAdmin && endpoints.length === 0" + title-text="Information"> -

- Welcome to Portainer ! Click on any endpoint in the list below to access management features. -

-

+

You do not have access to any environment. Please contact your administrator.

- -

- - Endpoint snapshot is disabled. -

diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index b422a7e86..f25e5b51f 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'ExtensionManager', 'ModalService', 'MotdService', 'SystemService', -function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, ExtensionManager, ModalService, MotdService, SystemService) { +.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'LegacyExtensionManager', 'ModalService', 'MotdService', 'SystemService', +function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, LegacyExtensionManager, ModalService, MotdService, SystemService) { $scope.goToEdit = function(id) { $state.go('portainer.endpoints.endpoint', { id: id }); @@ -87,7 +87,7 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G EndpointProvider.setEndpointID(endpoint.Id); EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); EndpointProvider.setOfflineModeFromStatus(endpoint.Status); - ExtensionManager.initEndpointExtensions(endpoint) + LegacyExtensionManager.initEndpointExtensions(endpoint) .then(function success(data) { var extensions = data; return StateManager.updateEndpointState(endpoint, extensions); diff --git a/app/portainer/views/registries/create/createRegistryController.js b/app/portainer/views/registries/create/createRegistryController.js index ef6212087..059f8b428 100644 --- a/app/portainer/views/registries/create/createRegistryController.js +++ b/app/portainer/views/registries/create/createRegistryController.js @@ -2,40 +2,38 @@ angular.module('portainer.app') .controller('CreateRegistryController', ['$scope', '$state', 'RegistryService', 'Notifications', function ($scope, $state, RegistryService, Notifications) { + $scope.selectQuayRegistry = selectQuayRegistry; + $scope.selectAzureRegistry = selectAzureRegistry; + $scope.selectCustomRegistry = selectCustomRegistry; + $scope.create = createRegistry; + $scope.state = { - RegistryType: 'quay', actionInProgress: false }; - $scope.formValues = { - Name: 'Quay', - URL: 'quay.io', - Authentication: true, - Username: '', - Password: '' - }; + function selectQuayRegistry() { + $scope.model.Name = 'Quay'; + $scope.model.URL = 'quay.io'; + $scope.model.Authentication = true; + } - $scope.selectQuayRegistry = function() { - $scope.formValues.Name = 'Quay'; - $scope.formValues.URL = 'quay.io'; - $scope.formValues.Authentication = true; - }; + function selectAzureRegistry() { + $scope.model.Name = ''; + $scope.model.URL = ''; + $scope.model.Authentication = true; + } - $scope.selectCustomRegistry = function() { - $scope.formValues.Name = ''; - $scope.formValues.URL = ''; - $scope.formValues.Authentication = false; - }; + function selectCustomRegistry() { + $scope.model.Name = ''; + $scope.model.URL = ''; + $scope.model.Authentication = false; + } - $scope.addRegistry = function() { - var registryName = $scope.formValues.Name; - var registryURL = $scope.formValues.URL.replace(/^https?\:\/\//i, ''); - var authentication = $scope.formValues.Authentication; - var username = $scope.formValues.Username; - var password = $scope.formValues.Password; + function createRegistry() { + $scope.model.URL = $scope.model.URL.replace(/^https?\:\/\//i, ''); $scope.state.actionInProgress = true; - RegistryService.createRegistry(registryName, registryURL, authentication, username, password) + RegistryService.createRegistry($scope.model) .then(function success() { Notifications.success('Registry successfully created'); $state.go('portainer.registries'); @@ -46,5 +44,11 @@ function ($scope, $state, RegistryService, Notifications) { .finally(function final() { $scope.state.actionInProgress = false; }); - }; + } + + function initView() { + $scope.model = new RegistryDefaultModel(); + } + + initView(); }]); diff --git a/app/portainer/views/registries/create/createregistry.html b/app/portainer/views/registries/create/createregistry.html index e4e1499b0..fd6f33e43 100644 --- a/app/portainer/views/registries/create/createregistry.html +++ b/app/portainer/views/registries/create/createregistry.html @@ -13,11 +13,13 @@
Registry provider
+
+
- +
-
- Important notice -
-
- - Docker requires you to connect to a secure registry. - You can find more information about how to connect to an insecure registry in the Docker documentation. - -
-
- Registry details -
- -
- -
- -
-
- - -
- -
- -
-
- - -
-
- - -
-
- - -
- -
- -
- -
-
- - -
- -
- -
-
- -
- -
- Actions -
-
-
- -
-
+ + + + + + + diff --git a/app/portainer/views/registries/registries.html b/app/portainer/views/registries/registries.html index 9a01180ab..77ef9b773 100644 --- a/app/portainer/views/registries/registries.html +++ b/app/portainer/views/registries/registries.html @@ -73,9 +73,10 @@
diff --git a/app/portainer/views/registries/registriesController.js b/app/portainer/views/registries/registriesController.js index 317091841..373a01bdf 100644 --- a/app/portainer/views/registries/registriesController.js +++ b/app/portainer/views/registries/registriesController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications', -function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications) { +.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications', 'ExtensionService', +function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications, ExtensionService) { $scope.state = { actionInProgress: false @@ -60,11 +60,13 @@ function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, N function initView() { $q.all({ registries: RegistryService.registries(), - dockerhub: DockerHubService.dockerhub() + dockerhub: DockerHubService.dockerhub(), + registryManagement: ExtensionService.registryManagementEnabled() }) .then(function success(data) { $scope.registries = data.registries; $scope.dockerhub = data.dockerhub; + $scope.registryManagementAvailable = data.registryManagement; }) .catch(function error(err) { $scope.registries = []; diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index a78c4f986..59ccaadff 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -45,6 +45,9 @@ +