mirror of https://github.com/portainer/portainer
feat(extensions): add the ability to upload and enable an extension (#3345)
* feat(extensions): offline mode mockup * feat(extensions): offline mode mockup * feat(api): add support for extensionUpload API operation * feat(extensions): offline extension upload * feat(api): better support for extensions in offline mode * feat(extension): update offline description * feat(api): introduce local extension manifest * fix(api): fix LocalExtensionManifestFile value * feat(api): use a 5second timeout for online extension infos * feat(extensions): add download archive link * feat(extensions): add support for offline update * fix(api): fix issues with offline install and online updates of extensions * fix(extensions): fix extensions link URL * fix(extension): hide screenshot in offline modepull/3392/head
parent
8b0eb71d69
commit
a85f0058ee
|
@ -17,8 +17,17 @@ func UnzipArchive(archiveData []byte, dest string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, zipFile := range zipReader.File {
|
for _, zipFile := range zipReader.File {
|
||||||
|
err := extractFileFromArchive(zipFile, dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
f, err := zipFile.Open()
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFileFromArchive(file *zip.File, dest string) error {
|
||||||
|
f, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -29,9 +38,9 @@ func UnzipArchive(archiveData []byte, dest string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fpath := filepath.Join(dest, zipFile.Name)
|
fpath := filepath.Join(dest, file.Name)
|
||||||
|
|
||||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode())
|
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -41,8 +50,5 @@ func UnzipArchive(archiveData []byte, dest string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
outFile.Close()
|
return outFile.Close()
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,10 +4,12 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -20,7 +22,8 @@ import (
|
||||||
"github.com/portainer/portainer/api/http/client"
|
"github.com/portainer/portainer/api/http/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/"
|
var extensionDownloadBaseURL = portainer.AssetsServerURL + "/extensions/"
|
||||||
|
var extensionVersionRegexp = regexp.MustCompile(`\d+(\.\d+)+`)
|
||||||
|
|
||||||
var extensionBinaryMap = map[portainer.ExtensionID]string{
|
var extensionBinaryMap = map[portainer.ExtensionID]string{
|
||||||
portainer.RegistryManagementExtension: "extension-registry-management",
|
portainer.RegistryManagementExtension: "extension-registry-management",
|
||||||
|
@ -50,20 +53,11 @@ func processKey(ID portainer.ExtensionID) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildExtensionURL(extension *portainer.Extension) string {
|
func buildExtensionURL(extension *portainer.Extension) string {
|
||||||
extensionURL := extensionDownloadBaseURL
|
return fmt.Sprintf("%s%s-%s-%s-%s.zip", extensionDownloadBaseURL, extensionBinaryMap[extension.ID], runtime.GOOS, runtime.GOARCH, extension.Version)
|
||||||
extensionURL += extensionBinaryMap[extension.ID]
|
|
||||||
extensionURL += "-" + runtime.GOOS + "-" + runtime.GOARCH
|
|
||||||
extensionURL += "-" + extension.Version
|
|
||||||
extensionURL += ".zip"
|
|
||||||
return extensionURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildExtensionPath(binaryPath string, extension *portainer.Extension) string {
|
func buildExtensionPath(binaryPath string, extension *portainer.Extension) string {
|
||||||
|
extensionFilename := fmt.Sprintf("%s-%s-%s-%s", extensionBinaryMap[extension.ID], runtime.GOOS, runtime.GOARCH, extension.Version)
|
||||||
extensionFilename := extensionBinaryMap[extension.ID]
|
|
||||||
extensionFilename += "-" + runtime.GOOS + "-" + runtime.GOARCH
|
|
||||||
extensionFilename += "-" + extension.Version
|
|
||||||
|
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
extensionFilename += ".exe"
|
extensionFilename += ".exe"
|
||||||
}
|
}
|
||||||
|
@ -76,12 +70,21 @@ func buildExtensionPath(binaryPath string, extension *portainer.Extension) strin
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchExtensionDefinitions will fetch the list of available
|
// FetchExtensionDefinitions will fetch the list of available
|
||||||
// extension definitions from the official Portainer assets server
|
// extension definitions from the official Portainer assets server.
|
||||||
|
// If it cannot retrieve the data from the Internet it will fallback to the locally cached
|
||||||
|
// manifest file.
|
||||||
func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extension, error) {
|
func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extension, error) {
|
||||||
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30)
|
var extensionData []byte
|
||||||
|
|
||||||
|
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 5)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] [exec,extensions] [message: unable to retrieve extensions manifest via Internet. Extensions will be retrieved from local cache and might not be up to date] [err: %s]", err)
|
||||||
|
|
||||||
|
extensionData, err = manager.fileService.GetFileContent(portainer.LocalExtensionManifestFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var extensions []portainer.Extension
|
var extensions []portainer.Extension
|
||||||
err = json.Unmarshal(extensionData, &extensions)
|
err = json.Unmarshal(extensionData, &extensions)
|
||||||
|
@ -92,6 +95,37 @@ func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extens
|
||||||
return extensions, nil
|
return extensions, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InstallExtension will install the extension from an archive. It will extract the extension version number from
|
||||||
|
// the archive file name first and return an error if the file name is not valid (cannot find extension version).
|
||||||
|
// It will then extract the archive and execute the EnableExtension function to enable the extension.
|
||||||
|
// Since we're missing information about this extension (stored on Portainer.io server) we need to assume
|
||||||
|
// default information based on the extension ID.
|
||||||
|
func (manager *ExtensionManager) InstallExtension(extension *portainer.Extension, licenseKey string, archiveFileName string, extensionArchive []byte) error {
|
||||||
|
extensionVersion := extensionVersionRegexp.FindString(archiveFileName)
|
||||||
|
if extensionVersion == "" {
|
||||||
|
return errors.New("invalid extension archive filename: unable to retrieve extension version")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := manager.fileService.ExtractExtensionArchive(extensionArchive)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch extension.ID {
|
||||||
|
case portainer.RegistryManagementExtension:
|
||||||
|
extension.Name = "Registry Manager"
|
||||||
|
case portainer.OAuthAuthenticationExtension:
|
||||||
|
extension.Name = "External Authentication"
|
||||||
|
case portainer.RBACExtension:
|
||||||
|
extension.Name = "Role-Based Access Control"
|
||||||
|
}
|
||||||
|
extension.ShortDescription = "Extension enabled offline"
|
||||||
|
extension.Version = extensionVersion
|
||||||
|
extension.Available = true
|
||||||
|
|
||||||
|
return manager.EnableExtension(extension, licenseKey)
|
||||||
|
}
|
||||||
|
|
||||||
// EnableExtension will check for the existence of the extension binary on the filesystem
|
// 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.
|
// 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
|
// After installing the binary on the filesystem, it will execute the binary in license check
|
||||||
|
@ -268,6 +302,7 @@ func (manager *ExtensionManager) startExtensionProcess(extension *portainer.Exte
|
||||||
|
|
||||||
err := extensionProcess.Start()
|
err := extensionProcess.Start()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[DEBUG] [exec,extension] [message: unable to start extension process] [err: %s]", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -87,12 +87,7 @@ func (service *Service) GetBinaryFolder() string {
|
||||||
// ExtractExtensionArchive extracts the content of an extension archive
|
// ExtractExtensionArchive extracts the content of an extension archive
|
||||||
// specified as raw data into the binary store on the filesystem
|
// specified as raw data into the binary store on the filesystem
|
||||||
func (service *Service) ExtractExtensionArchive(data []byte) error {
|
func (service *Service) ExtractExtensionArchive(data []byte) error {
|
||||||
err := archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath))
|
return archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath))
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveDirectory removes a directory on the filesystem.
|
// RemoveDirectory removes a directory on the filesystem.
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -87,6 +88,7 @@ func Get(url string, timeout int) ([]byte, error) {
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
|
|
||||||
if response.StatusCode != http.StatusOK {
|
if response.StatusCode != http.StatusOK {
|
||||||
|
log.Printf("[ERROR] [http,client] [message: unexpected status code] [status_code: %d]", response.StatusCode)
|
||||||
return nil, errInvalidResponseStatus
|
return nil, errInvalidResponseStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
package extensions
|
package extensions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/coreos/go-semver/semver"
|
"github.com/portainer/portainer/api/http/client"
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
"github.com/portainer/portainer/api"
|
"github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/http/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GET request on /api/extensions/:id
|
// GET request on /api/extensions/:id
|
||||||
|
@ -18,46 +17,39 @@ func (handler *Handler) extensionInspect(w http.ResponseWriter, r *http.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
extensionID := portainer.ExtensionID(extensionIdentifier)
|
extensionID := portainer.ExtensionID(extensionIdentifier)
|
||||||
|
|
||||||
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30)
|
definitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions informations", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
var extensions []portainer.Extension
|
localExtension, err := handler.ExtensionService.Extension(extensionID)
|
||||||
err = json.Unmarshal(extensionData, &extensions)
|
if err != nil && err != portainer.ErrObjectNotFound {
|
||||||
if err != nil {
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension information from the database", err}
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external extension definitions", err}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var extension portainer.Extension
|
var extension portainer.Extension
|
||||||
for _, p := range extensions {
|
var extensionDefinition portainer.Extension
|
||||||
if p.ID == extensionID {
|
|
||||||
extension = p
|
for _, definition := range definitions {
|
||||||
if extension.DescriptionURL != "" {
|
if definition.ID == extensionID {
|
||||||
description, _ := client.Get(extension.DescriptionURL, 10)
|
extensionDefinition = definition
|
||||||
extension.Description = string(description)
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
storedExtension, err := handler.ExtensionService.Extension(extensionID)
|
if localExtension == nil {
|
||||||
if err == portainer.ErrObjectNotFound {
|
extension = extensionDefinition
|
||||||
return response.JSON(w, extension)
|
} else {
|
||||||
} else if err != nil {
|
extension = *localExtension
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension.Enabled = storedExtension.Enabled
|
mergeExtensionAndDefinition(&extension, &extensionDefinition)
|
||||||
|
|
||||||
extensionVer := semver.New(extension.Version)
|
description, _ := client.Get(extension.DescriptionURL, 5)
|
||||||
pVer := semver.New(storedExtension.Version)
|
extension.Description = string(description)
|
||||||
|
|
||||||
if pVer.LessThan(*extensionVer) {
|
|
||||||
extension.UpdateAvailable = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, extension)
|
return response.JSON(w, extension)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,54 +3,28 @@ package extensions
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/coreos/go-semver/semver"
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
"github.com/portainer/portainer/api"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GET request on /api/extensions?store=<store>
|
// GET request on /api/extensions?store=<store>
|
||||||
func (handler *Handler) extensionList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) extensionList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
storeDetails, _ := request.RetrieveBooleanQueryParameter(r, "store", true)
|
fetchManifestInformation, _ := request.RetrieveBooleanQueryParameter(r, "store", true)
|
||||||
|
|
||||||
extensions, err := handler.ExtensionService.Extensions()
|
extensions, err := handler.ExtensionService.Extensions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions from the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions from the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
if storeDetails {
|
if fetchManifestInformation {
|
||||||
definitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
|
definitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions informations", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
for idx := range definitions {
|
extensions = mergeExtensionsAndDefinitions(extensions, definitions)
|
||||||
associateExtensionData(&definitions[idx], extensions)
|
|
||||||
}
|
|
||||||
|
|
||||||
extensions = definitions
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, extensions)
|
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
|
|
||||||
definition.License.Valid = extension.License.Valid
|
|
||||||
|
|
||||||
definitionVersion := semver.New(definition.Version)
|
|
||||||
extensionVersion := semver.New(extension.Version)
|
|
||||||
if extensionVersion.LessThan(*definitionVersion) {
|
|
||||||
definition.UpdateAvailable = true
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
package extensions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
"github.com/portainer/libhttp/request"
|
||||||
|
"github.com/portainer/libhttp/response"
|
||||||
|
"github.com/portainer/portainer/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type extensionUploadPayload struct {
|
||||||
|
License string
|
||||||
|
ExtensionArchive []byte
|
||||||
|
ArchiveFileName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *extensionUploadPayload) Validate(r *http.Request) error {
|
||||||
|
license, err := request.RetrieveMultiPartFormValue(r, "License", false)
|
||||||
|
if err != nil {
|
||||||
|
return portainer.Error("Invalid license")
|
||||||
|
}
|
||||||
|
payload.License = license
|
||||||
|
|
||||||
|
fileData, fileName, err := request.RetrieveMultiPartFormFile(r, "file")
|
||||||
|
if err != nil {
|
||||||
|
return portainer.Error("Invalid extension archive file. Ensure that the file is uploaded correctly")
|
||||||
|
}
|
||||||
|
payload.ExtensionArchive = fileData
|
||||||
|
payload.ArchiveFileName = fileName
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) extensionUpload(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
payload := &extensionUploadPayload{}
|
||||||
|
err := payload.Validate(r)
|
||||||
|
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)
|
||||||
|
|
||||||
|
extension := &portainer.Extension{
|
||||||
|
ID: extensionID,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = handler.ExtensionManager.DisableExtension(extension)
|
||||||
|
|
||||||
|
err = handler.ExtensionManager.InstallExtension(extension, payload.License, payload.ArchiveFileName, payload.ExtensionArchive)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to install extension", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension.Enabled = true
|
||||||
|
|
||||||
|
if extension.ID == portainer.RBACExtension {
|
||||||
|
err = handler.upgradeRBACData()
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "An error occured during database update", 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)
|
||||||
|
}
|
|
@ -3,6 +3,8 @@ package extensions
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/coreos/go-semver/semver"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
"github.com/portainer/portainer/api"
|
"github.com/portainer/portainer/api"
|
||||||
|
@ -30,6 +32,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet)
|
||||||
h.Handle("/extensions",
|
h.Handle("/extensions",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost)
|
bouncer.AdminAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/extensions/upload",
|
||||||
|
bouncer.AdminAccess(httperror.LoggerHandler(h.extensionUpload))).Methods(http.MethodPost)
|
||||||
h.Handle("/extensions/{id}",
|
h.Handle("/extensions/{id}",
|
||||||
bouncer.AdminAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet)
|
bouncer.AdminAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet)
|
||||||
h.Handle("/extensions/{id}",
|
h.Handle("/extensions/{id}",
|
||||||
|
@ -39,3 +43,44 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
|
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergeExtensionsAndDefinitions(extensions, definitions []portainer.Extension) []portainer.Extension {
|
||||||
|
for _, definition := range definitions {
|
||||||
|
foundInDB := false
|
||||||
|
|
||||||
|
for idx, extension := range extensions {
|
||||||
|
if extension.ID == definition.ID {
|
||||||
|
foundInDB = true
|
||||||
|
mergeExtensionAndDefinition(&extensions[idx], &definition)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundInDB {
|
||||||
|
extensions = append(extensions, definition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeExtensionAndDefinition(extension, definition *portainer.Extension) {
|
||||||
|
extension.Name = definition.Name
|
||||||
|
extension.ShortDescription = definition.ShortDescription
|
||||||
|
extension.Deal = definition.Deal
|
||||||
|
extension.Available = definition.Available
|
||||||
|
extension.DescriptionURL = definition.DescriptionURL
|
||||||
|
extension.Images = definition.Images
|
||||||
|
extension.Logo = definition.Logo
|
||||||
|
extension.Price = definition.Price
|
||||||
|
extension.PriceDescription = definition.PriceDescription
|
||||||
|
extension.ShopURL = definition.ShopURL
|
||||||
|
|
||||||
|
definitionVersion := semver.New(definition.Version)
|
||||||
|
extensionVersion := semver.New(extension.Version)
|
||||||
|
if extensionVersion.LessThan(*definitionVersion) {
|
||||||
|
extension.UpdateAvailable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
extension.Version = definition.Version
|
||||||
|
}
|
||||||
|
|
|
@ -891,6 +891,7 @@ type (
|
||||||
// ExtensionManager represents a service used to manage extensions
|
// ExtensionManager represents a service used to manage extensions
|
||||||
ExtensionManager interface {
|
ExtensionManager interface {
|
||||||
FetchExtensionDefinitions() ([]Extension, error)
|
FetchExtensionDefinitions() ([]Extension, error)
|
||||||
|
InstallExtension(extension *Extension, licenseKey string, archiveFileName string, extensionArchive []byte) error
|
||||||
EnableExtension(extension *Extension, licenseKey string) error
|
EnableExtension(extension *Extension, licenseKey string) error
|
||||||
DisableExtension(extension *Extension) error
|
DisableExtension(extension *Extension) error
|
||||||
UpdateExtension(extension *Extension, version string) error
|
UpdateExtension(extension *Extension, version string) error
|
||||||
|
@ -942,6 +943,8 @@ const (
|
||||||
ExtensionServer = "localhost"
|
ExtensionServer = "localhost"
|
||||||
// DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance
|
// DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance
|
||||||
DefaultEdgeAgentCheckinIntervalInSeconds = 5
|
DefaultEdgeAgentCheckinIntervalInSeconds = 5
|
||||||
|
// LocalExtensionManifestFile represents the name of the local manifest file for extensions
|
||||||
|
LocalExtensionManifestFile = "/app/extensions.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
@ -2,7 +2,8 @@ import _ from 'lodash-es';
|
||||||
import { ExtensionViewModel } from '../../models/extension';
|
import { ExtensionViewModel } from '../../models/extension';
|
||||||
|
|
||||||
angular.module('portainer.app')
|
angular.module('portainer.app')
|
||||||
.factory('ExtensionService', ['$q', 'Extension', 'StateManager', '$async', function ExtensionServiceFactory($q, Extension, StateManager, $async) {
|
.factory('ExtensionService', ['$q', 'Extension', 'StateManager', '$async', 'FileUploadService',
|
||||||
|
function ExtensionServiceFactory($q, Extension, StateManager, $async, FileUploadService) {
|
||||||
'use strict';
|
'use strict';
|
||||||
var service = {};
|
var service = {};
|
||||||
|
|
||||||
|
@ -20,9 +21,13 @@ angular.module('portainer.app')
|
||||||
service.extensionEnabled = extensionEnabled;
|
service.extensionEnabled = extensionEnabled;
|
||||||
service.retrieveAndSaveEnabledExtensions = retrieveAndSaveEnabledExtensions;
|
service.retrieveAndSaveEnabledExtensions = retrieveAndSaveEnabledExtensions;
|
||||||
|
|
||||||
function enable(license) {
|
function enable(license, extensionFile) {
|
||||||
|
if (extensionFile) {
|
||||||
|
return FileUploadService.uploadExtension(license, extensionFile);
|
||||||
|
} else {
|
||||||
return Extension.create({ license: license }).$promise;
|
return Extension.create({ license: license }).$promise;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function update(id, version) {
|
function update(id, version) {
|
||||||
return Extension.update({ id: id, version: version }).$promise;
|
return Extension.update({ id: id, version: version }).$promise;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { jsonObjectsToArrayHandler, genericHandler } from '../../docker/rest/response/handlers';
|
import {genericHandler, jsonObjectsToArrayHandler} from '../../docker/rest/response/handlers';
|
||||||
|
|
||||||
angular.module('portainer.app')
|
angular.module('portainer.app')
|
||||||
.factory('FileUploadService', ['$q', 'Upload', 'EndpointProvider', function FileUploadFactory($q, Upload, EndpointProvider) {
|
.factory('FileUploadService', ['$q', 'Upload', 'EndpointProvider', function FileUploadFactory($q, Upload, EndpointProvider) {
|
||||||
|
@ -169,5 +169,18 @@ angular.module('portainer.app')
|
||||||
return $q.all(queue);
|
return $q.all(queue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
service.uploadExtension = function(license, extensionFile) {
|
||||||
|
const payload = {
|
||||||
|
License: license,
|
||||||
|
file: extensionFile,
|
||||||
|
ArchiveFileName: extensionFile.name
|
||||||
|
};
|
||||||
|
return Upload.upload({
|
||||||
|
url: 'api/extensions/upload',
|
||||||
|
data: payload,
|
||||||
|
ignoreLoadingBar: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return service;
|
return service;
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -42,9 +42,23 @@
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<span class="small text-muted">
|
<p class="small text-muted" ng-if="!state.offlineActivation">
|
||||||
Ensure that you have a valid license.
|
Portainer will download the latest version of the extension. Ensure that you have a valid license.
|
||||||
</span>
|
</p>
|
||||||
|
<p class="small text-muted" ng-if="state.offlineActivation">
|
||||||
|
You will need to upload the extension archive manually. Ensure that you have a valid license.
|
||||||
|
</p>
|
||||||
|
<p class="small text-muted" ng-if="state.offlineActivation">
|
||||||
|
You can download the latest version of our extensions <a target="_blank" href="https://downloads.portainer.io/extensions.zip">here</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a class="small interactive" ng-if="!state.offlineActivation" ng-click="state.offlineActivation = true;">
|
||||||
|
<i class="fa fa-toggle-off space-right" aria-hidden="true"></i> Switch to offline activation
|
||||||
|
</a>
|
||||||
|
<a class="small interactive" ng-if="state.offlineActivation" ng-click="state.offlineActivation = false;">
|
||||||
|
<i class="fa fa-wifi space-right" aria-hidden="true"></i> Switch to online activation
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -58,14 +72,25 @@
|
||||||
<div class="form-group" ng-show="extensionEnableForm.extension_license.$invalid">
|
<div class="form-group" ng-show="extensionEnableForm.extension_license.$invalid">
|
||||||
<div class="col-sm-12 small text-warning">
|
<div class="col-sm-12 small text-warning">
|
||||||
<div ng-messages="extensionEnableForm.extension_license.$error">
|
<div ng-messages="extensionEnableForm.extension_license.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||||
<p ng-message="invalidLicense"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Invalid license format.</p>
|
<p ng-message="invalidLicense"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Invalid license format.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" ng-if="state.offlineActivation">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" ngf-select ng-model="formValues.ExtensionFile" style="margin-left: 0px;">Select file</button>
|
||||||
|
<span style="margin-left: 5px;">
|
||||||
|
{{ formValues.ExtensionFile.name }}
|
||||||
|
<i class="fa fa-times red-icon" ng-if="!formValues.ExtensionFile" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<button type="button" class="btn btn-primary btn-sm" ng-click="enableExtension()" ng-disabled="state.actionInProgress || !extensionEnableForm.$valid" button-spinner="state.actionInProgress" style="margin-left: 0px;">
|
<button type="button" class="btn btn-primary btn-sm" ng-click="enableExtension()" ng-disabled="state.actionInProgress || !extensionEnableForm.$valid || (state.offlineActivation && !formValues.ExtensionFile)" button-spinner="state.actionInProgress" style="margin-left: 0px;">
|
||||||
<span ng-hide="state.actionInProgress">Enable extension</span>
|
<span ng-hide="state.actionInProgress">Enable extension</span>
|
||||||
<span ng-show="state.actionInProgress">Enabling extension...</span>
|
<span ng-show="state.actionInProgress">Enabling extension...</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -10,7 +10,8 @@ angular.module('portainer.app')
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
License: ''
|
License: '',
|
||||||
|
ExtensionFile: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function initView() {
|
function initView() {
|
||||||
|
@ -25,10 +26,11 @@ angular.module('portainer.app')
|
||||||
}
|
}
|
||||||
|
|
||||||
$scope.enableExtension = function() {
|
$scope.enableExtension = function() {
|
||||||
var license = $scope.formValues.License;
|
const license = $scope.formValues.License;
|
||||||
|
const extensionFile = $scope.formValues.ExtensionFile;
|
||||||
|
|
||||||
$scope.state.actionInProgress = true;
|
$scope.state.actionInProgress = true;
|
||||||
ExtensionService.enable(license)
|
ExtensionService.enable(license, extensionFile)
|
||||||
.then(function onSuccess() {
|
.then(function onSuccess() {
|
||||||
return ExtensionService.retrieveAndSaveEnabledExtensions();
|
return ExtensionService.retrieveAndSaveEnabledExtensions();
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
|
|
|
@ -68,10 +68,10 @@
|
||||||
<btn class="btn btn-primary btn-sm" style="width: 100%; margin-left: 0;" disabled>Coming soon</btn>
|
<btn class="btn btn-primary btn-sm" style="width: 100%; margin-left: 0;" disabled>Coming soon</btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 15px;" ng-if="extension.Enabled && extension.UpdateAvailable">
|
<div style="margin-top: 15px;" ng-if="extension.Enabled && extension.UpdateAvailable && !state.offlineUpdate">
|
||||||
<button type="button" class="btn btn-primary btn-sm" ng-click="updateExtension(extension)" ng-disabled="state.updateInProgress" button-spinner="state.updateInProgress" style="width: 100%; margin-left: 0;">
|
<button type="button" class="btn btn-primary btn-sm" ng-click="updateExtensionOnline(extension)" ng-disabled="state.onlineUpdateInProgress" button-spinner="state.onlineUpdateInProgress" style="width: 100%; margin-left: 0;">
|
||||||
<span ng-hide="state.updateInProgress">Update</span>
|
<span ng-hide="state.onlineUpdateInProgress">Update via Internet</span>
|
||||||
<span ng-show="state.updateInProgress">Updating extension...</span>
|
<span ng-show="state.onlineUpdateInProgress">Updating extension...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -82,10 +82,60 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px;" ng-if="extension.Enabled && extension.UpdateAvailable">
|
||||||
|
<p>
|
||||||
|
<a class="small interactive" ng-if="!state.offlineUpdate" ng-click="state.offlineUpdate = true;">
|
||||||
|
<i class="fa fa-toggle-off space-right" aria-hidden="true"></i> Switch to offline update
|
||||||
|
</a>
|
||||||
|
<a class="small interactive" ng-if="state.offlineUpdate" ng-click="state.offlineUpdate = false;">
|
||||||
|
<i class="fa fa-wifi space-right" aria-hidden="true"></i> Switch to online update
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" ng-if="extension && state.offlineUpdate">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-body>
|
||||||
|
<form class="form-horizontal">
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
<span>
|
||||||
|
Offline update
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<p class="small text-muted" ng-if="state.offlineUpdate">
|
||||||
|
You will need to upload the extension archive manually. You can download the latest version of our extensions <a target="_blank" href="https://download.portainer.io/extensions.zip">here</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" ng-if="state.offlineUpdate">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" ngf-select ng-model="formValues.ExtensionFile" style="margin-left: 0px;">Select file</button>
|
||||||
|
<span style="margin-left: 5px;">
|
||||||
|
{{ formValues.ExtensionFile.name }}
|
||||||
|
<i class="fa fa-times red-icon" ng-if="!formValues.ExtensionFile" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" ng-click="updateExtensionOffline(extension)" ng-disabled="state.offlineUpdateInProgress || !formValues.ExtensionFile" button-spinner="state.offlineUpdateInProgress" style="margin-left: 0px;">
|
||||||
|
<span ng-hide="state.offlineUpdateInProgress">Update extension</span>
|
||||||
|
<span ng-show="state.offlineUpdateInProgress">Updating extension...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</rd-widget-body>
|
</rd-widget-body>
|
||||||
</rd-widget>
|
</rd-widget>
|
||||||
</div>
|
</div>
|
||||||
|
@ -107,7 +157,7 @@
|
||||||
<div class="small text-muted">
|
<div class="small text-muted">
|
||||||
<p>
|
<p>
|
||||||
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i>
|
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i>
|
||||||
Description for this extension unavailable at the moment.
|
Unable to provide a description in an offline environment.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -116,7 +166,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row" ng-if="extension">
|
<div class="row" ng-if="extension.Description && extension.Images">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<rd-widget>
|
<rd-widget>
|
||||||
<rd-widget-body>
|
<rd-widget-body>
|
||||||
|
|
|
@ -3,15 +3,19 @@ angular.module('portainer.app')
|
||||||
function ($q, $scope, $transition$, $state, ExtensionService, Notifications, ModalService) {
|
function ($q, $scope, $transition$, $state, ExtensionService, Notifications, ModalService) {
|
||||||
|
|
||||||
$scope.state = {
|
$scope.state = {
|
||||||
updateInProgress: false,
|
onlineUpdateInProgress: false,
|
||||||
deleteInProgress: false
|
offlineUpdateInProgress: false,
|
||||||
|
deleteInProgress: false,
|
||||||
|
offlineUpdate: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
instances: 1
|
instances: 1,
|
||||||
|
extensionFile: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.updateExtension = updateExtension;
|
$scope.updateExtensionOnline = updateExtensionOnline;
|
||||||
|
$scope.updateExtensionOffline = updateExtensionOffline;
|
||||||
$scope.deleteExtension = deleteExtension;
|
$scope.deleteExtension = deleteExtension;
|
||||||
$scope.enlargeImage = enlargeImage;
|
$scope.enlargeImage = enlargeImage;
|
||||||
|
|
||||||
|
@ -24,7 +28,7 @@ function ($q, $scope, $transition$, $state, ExtensionService, Notifications, Mod
|
||||||
ExtensionService.delete(extension.Id)
|
ExtensionService.delete(extension.Id)
|
||||||
.then(function onSuccess() {
|
.then(function onSuccess() {
|
||||||
Notifications.success('Extension successfully deleted');
|
Notifications.success('Extension successfully deleted');
|
||||||
$state.reload();
|
$state.go('portainer.extensions');
|
||||||
})
|
})
|
||||||
.catch(function onError(err) {
|
.catch(function onError(err) {
|
||||||
Notifications.error('Failure', err, 'Unable to delete extension');
|
Notifications.error('Failure', err, 'Unable to delete extension');
|
||||||
|
@ -34,8 +38,8 @@ function ($q, $scope, $transition$, $state, ExtensionService, Notifications, Mod
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateExtension(extension) {
|
function updateExtensionOnline(extension) {
|
||||||
$scope.state.updateInProgress = true;
|
$scope.state.onlineUpdateInProgress = true;
|
||||||
ExtensionService.update(extension.Id, extension.Version)
|
ExtensionService.update(extension.Id, extension.Version)
|
||||||
.then(function onSuccess() {
|
.then(function onSuccess() {
|
||||||
Notifications.success('Extension successfully updated');
|
Notifications.success('Extension successfully updated');
|
||||||
|
@ -45,7 +49,24 @@ function ($q, $scope, $transition$, $state, ExtensionService, Notifications, Mod
|
||||||
Notifications.error('Failure', err, 'Unable to update extension');
|
Notifications.error('Failure', err, 'Unable to update extension');
|
||||||
})
|
})
|
||||||
.finally(function final() {
|
.finally(function final() {
|
||||||
$scope.state.updateInProgress = false;
|
$scope.state.onlineUpdateInProgress = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExtensionOffline(extension) {
|
||||||
|
$scope.state.offlineUpdateInProgress = true;
|
||||||
|
const extensionFile = $scope.formValues.ExtensionFile;
|
||||||
|
|
||||||
|
ExtensionService.enable(extension.License.LicenseKey, extensionFile)
|
||||||
|
.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.offlineUpdateInProgress = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Id": 3,
|
||||||
|
"Name": "Role-Based Access Control",
|
||||||
|
"ShortDescription": "Fine grained access control against Portainer and deployed resources",
|
||||||
|
"Price": "See website for pricing",
|
||||||
|
"PriceDescription": "Price per instance per year",
|
||||||
|
"Deal": false,
|
||||||
|
"Available": true,
|
||||||
|
"Version": "1.0.0",
|
||||||
|
"DescriptionURL": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/description_rbac.html",
|
||||||
|
"ShopURL": "https://portainer.io/checkout/?add-to-cart=2890",
|
||||||
|
"Logo": "fa-user-lock",
|
||||||
|
"Images": [
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rbac01.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rbac02.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rbac03.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": 2,
|
||||||
|
"Name": "External Authentication",
|
||||||
|
"ShortDescription": "Enable single sign-on authentication via OAuth",
|
||||||
|
"Price": "See website for pricing",
|
||||||
|
"PriceDescription": "Price per instance per year",
|
||||||
|
"Deal": false,
|
||||||
|
"Available": true,
|
||||||
|
"Version": "1.0.0",
|
||||||
|
"DescriptionURL": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/description_external_authentication.html",
|
||||||
|
"ShopURL": "https://portainer.io/checkout/?add-to-cart=992",
|
||||||
|
"Logo": "fa-users",
|
||||||
|
"Images": [
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/extauth01.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/extauth02.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/extauth03.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/extauth04.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/extauth05.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": 1,
|
||||||
|
"Name": "Registry Manager",
|
||||||
|
"ShortDescription": "Enable in-app registry management",
|
||||||
|
"Price": "See website for pricing",
|
||||||
|
"PriceDescription": "Price per instance per year",
|
||||||
|
"Deal": false,
|
||||||
|
"Available": true,
|
||||||
|
"Version": "1.1.0-dev",
|
||||||
|
"DescriptionURL": "https://portainer-io-assets.sfo2.digitaloceanspaces.com/description_registry_manager.html",
|
||||||
|
"ShopURL": "https://portainer.io/checkout/?add-to-cart=1164",
|
||||||
|
"Logo": "fa-database",
|
||||||
|
"Images": [
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rm01.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rm02.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rm03.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rm04.png",
|
||||||
|
"https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rm05.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
15
gruntfile.js
15
gruntfile.js
|
@ -44,12 +44,12 @@ module.exports = function(grunt) {
|
||||||
grunt.registerTask('build', [
|
grunt.registerTask('build', [
|
||||||
'build:server',
|
'build:server',
|
||||||
'build:client',
|
'build:client',
|
||||||
'copy:templates'
|
'copy:assets'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
grunt.registerTask('start:server', [
|
grunt.registerTask('start:server', [
|
||||||
'build:server',
|
'build:server',
|
||||||
'copy:templates',
|
'copy:assets',
|
||||||
'shell:run_container'
|
'shell:run_container'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -70,7 +70,7 @@ module.exports = function(grunt) {
|
||||||
'config:prod',
|
'config:prod',
|
||||||
'env:prod',
|
'env:prod',
|
||||||
'clean:all',
|
'clean:all',
|
||||||
'copy:templates',
|
'copy:assets',
|
||||||
'shell:build_binary:' + p + ':' + a,
|
'shell:build_binary:' + p + ':' + a,
|
||||||
'shell:download_docker_binary:' + p + ':' + a,
|
'shell:download_docker_binary:' + p + ':' + a,
|
||||||
'webpack:prod'
|
'webpack:prod'
|
||||||
|
@ -83,7 +83,7 @@ module.exports = function(grunt) {
|
||||||
'config:prod',
|
'config:prod',
|
||||||
'env:prod',
|
'env:prod',
|
||||||
'clean:all',
|
'clean:all',
|
||||||
'copy:templates',
|
'copy:assets',
|
||||||
'shell:build_binary_azuredevops:' + p + ':' + a,
|
'shell:build_binary_azuredevops:' + p + ':' + a,
|
||||||
'shell:download_docker_binary:' + p + ':' + a,
|
'shell:download_docker_binary:' + p + ':' + a,
|
||||||
'webpack:prod'
|
'webpack:prod'
|
||||||
|
@ -135,12 +135,17 @@ gruntfile_cfg.eslint = {
|
||||||
};
|
};
|
||||||
|
|
||||||
gruntfile_cfg.copy = {
|
gruntfile_cfg.copy = {
|
||||||
templates: {
|
assets: {
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
dest: '<%= root %>/',
|
dest: '<%= root %>/',
|
||||||
src: 'templates.json',
|
src: 'templates.json',
|
||||||
cwd: ''
|
cwd: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dest: '<%= root %>/',
|
||||||
|
src: 'extensions.json',
|
||||||
|
cwd: ''
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue