THIS IS A TEST INSTANCE ONLY! REPOSITORIES CAN BE DELETED AT ANY TIME!

Browse Source

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
tags/1.20.0
Anthony Lapenna GitHub 1 year ago
parent
commit
6fd5ddc802
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 3504 additions and 253 deletions
  1. +48
    -0
      api/archive/zip.go
  2. +8
    -0
      api/bolt/datastore.go
  3. +86
    -0
      api/bolt/extension/extension.go
  4. +25
    -0
      api/cmd/portainer/main.go
  5. +5
    -0
      api/errors.go
  6. +205
    -0
      api/exec/extension.go
  7. +48
    -0
      api/filesystem/filesystem.go
  8. +4
    -2
      api/http/handler/endpointproxy/proxy_storidge.go
  9. +0
    -1
      api/http/handler/endpoints/endpoint_delete.go
  10. +2
    -0
      api/http/handler/endpoints/endpoint_extension_add.go
  11. +2
    -0
      api/http/handler/endpoints/endpoint_extension_remove.go
  12. +79
    -0
      api/http/handler/extensions/extension_create.go
  13. +38
    -0
      api/http/handler/extensions/extension_delete.go
  14. +59
    -0
      api/http/handler/extensions/extension_inspect.go
  15. +55
    -0
      api/http/handler/extensions/extension_list.go
  16. +56
    -0
      api/http/handler/extensions/extension_update.go
  17. +37
    -0
      api/http/handler/extensions/handler.go
  18. +4
    -0
      api/http/handler/handler.go
  19. +13
    -5
      api/http/handler/registries/handler.go
  20. +78
    -0
      api/http/handler/registries/proxy.go
  21. +137
    -0
      api/http/handler/registries/registry_configure.go
  22. +5
    -0
      api/http/handler/registries/registry_create.go
  23. +1
    -1
      api/http/proxy/factory.go
  24. +77
    -48
      api/http/proxy/manager.go
  25. +11
    -0
      api/http/server.go
  26. +91
    -9
      api/portainer.go
  27. +1
    -0
      app/__module.js
  28. +4
    -2
      app/app.js
  29. +1
    -0
      app/constants.js
  30. +3
    -0
      app/extensions/_module.js
  31. +41
    -0
      app/extensions/registry-management/_module.js
  32. +83
    -0
      app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html
  33. +13
    -0
      app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js
  34. +112
    -0
      app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html
  35. +14
    -0
      app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js
  36. +38
    -0
      app/extensions/registry-management/helpers/localRegistryHelper.js
  37. +4
    -0
      app/extensions/registry-management/models/registryRepository.js
  38. +12
    -0
      app/extensions/registry-management/models/repositoryTag.js
  39. +23
    -0
      app/extensions/registry-management/rest/catalog.js
  40. +61
    -0
      app/extensions/registry-management/rest/manifest.js
  41. +10
    -0
      app/extensions/registry-management/rest/tags.js
  42. +118
    -0
      app/extensions/registry-management/services/registryAPIService.js
  43. +66
    -0
      app/extensions/registry-management/views/configure/configureRegistryController.js
  44. +161
    -0
      app/extensions/registry-management/views/configure/configureregistry.html
  45. +88
    -0
      app/extensions/registry-management/views/repositories/edit/registryRepository.html
  46. +150
    -0
      app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js
  47. +37
    -0
      app/extensions/registry-management/views/repositories/registryRepositories.html
  48. +33
    -0
      app/extensions/registry-management/views/repositories/registryRepositoriesController.js
  49. +1
    -0
      app/extensions/storidge/__module.js
  50. +41
    -1
      app/portainer/__module.js
  51. +6
    -0
      app/portainer/components/datatables/registries-datatable/registriesDatatable.html
  52. +2
    -1
      app/portainer/components/datatables/registries-datatable/registriesDatatable.js
  53. +8
    -0
      app/portainer/components/extension-list/extension-item/extension-item.js
  54. +46
    -0
      app/portainer/components/extension-list/extension-item/extensionItem.html
  55. +18
    -0
      app/portainer/components/extension-list/extension-item/extensionItemController.js
  56. +7
    -0
      app/portainer/components/extension-list/extension-list.js
  57. +20
    -0
      app/portainer/components/extension-list/extensionList.html
  58. +1
    -0
      app/portainer/components/extension-tooltip/extension-tooltip.html
  59. +3
    -0
      app/portainer/components/extension-tooltip/extension-tooltip.js
  60. +81
    -0
      app/portainer/components/forms/registry-form-azure/registry-form-azure.html
  61. +9
    -0
      app/portainer/components/forms/registry-form-azure/registry-form-azure.js
  62. +105
    -0
      app/portainer/components/forms/registry-form-custom/registry-form-custom.html
  63. +9
    -0
      app/portainer/components/forms/registry-form-custom/registry-form-custom.js
  64. +48
    -0
      app/portainer/components/forms/registry-form-quay/registry-form-quay.html
  65. +9
    -0
      app/portainer/components/forms/registry-form-quay/registry-form-quay.js
  66. +9
    -0
      app/portainer/components/product-list/product-item/product-item.js
  67. +41
    -0
      app/portainer/components/product-list/product-item/productItem.html
  68. +18
    -0
      app/portainer/components/product-list/product-item/productItemController.js
  69. +10
    -0
      app/portainer/components/product-list/product-list.js
  70. +21
    -0
      app/portainer/components/product-list/productList.html
  71. +17
    -0
      app/portainer/models/extension.js
  72. +42
    -0
      app/portainer/models/registry.js
  73. +1
    -0
      app/portainer/models/status.js
  74. +8
    -7
      app/portainer/rest/extension.js
  75. +12
    -0
      app/portainer/rest/legacyExtension.js
  76. +2
    -1
      app/portainer/rest/registry.js
  77. +55
    -9
      app/portainer/services/api/extensionService.js
  78. +21
    -0
      app/portainer/services/api/legacyExtensionService.js
  79. +8
    -12
      app/portainer/services/api/registryService.js
  80. +7
    -0
      app/portainer/services/fileUpload.js
  81. +5
    -4
      app/portainer/services/legacyExtensionManager.js
  82. +8
    -0
      app/portainer/services/modalService.js
  83. +71
    -0
      app/portainer/views/extensions/extensions.html
  84. +58
    -0
      app/portainer/views/extensions/extensionsController.js
  85. +122
    -0
      app/portainer/views/extensions/inspect/extension.html
  86. +63
    -0
      app/portainer/views/extensions/inspect/extensionController.js
  87. +3
    -12
      app/portainer/views/home/home.html
  88. +3
    -3
      app/portainer/views/home/homeController.js
  89. +30
    -26
      app/portainer/views/registries/create/createRegistryController.js
  90. +36
    -77
      app/portainer/views/registries/create/createregistry.html
  91. +2
    -1
      app/portainer/views/registries/registries.html
  92. +5
    -3
      app/portainer/views/registries/registriesController.js
  93. +3
    -0
      app/portainer/views/sidebar/sidebar.html
  94. +84
    -0
      app/portainer/views/support/product/product.html
  95. +14
    -0
      app/portainer/views/support/product/productController.js
  96. +24
    -28
      app/portainer/views/support/support.html
  97. +35
    -0
      app/portainer/views/support/supportController.js
  98. +5
    -0
      assets/css/app.css
  99. BIN
      assets/images/support_1.png
  100. BIN
      assets/images/support_2.png

+ 48
- 0
api/archive/zip.go View File

@@ -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
}

+ 8
- 0
api/bolt/datastore.go View File

@@ -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


+ 86
- 0
api/bolt/extension/extension.go View File

@@ -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)
}

+ 25
- 0
api/cmd/portainer/main.go View File

@@ -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,


+ 5
- 0
api/errors.go View File

@@ -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")


+ 205
- 0
api/exec/extension.go View File

@@ -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
}

+ 48
- 0
api/filesystem/filesystem.go View File

@@ -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) {


+ 4
- 2
api/http/handler/endpointproxy/proxy_storidge.go View File

@@ -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}
}


+ 0
- 1
api/http/handler/endpoints/endpoint_delete.go View File

@@ -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)
}

+ 2
- 0
api/http/handler/endpoints/endpoint_extension_add.go View File

@@ -1,5 +1,7 @@
package endpoints

// TODO: legacy extension management

import (
"net/http"



+ 2
- 0
api/http/handler/endpoints/endpoint_extension_remove.go View File

@@ -1,5 +1,7 @@
package endpoints

// TODO: legacy extension management

import (
"net/http"



+ 79
- 0
api/http/handler/extensions/extension_create.go View File

@@ -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)
}

+ 38
- 0
api/http/handler/extensions/extension_delete.go View File

@@ -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)
}

+ 59
- 0
api/http/handler/extensions/extension_inspect.go View File

@@ -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)
}

+ 55
- 0
api/http/handler/extensions/extension_list.go View File

@@ -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=<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
}
}
}

+ 56
- 0
api/http/handler/extensions/extension_update.go View File

@@ -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)
}

+ 37
- 0
api/http/handler/extensions/handler.go View File

@@ -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
}

+ 4
- 0
api/http/handler/handler.go View File

@@ -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"):


+ 13
- 5
api/http/handler/registries/handler.go View File

@@ -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
}

+ 78
- 0
api/http/handler/registries/proxy.go View File

@@ -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
}

+ 137
- 0
api/http/handler/registries/registry_configure.go View File

@@ -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 := &registryConfigurePayload{}
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)
}

+ 5
- 0
api/http/handler/registries/registry_create.go View File

@@ -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,


+ 1
- 1
api/http/proxy/factory.go View File

@@ -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) {


+ 77
- 48
api/http/proxy/manager.go View File

@@ -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,30 +52,13 @@ func NewManager(parameters *ManagerParams) *Manager {
}
}

func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration) (http.Handler, error) {
if endpointURL.Scheme == "tcp" {
if tlsConfig.TLS || tlsConfig.TLSSkipVerify {
return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, tlsConfig, false)
}
return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false), nil
}
return manager.proxyFactory.newLocalProxy(endpointURL.Path), nil
}

func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}

switch endpoint.Type {
case portainer.AgentOnDockerEnvironment:
return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, true)
case portainer.AzureEnvironment:
return newAzureProxy(&endpoint.AzureCredentials)
default:
return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig)
// 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.
@@ -82,46 +73,84 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
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)
// 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)
}

// DeleteProxy deletes the proxy associated to a key
func (manager *Manager) DeleteProxy(key string) {
manager.proxies.Remove(key)
}
// 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]

// 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)
extensionURL, err := url.Parse(address)
if err != nil {
return nil, err
}

proxy := manager.proxyFactory.newHTTPProxy(extensionURL)
manager.extensionProxies.Set(key, proxy)
manager.extensionProxies.Set(strconv.Itoa(int(extensionID)), 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)
// 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)
}

// 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)
// 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 {
return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, tlsConfig, false)
}
return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false), nil
}
return manager.proxyFactory.newLocalProxy(endpointURL.Path), nil
}

func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}

switch endpoint.Type {
case portainer.AgentOnDockerEnvironment:
return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, true)
case portainer.AzureEnvironment:
return newAzureProxy(&endpoint.AzureCredentials)
default:
return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig)
}
}

+ 11
- 0
api/http/server.go View File

@@ -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,


+ 91
- 9
api/portainer.go View File

@@ -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
)

+ 1
- 0
app/__module.js View File

@@ -22,6 +22,7 @@ angular.module('portainer', [
'portainer.agent',
'portainer.azure',
'portainer.docker',
'portainer.extensions',
'extension.storidge',
'rzModule',
'moment-picker'


+ 4
- 2
app/app.js View File

@@ -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'});
}
});
}



+ 1
- 0
app/constants.js View File

@@ -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')


+ 3
- 0
app/extensions/_module.js View File

@@ -0,0 +1,3 @@
angular.module('portainer.extensions', [
'portainer.extensions.registrymanagement'
]);

+ 41
- 0
app/extensions/registry-management/_module.js View File

@@ -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);
}]);

+ 83
- 0
app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html View File

@@ -0,0 +1,83 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover table-filters nowrap-cells">
<thead>
<tr>
<th>
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<a ng-click="$ctrl.changeOrderBy('Name')">
Repository
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('TagsCount')">
Tags count
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'TagsCount' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'TagsCount' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" />
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="portainer.registries.registry.repository({repository: item.Name})" class="monospaced"
title="{{ item.Name }}">{{ item.Name }}</a>
</td>
<td>{{ item.TagsCount }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="5" class="text-center text-muted">No repository available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
{{ $ctrl.state.selectedItemCount }} item(s) selected
</div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

+ 13
- 0
app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js View File

@@ -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: '<'
}
});

+ 112
- 0
app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html View File

@@ -0,0 +1,112 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{
$ctrl.titleText }}
</div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Os/Architecture</th>
<th>
<a ng-click="$ctrl.changeOrderBy('ImageId')">
Image ID
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ImageId' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ImageId' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Size')">
Size
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" />
<label for="select_{{ $index }}"></label>
</span>
{{ item.Name }}
</td>
<td>{{ item.Os }}/{{ item.Architecture }}</td>
<td>{{ item.ImageId | truncate:40 }}</td>
<td>{{ item.Size | humansize }}</td>
<td>
<span ng-if="!item.Modified">
<a class="interactive" ng-click="item.Modified = true; item.NewName = item.Name; $event.stopPropagation();">
<i class="fa fa-tag" aria-hidden="true"></i> Retag
</a>
</span>
<span ng-if="item.Modified">
<input class="input-sm" type="text" ng-model="item.NewName" on-enter-key="$ctrl.retagAction(item)"
auto-focus ng-click="$event.stopPropagation();" />
<a class="interactive" ng-click="item.Modified = false; $event.stopPropagation();"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="$ctrl.retagAction(item); $event.stopPropagation();"><i class="fa fa-check-square"></i></a>
</span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No tag available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
{{ $ctrl.state.selectedItemCount }} item(s) selected
</div>
<div class="paginationControls">