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,32 +17,38 @@ func UnzipArchive(archiveData []byte, dest string) error {
|
|||
}
|
||||
|
||||
for _, zipFile := range zipReader.File {
|
||||
|
||||
f, err := zipFile.Open()
|
||||
err := extractFileFromArchive(zipFile, dest)
|
||||
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
|
||||
}
|
||||
|
||||
func extractFileFromArchive(file *zip.File, dest string) error {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fpath := filepath.Join(dest, file.Name)
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return outFile.Close()
|
||||
}
|
||||
|
|
|
@ -4,10 +4,12 @@ import (
|
|||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -20,7 +22,8 @@ import (
|
|||
"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{
|
||||
portainer.RegistryManagementExtension: "extension-registry-management",
|
||||
|
@ -50,20 +53,11 @@ func processKey(ID portainer.ExtensionID) string {
|
|||
}
|
||||
|
||||
func buildExtensionURL(extension *portainer.Extension) string {
|
||||
extensionURL := extensionDownloadBaseURL
|
||||
extensionURL += extensionBinaryMap[extension.ID]
|
||||
extensionURL += "-" + runtime.GOOS + "-" + runtime.GOARCH
|
||||
extensionURL += "-" + extension.Version
|
||||
extensionURL += ".zip"
|
||||
return extensionURL
|
||||
return fmt.Sprintf("%s%s-%s-%s-%s.zip", extensionDownloadBaseURL, extensionBinaryMap[extension.ID], runtime.GOOS, runtime.GOARCH, extension.Version)
|
||||
}
|
||||
|
||||
func buildExtensionPath(binaryPath string, extension *portainer.Extension) string {
|
||||
|
||||
extensionFilename := extensionBinaryMap[extension.ID]
|
||||
extensionFilename += "-" + runtime.GOOS + "-" + runtime.GOARCH
|
||||
extensionFilename += "-" + extension.Version
|
||||
|
||||
extensionFilename := fmt.Sprintf("%s-%s-%s-%s", extensionBinaryMap[extension.ID], runtime.GOOS, runtime.GOARCH, extension.Version)
|
||||
if runtime.GOOS == "windows" {
|
||||
extensionFilename += ".exe"
|
||||
}
|
||||
|
@ -76,11 +70,20 @@ func buildExtensionPath(binaryPath string, extension *portainer.Extension) strin
|
|||
}
|
||||
|
||||
// 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) {
|
||||
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30)
|
||||
var extensionData []byte
|
||||
|
||||
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var extensions []portainer.Extension
|
||||
|
@ -92,6 +95,37 @@ func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extens
|
|||
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
|
||||
// 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
|
||||
|
@ -268,6 +302,7 @@ func (manager *ExtensionManager) startExtensionProcess(extension *portainer.Exte
|
|||
|
||||
err := extensionProcess.Start()
|
||||
if err != nil {
|
||||
log.Printf("[DEBUG] [exec,extension] [message: unable to start extension process] [err: %s]", err)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -87,12 +87,7 @@ func (service *Service) GetBinaryFolder() string {
|
|||
// 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
|
||||
return archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath))
|
||||
}
|
||||
|
||||
// RemoveDirectory removes a directory on the filesystem.
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
@ -87,6 +88,7 @@ func Get(url string, timeout int) ([]byte, error) {
|
|||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
log.Printf("[ERROR] [http,client] [message: unexpected status code] [status_code: %d]", response.StatusCode)
|
||||
return nil, errInvalidResponseStatus
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
)
|
||||
|
||||
// GET request on /api/extensions/:id
|
||||
|
@ -18,46 +17,39 @@ func (handler *Handler) extensionInspect(w http.ResponseWriter, r *http.Request)
|
|||
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)
|
||||
definitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
|
||||
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
|
||||
err = json.Unmarshal(extensionData, &extensions)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external extension definitions", err}
|
||||
localExtension, err := handler.ExtensionService.Extension(extensionID)
|
||||
if err != nil && err != portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension information from the database", err}
|
||||
}
|
||||
|
||||
var extension portainer.Extension
|
||||
for _, p := range extensions {
|
||||
if p.ID == extensionID {
|
||||
extension = p
|
||||
if extension.DescriptionURL != "" {
|
||||
description, _ := client.Get(extension.DescriptionURL, 10)
|
||||
extension.Description = string(description)
|
||||
}
|
||||
var extensionDefinition portainer.Extension
|
||||
|
||||
for _, definition := range definitions {
|
||||
if definition.ID == extensionID {
|
||||
extensionDefinition = definition
|
||||
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}
|
||||
if localExtension == nil {
|
||||
extension = extensionDefinition
|
||||
} else {
|
||||
extension = *localExtension
|
||||
}
|
||||
|
||||
extension.Enabled = storedExtension.Enabled
|
||||
mergeExtensionAndDefinition(&extension, &extensionDefinition)
|
||||
|
||||
extensionVer := semver.New(extension.Version)
|
||||
pVer := semver.New(storedExtension.Version)
|
||||
|
||||
if pVer.LessThan(*extensionVer) {
|
||||
extension.UpdateAvailable = true
|
||||
}
|
||||
description, _ := client.Get(extension.DescriptionURL, 5)
|
||||
extension.Description = string(description)
|
||||
|
||||
return response.JSON(w, extension)
|
||||
}
|
||||
|
|
|
@ -3,54 +3,28 @@ 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/api"
|
||||
)
|
||||
|
||||
// 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)
|
||||
fetchManifestInformation, _ := 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 {
|
||||
if fetchManifestInformation {
|
||||
definitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
|
||||
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 {
|
||||
associateExtensionData(&definitions[idx], extensions)
|
||||
}
|
||||
|
||||
extensions = definitions
|
||||
extensions = mergeExtensionsAndDefinitions(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
|
||||
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 (
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
|
@ -30,6 +32,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
|||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet)
|
||||
h.Handle("/extensions",
|
||||
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}",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/extensions/{id}",
|
||||
|
@ -39,3 +43,44 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
|||
|
||||
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 interface {
|
||||
FetchExtensionDefinitions() ([]Extension, error)
|
||||
InstallExtension(extension *Extension, licenseKey string, archiveFileName string, extensionArchive []byte) error
|
||||
EnableExtension(extension *Extension, licenseKey string) error
|
||||
DisableExtension(extension *Extension) error
|
||||
UpdateExtension(extension *Extension, version string) error
|
||||
|
@ -942,6 +943,8 @@ const (
|
|||
ExtensionServer = "localhost"
|
||||
// DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance
|
||||
DefaultEdgeAgentCheckinIntervalInSeconds = 5
|
||||
// LocalExtensionManifestFile represents the name of the local manifest file for extensions
|
||||
LocalExtensionManifestFile = "/app/extensions.json"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
@ -2,7 +2,8 @@ import _ from 'lodash-es';
|
|||
import { ExtensionViewModel } from '../../models/extension';
|
||||
|
||||
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';
|
||||
var service = {};
|
||||
|
||||
|
@ -20,8 +21,12 @@ angular.module('portainer.app')
|
|||
service.extensionEnabled = extensionEnabled;
|
||||
service.retrieveAndSaveEnabledExtensions = retrieveAndSaveEnabledExtensions;
|
||||
|
||||
function enable(license) {
|
||||
return Extension.create({ license: license }).$promise;
|
||||
function enable(license, extensionFile) {
|
||||
if (extensionFile) {
|
||||
return FileUploadService.uploadExtension(license, extensionFile);
|
||||
} else {
|
||||
return Extension.create({ license: license }).$promise;
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, version) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { jsonObjectsToArrayHandler, genericHandler } from '../../docker/rest/response/handlers';
|
||||
import {genericHandler, jsonObjectsToArrayHandler} from '../../docker/rest/response/handlers';
|
||||
|
||||
angular.module('portainer.app')
|
||||
.factory('FileUploadService', ['$q', 'Upload', 'EndpointProvider', function FileUploadFactory($q, Upload, EndpointProvider) {
|
||||
|
@ -169,5 +169,18 @@ angular.module('portainer.app')
|
|||
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;
|
||||
}]);
|
||||
|
|
|
@ -42,9 +42,23 @@
|
|||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
Ensure that you have a valid license.
|
||||
</span>
|
||||
<p class="small text-muted" ng-if="!state.offlineActivation">
|
||||
Portainer will download the latest version of the extension. Ensure that you have a valid license.
|
||||
</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>
|
||||
|
||||
|
@ -58,14 +72,25 @@
|
|||
<div class="form-group" ng-show="extensionEnableForm.extension_license.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<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>
|
||||
</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="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-show="state.actionInProgress">Enabling extension...</span>
|
||||
</button>
|
||||
|
|
|
@ -10,7 +10,8 @@ angular.module('portainer.app')
|
|||
};
|
||||
|
||||
$scope.formValues = {
|
||||
License: ''
|
||||
License: '',
|
||||
ExtensionFile: null,
|
||||
};
|
||||
|
||||
function initView() {
|
||||
|
@ -25,10 +26,11 @@ angular.module('portainer.app')
|
|||
}
|
||||
|
||||
$scope.enableExtension = function() {
|
||||
var license = $scope.formValues.License;
|
||||
const license = $scope.formValues.License;
|
||||
const extensionFile = $scope.formValues.ExtensionFile;
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
ExtensionService.enable(license)
|
||||
ExtensionService.enable(license, extensionFile)
|
||||
.then(function onSuccess() {
|
||||
return ExtensionService.retrieveAndSaveEnabledExtensions();
|
||||
}).then(function () {
|
||||
|
|
|
@ -68,10 +68,10 @@
|
|||
<btn class="btn btn-primary btn-sm" style="width: 100%; margin-left: 0;" disabled>Coming soon</btn>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px;" ng-if="extension.Enabled && extension.UpdateAvailable">
|
||||
<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;">
|
||||
<span ng-hide="state.updateInProgress">Update</span>
|
||||
<span ng-show="state.updateInProgress">Updating extension...</span>
|
||||
<div style="margin-top: 15px;" ng-if="extension.Enabled && extension.UpdateAvailable && !state.offlineUpdate">
|
||||
<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.onlineUpdateInProgress">Update via Internet</span>
|
||||
<span ng-show="state.onlineUpdateInProgress">Updating extension...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
@ -82,8 +82,18 @@
|
|||
</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>
|
||||
</rd-widget-body>
|
||||
|
@ -91,6 +101,46 @@
|
|||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="extension">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
|
@ -107,7 +157,7 @@
|
|||
<div class="small text-muted">
|
||||
<p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -116,7 +166,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="extension">
|
||||
<div class="row" ng-if="extension.Description && extension.Images">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
|
|
|
@ -3,15 +3,19 @@ angular.module('portainer.app')
|
|||
function ($q, $scope, $transition$, $state, ExtensionService, Notifications, ModalService) {
|
||||
|
||||
$scope.state = {
|
||||
updateInProgress: false,
|
||||
deleteInProgress: false
|
||||
onlineUpdateInProgress: false,
|
||||
offlineUpdateInProgress: false,
|
||||
deleteInProgress: false,
|
||||
offlineUpdate: false,
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
instances: 1
|
||||
instances: 1,
|
||||
extensionFile: null,
|
||||
};
|
||||
|
||||
$scope.updateExtension = updateExtension;
|
||||
$scope.updateExtensionOnline = updateExtensionOnline;
|
||||
$scope.updateExtensionOffline = updateExtensionOffline;
|
||||
$scope.deleteExtension = deleteExtension;
|
||||
$scope.enlargeImage = enlargeImage;
|
||||
|
||||
|
@ -24,7 +28,7 @@ function ($q, $scope, $transition$, $state, ExtensionService, Notifications, Mod
|
|||
ExtensionService.delete(extension.Id)
|
||||
.then(function onSuccess() {
|
||||
Notifications.success('Extension successfully deleted');
|
||||
$state.reload();
|
||||
$state.go('portainer.extensions');
|
||||
})
|
||||
.catch(function onError(err) {
|
||||
Notifications.error('Failure', err, 'Unable to delete extension');
|
||||
|
@ -34,8 +38,8 @@ function ($q, $scope, $transition$, $state, ExtensionService, Notifications, Mod
|
|||
});
|
||||
}
|
||||
|
||||
function updateExtension(extension) {
|
||||
$scope.state.updateInProgress = true;
|
||||
function updateExtensionOnline(extension) {
|
||||
$scope.state.onlineUpdateInProgress = true;
|
||||
ExtensionService.update(extension.Id, extension.Version)
|
||||
.then(function onSuccess() {
|
||||
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');
|
||||
})
|
||||
.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', [
|
||||
'build:server',
|
||||
'build:client',
|
||||
'copy:templates'
|
||||
'copy:assets'
|
||||
]);
|
||||
|
||||
grunt.registerTask('start:server', [
|
||||
'build:server',
|
||||
'copy:templates',
|
||||
'copy:assets',
|
||||
'shell:run_container'
|
||||
]);
|
||||
|
||||
|
@ -70,7 +70,7 @@ module.exports = function(grunt) {
|
|||
'config:prod',
|
||||
'env:prod',
|
||||
'clean:all',
|
||||
'copy:templates',
|
||||
'copy:assets',
|
||||
'shell:build_binary:' + p + ':' + a,
|
||||
'shell:download_docker_binary:' + p + ':' + a,
|
||||
'webpack:prod'
|
||||
|
@ -83,7 +83,7 @@ module.exports = function(grunt) {
|
|||
'config:prod',
|
||||
'env:prod',
|
||||
'clean:all',
|
||||
'copy:templates',
|
||||
'copy:assets',
|
||||
'shell:build_binary_azuredevops:' + p + ':' + a,
|
||||
'shell:download_docker_binary:' + p + ':' + a,
|
||||
'webpack:prod'
|
||||
|
@ -135,12 +135,17 @@ gruntfile_cfg.eslint = {
|
|||
};
|
||||
|
||||
gruntfile_cfg.copy = {
|
||||
templates: {
|
||||
assets: {
|
||||
files: [
|
||||
{
|
||||
dest: '<%= root %>/',
|
||||
src: 'templates.json',
|
||||
cwd: ''
|
||||
},
|
||||
{
|
||||
dest: '<%= root %>/',
|
||||
src: 'extensions.json',
|
||||
cwd: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue