Compare commits
46 Commits
release/2.
...
feat/EE-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e421e04dd1 | ||
|
|
a0e3129f0d | ||
|
|
f487a067c7 | ||
|
|
37dd521b9e | ||
|
|
27cfb692f4 | ||
|
|
4b054f9dab | ||
|
|
6cbbba596a | ||
|
|
a5c5d34de8 | ||
|
|
75da9ac2aa | ||
|
|
fea8ea6c73 | ||
|
|
d982f89167 | ||
|
|
2fb17c9cf9 | ||
|
|
c8d78ad15f | ||
|
|
96a6129d8a | ||
|
|
b8660ed2a0 | ||
|
|
9ec1f2ed6d | ||
|
|
8bfa5132cd | ||
|
|
cafcebe27e | ||
|
|
ea6df891c3 | ||
|
|
230f8fddc3 | ||
|
|
6734f0ab74 | ||
|
|
3e60167aeb | ||
|
|
8a4902f15a | ||
|
|
dde0467b89 | ||
|
|
a2a197b14b | ||
|
|
3018801fc0 | ||
|
|
3568fe9e52 | ||
|
|
2270de73ee | ||
|
|
0114766d50 | ||
|
|
2b94aa5aa6 | ||
|
|
746e738f1d | ||
|
|
29f5008c5f | ||
|
|
af03d91e39 | ||
|
|
71635834c7 | ||
|
|
39e24ec93f | ||
|
|
ce04944ce6 | ||
|
|
daabce2b8f | ||
|
|
d99358ea8e | ||
|
|
befccacc27 | ||
|
|
a7fc7816d1 | ||
|
|
872a8262f1 | ||
|
|
c339afb562 | ||
|
|
78661b50ca | ||
|
|
3f9ff8460f | ||
|
|
ae3809cefd | ||
|
|
8e246c203c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,3 +11,5 @@ api/cmd/portainer/portainer*
|
||||
__debug_bin
|
||||
|
||||
api/docs
|
||||
.idea
|
||||
.env
|
||||
|
||||
BIN
api/archive/testdata/sample_archive.zip
vendored
Normal file
BIN
api/archive/testdata/sample_archive.zip
vendored
Normal file
Binary file not shown.
@@ -3,10 +3,13 @@ package archive
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
|
||||
@@ -52,3 +55,60 @@ func extractFileFromArchive(file *zip.File, dest string) error {
|
||||
|
||||
return outFile.Close()
|
||||
}
|
||||
|
||||
// UnzipFile will decompress a zip archive, moving all files and folders
|
||||
// within the zip file (parameter 1) to an output directory (parameter 2).
|
||||
func UnzipFile(src string, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
p := filepath.Join(dest, f.Name)
|
||||
|
||||
// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
|
||||
if !strings.HasPrefix(p, filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("%s: illegal file path", p)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
// Make Folder
|
||||
os.MkdirAll(p, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
|
||||
err = unzipFile(f, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzipFile(f *zip.File, p string) error {
|
||||
// Make File
|
||||
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
|
||||
}
|
||||
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
||||
}
|
||||
defer outFile.Close()
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
32
api/archive/zip_test.go
Normal file
32
api/archive/zip_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnzipFile(t *testing.T) {
|
||||
dir, err := ioutil.TempDir("", "unzip-test-")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dir)
|
||||
/*
|
||||
Archive structure.
|
||||
├── 0
|
||||
│ ├── 1
|
||||
│ │ └── 2.txt
|
||||
│ └── 1.txt
|
||||
└── 0.txt
|
||||
*/
|
||||
|
||||
err = UnzipFile("./testdata/sample_archive.zip", dir)
|
||||
|
||||
assert.NoError(t, err)
|
||||
archiveDir := dir + "/sample_archive"
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1.txt"))
|
||||
assert.FileExists(t, filepath.Join(archiveDir, "0", "1", "2.txt"))
|
||||
|
||||
}
|
||||
73
api/bolt/bolttest/datastore.go
Normal file
73
api/bolt/bolttest/datastore.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package bolttest
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
var errTempDir = errors.New("can't create a temp dir")
|
||||
|
||||
func MustNewTestStore(init bool) (*bolt.Store, func()) {
|
||||
store, teardown, err := NewTestStore(init)
|
||||
if err != nil {
|
||||
if !errors.Is(err, errTempDir) {
|
||||
teardown()
|
||||
}
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
return store, teardown
|
||||
}
|
||||
|
||||
func NewTestStore(init bool) (*bolt.Store, func(), error) {
|
||||
// Creates unique temp directory in a concurrency friendly manner.
|
||||
dataStorePath, err := ioutil.TempDir("", "boltdb")
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(errTempDir, err.Error())
|
||||
}
|
||||
|
||||
fileService, err := filesystem.NewService(dataStorePath, "")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
store, err := bolt.NewStore(dataStorePath, fileService)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = store.Open()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if init {
|
||||
err = store.Init()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
teardown := func() {
|
||||
teardown(store, dataStorePath)
|
||||
}
|
||||
|
||||
return store, teardown, nil
|
||||
}
|
||||
|
||||
func teardown(store *bolt.Store, dataStorePath string) {
|
||||
err := store.Close()
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
err = os.RemoveAll(dataStorePath)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@ import "errors"
|
||||
|
||||
var (
|
||||
ErrObjectNotFound = errors.New("Object not found inside the database")
|
||||
ErrWrongDBEdition = errors.New("The Portainer database is set for Portainer Business Edition, please follow the instructions in our documention to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
ErrWrongDBEdition = errors.New("The Portainer database is set for Portainer Business Edition, please follow the instructions in our documentation to downgrade it: https://documentation.portainer.io/v2.0-be/downgrade/be-to-ce/")
|
||||
)
|
||||
|
||||
@@ -240,6 +240,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore portainer.Dat
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
@@ -301,6 +302,7 @@ func createUnsecuredEndpoint(endpointURL string, dataStore portainer.DataStore,
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
|
||||
@@ -279,13 +279,7 @@ func (service *Service) WriteJSONToFile(path string, content interface{}) error
|
||||
|
||||
// FileExists checks for the existence of the specified file.
|
||||
func (service *Service) FileExists(filePath string) (bool, error) {
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
return FileExists(filePath)
|
||||
}
|
||||
|
||||
// KeyPairFilesExist checks for the existence of the key files.
|
||||
@@ -510,3 +504,31 @@ func (service *Service) GetTemporaryPath() (string, error) {
|
||||
func (service *Service) GetDatastorePath() string {
|
||||
return service.dataStorePath
|
||||
}
|
||||
|
||||
// FileExists checks for the existence of the specified file.
|
||||
func FileExists(filePath string) (bool, error) {
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func MoveDirectory(originalPath, newPath string) error {
|
||||
if _, err := os.Stat(originalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
alreadyExists, err := FileExists(newPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if alreadyExists {
|
||||
return errors.New("Target path already exists")
|
||||
}
|
||||
|
||||
return os.Rename(originalPath, newPath)
|
||||
}
|
||||
|
||||
55
api/filesystem/filesystem_fileexists_test.go
Normal file
55
api/filesystem/filesystem_fileexists_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_fileSystemService_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
|
||||
service := createService(t)
|
||||
testHelperFileExists_fileExists(t, service.FileExists)
|
||||
}
|
||||
|
||||
func Test_fileSystemService_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
|
||||
service := createService(t)
|
||||
testHelperFileExists_fileNotExists(t, service.FileExists)
|
||||
}
|
||||
|
||||
func Test_FileExists_whenFileExistsShouldReturnTrue(t *testing.T) {
|
||||
testHelperFileExists_fileExists(t, FileExists)
|
||||
}
|
||||
|
||||
func Test_FileExists_whenFileNotExistsShouldReturnFalse(t *testing.T) {
|
||||
testHelperFileExists_fileNotExists(t, FileExists)
|
||||
}
|
||||
|
||||
func testHelperFileExists_fileExists(t *testing.T, checker func(path string) (bool, error)) {
|
||||
file, err := os.CreateTemp("", t.Name())
|
||||
assert.NoError(t, err, "CreateTemp should not fail")
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(file.Name())
|
||||
})
|
||||
|
||||
exists, err := checker(file.Name())
|
||||
assert.NoError(t, err, "FileExists should not fail")
|
||||
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
func testHelperFileExists_fileNotExists(t *testing.T, checker func(path string) (bool, error)) {
|
||||
filePath := path.Join(os.TempDir(), fmt.Sprintf("%s%d", t.Name(), rand.Int()))
|
||||
|
||||
err := os.RemoveAll(filePath)
|
||||
assert.NoError(t, err, "RemoveAll should not fail")
|
||||
|
||||
exists, err := checker(filePath)
|
||||
assert.NoError(t, err, "FileExists should not fail")
|
||||
|
||||
assert.False(t, exists)
|
||||
}
|
||||
49
api/filesystem/filesystem_move_test.go
Normal file
49
api/filesystem/filesystem_move_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// temporary function until upgrade to 1.16
|
||||
func tempDir(t *testing.T) string {
|
||||
tmpDir, err := os.MkdirTemp("", "dir")
|
||||
assert.NoError(t, err, "MkdirTemp should not fail")
|
||||
|
||||
return tmpDir
|
||||
}
|
||||
|
||||
func Test_movePath_shouldFailIfOriginalPathDoesntExist(t *testing.T) {
|
||||
tmpDir := tempDir(t)
|
||||
missingPath := path.Join(tmpDir, "missing")
|
||||
targetPath := path.Join(tmpDir, "target")
|
||||
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
err := MoveDirectory(missingPath, targetPath)
|
||||
assert.Error(t, err, "move directory should fail when target path exists")
|
||||
}
|
||||
|
||||
func Test_movePath_shouldFailIfTargetPathDoesExist(t *testing.T) {
|
||||
originalPath := tempDir(t)
|
||||
missingPath := tempDir(t)
|
||||
|
||||
defer os.RemoveAll(originalPath)
|
||||
defer os.RemoveAll(missingPath)
|
||||
|
||||
err := MoveDirectory(originalPath, missingPath)
|
||||
assert.Error(t, err, "move directory should fail when target path exists")
|
||||
}
|
||||
|
||||
func Test_movePath_success(t *testing.T) {
|
||||
originalPath := tempDir(t)
|
||||
|
||||
defer os.RemoveAll(originalPath)
|
||||
|
||||
err := MoveDirectory(originalPath, fmt.Sprintf("%s-old", originalPath))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
22
api/filesystem/filesystem_test.go
Normal file
22
api/filesystem/filesystem_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func createService(t *testing.T) *Service {
|
||||
dataStorePath := path.Join(os.TempDir(), t.Name())
|
||||
|
||||
service, err := NewService(dataStorePath, "")
|
||||
assert.NoError(t, err, "NewService should not fail")
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.RemoveAll(dataStorePath)
|
||||
})
|
||||
|
||||
return service
|
||||
}
|
||||
219
api/git/azure.go
Normal file
219
api/git/azure.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
azureDevOpsHost = "dev.azure.com"
|
||||
visualStudioHostSuffix = ".visualstudio.com"
|
||||
)
|
||||
|
||||
func isAzureUrl(s string) bool {
|
||||
return strings.Contains(s, azureDevOpsHost) ||
|
||||
strings.Contains(s, visualStudioHostSuffix)
|
||||
}
|
||||
|
||||
type azureOptions struct {
|
||||
organisation, project, repository string
|
||||
// a user may pass credentials in a repository URL,
|
||||
// for example https://<username>:<password>@<domain>/<path>
|
||||
username, password string
|
||||
}
|
||||
|
||||
type azureDownloader struct {
|
||||
client *http.Client
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
func NewAzureDownloader(client *http.Client) *azureDownloader {
|
||||
return &azureDownloader{
|
||||
client: client,
|
||||
baseUrl: "https://dev.azure.com",
|
||||
}
|
||||
}
|
||||
|
||||
func (a *azureDownloader) download(ctx context.Context, destination string, options cloneOptions) error {
|
||||
zipFilepath, err := a.downloadZipFromAzureDevOps(ctx, options)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to download a zip file from Azure DevOps")
|
||||
}
|
||||
defer os.Remove(zipFilepath)
|
||||
|
||||
err = archive.UnzipFile(zipFilepath, destination)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to unzip file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *azureDownloader) downloadZipFromAzureDevOps(ctx context.Context, options cloneOptions) (string, error) {
|
||||
config, err := parseUrl(options.repositoryUrl)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to parse url")
|
||||
}
|
||||
downloadUrl, err := a.buildDownloadUrl(config, options.referenceName)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to build download url")
|
||||
}
|
||||
zipFile, err := ioutil.TempFile("", "azure-git-repo-*.zip")
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to create temp file")
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadUrl, nil)
|
||||
if options.username != "" || options.password != "" {
|
||||
req.SetBasicAuth(options.username, options.password)
|
||||
} else if config.username != "" || config.password != "" {
|
||||
req.SetBasicAuth(config.username, config.password)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to create a new HTTP request")
|
||||
}
|
||||
|
||||
res, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to make an HTTP request")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to download zip with a status \"%v\"", res.Status)
|
||||
}
|
||||
|
||||
_, err = io.Copy(zipFile, res.Body)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to save HTTP response to a file")
|
||||
}
|
||||
return zipFile.Name(), nil
|
||||
}
|
||||
|
||||
func parseUrl(rawUrl string) (*azureOptions, error) {
|
||||
if strings.HasPrefix(rawUrl, "https://") || strings.HasPrefix(rawUrl, "http://") {
|
||||
return parseHttpUrl(rawUrl)
|
||||
}
|
||||
if strings.HasPrefix(rawUrl, "git@ssh") {
|
||||
return parseSshUrl(rawUrl)
|
||||
}
|
||||
if strings.HasPrefix(rawUrl, "ssh://") {
|
||||
r := []rune(rawUrl)
|
||||
return parseSshUrl(string(r[6:])) // remove the prefix
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("supported url schemes are https and ssh; recevied URL %s rawUrl", rawUrl)
|
||||
}
|
||||
|
||||
var expectedSshUrl = "git@ssh.dev.azure.com:v3/Organisation/Project/Repository"
|
||||
|
||||
func parseSshUrl(rawUrl string) (*azureOptions, error) {
|
||||
path := strings.Split(rawUrl, "/")
|
||||
|
||||
unexpectedUrlErr := errors.Errorf("want url %s, got %s", expectedSshUrl, rawUrl)
|
||||
if len(path) != 4 {
|
||||
return nil, unexpectedUrlErr
|
||||
}
|
||||
return &azureOptions{
|
||||
organisation: path[1],
|
||||
project: path[2],
|
||||
repository: path[3],
|
||||
}, nil
|
||||
}
|
||||
|
||||
const expectedAzureDevOpsHttpUrl = "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository"
|
||||
const expectedVisualStudioHttpUrl = "https://organisation.visualstudio.com/project/_git/repository"
|
||||
|
||||
func parseHttpUrl(rawUrl string) (*azureOptions, error) {
|
||||
u, err := url.Parse(rawUrl)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse HTTP url")
|
||||
}
|
||||
|
||||
opt := azureOptions{}
|
||||
switch {
|
||||
case u.Host == azureDevOpsHost:
|
||||
path := strings.Split(u.Path, "/")
|
||||
if len(path) != 5 {
|
||||
return nil, errors.Errorf("want url %s, got %s", expectedAzureDevOpsHttpUrl, u)
|
||||
}
|
||||
opt.organisation = path[1]
|
||||
opt.project = path[2]
|
||||
opt.repository = path[4]
|
||||
case strings.HasSuffix(u.Host, visualStudioHostSuffix):
|
||||
path := strings.Split(u.Path, "/")
|
||||
if len(path) != 4 {
|
||||
return nil, errors.Errorf("want url %s, got %s", expectedVisualStudioHttpUrl, u)
|
||||
}
|
||||
opt.organisation = strings.TrimSuffix(u.Host, visualStudioHostSuffix)
|
||||
opt.project = path[1]
|
||||
opt.repository = path[3]
|
||||
default:
|
||||
return nil, errors.Errorf("unknown azure host in url \"%s\"", rawUrl)
|
||||
}
|
||||
|
||||
opt.username = u.User.Username()
|
||||
opt.password, _ = u.User.Password()
|
||||
|
||||
return &opt, nil
|
||||
}
|
||||
|
||||
func (a *azureDownloader) buildDownloadUrl(config *azureOptions, referenceName string) (string, error) {
|
||||
rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/items",
|
||||
a.baseUrl,
|
||||
url.PathEscape(config.organisation),
|
||||
url.PathEscape(config.project),
|
||||
url.PathEscape(config.repository))
|
||||
u, err := url.Parse(rawUrl)
|
||||
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to parse download url path %s", rawUrl)
|
||||
}
|
||||
q := u.Query()
|
||||
// scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0
|
||||
q.Set("scopePath", "/")
|
||||
q.Set("download", "true")
|
||||
q.Set("versionDescriptor.versionType", getVersionType(referenceName))
|
||||
q.Set("versionDescriptor.version", formatReferenceName(referenceName))
|
||||
q.Set("$format", "zip")
|
||||
q.Set("recursionLevel", "full")
|
||||
q.Set("api-version", "6.0")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
const (
|
||||
branchPrefix = "refs/heads/"
|
||||
tagPrefix = "refs/tags/"
|
||||
)
|
||||
|
||||
func formatReferenceName(name string) string {
|
||||
if strings.HasPrefix(name, branchPrefix) {
|
||||
return strings.TrimPrefix(name, branchPrefix)
|
||||
}
|
||||
if strings.HasPrefix(name, tagPrefix) {
|
||||
return strings.TrimPrefix(name, tagPrefix)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func getVersionType(name string) string {
|
||||
if strings.HasPrefix(name, branchPrefix) {
|
||||
return "branch"
|
||||
}
|
||||
if strings.HasPrefix(name, tagPrefix) {
|
||||
return "tag"
|
||||
}
|
||||
return "commit"
|
||||
}
|
||||
93
api/git/azure_integration_test.go
Normal file
93
api/git/azure_integration_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestService_ClonePublicRepository_Azure(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
service := NewService()
|
||||
|
||||
type args struct {
|
||||
repositoryURLFormat string
|
||||
referenceName string
|
||||
username string
|
||||
password string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Clone Azure DevOps repo branch",
|
||||
args: args{
|
||||
repositoryURLFormat: "https://:%s@portainer.visualstudio.com/Playground/_git/dev_integration",
|
||||
referenceName: "refs/heads/main",
|
||||
username: "",
|
||||
password: pat,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Clone Azure DevOps repo tag",
|
||||
args: args{
|
||||
repositoryURLFormat: "https://:%s@portainer.visualstudio.com/Playground/_git/dev_integration",
|
||||
referenceName: "refs/tags/v1.1",
|
||||
username: "",
|
||||
password: pat,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dst, err := ioutils.TempDir("", "clone")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dst)
|
||||
repositoryUrl := fmt.Sprintf(tt.args.repositoryURLFormat, tt.args.password)
|
||||
err = service.CloneRepository(dst, repositoryUrl, tt.args.referenceName, "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ClonePrivateRepository_Azure(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
service := NewService()
|
||||
|
||||
dst, err := ioutils.TempDir("", "clone")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dst)
|
||||
|
||||
repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration"
|
||||
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", "", pat)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
|
||||
func getRequiredValue(t *testing.T, name string) string {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
t.Fatalf("can't find required env var \"%s\"", name)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func ensureIntegrationTest(t *testing.T) {
|
||||
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
|
||||
t.Skip("skip an integration test")
|
||||
}
|
||||
}
|
||||
250
api/git/azure_test.go
Normal file
250
api/git/azure_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_buildDownloadUrl(t *testing.T) {
|
||||
a := NewAzureDownloader(nil)
|
||||
u, err := a.buildDownloadUrl(&azureOptions{
|
||||
organisation: "organisation",
|
||||
project: "project",
|
||||
repository: "repository",
|
||||
}, "refs/heads/main")
|
||||
|
||||
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/items?scopePath=/&download=true&versionDescriptor.version=main&$format=zip&recursionLevel=full&api-version=6.0&versionDescriptor.versionType=branch")
|
||||
actualUrl, _ := url.Parse(u)
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, expectedUrl.Host, actualUrl.Host)
|
||||
assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme)
|
||||
assert.Equal(t, expectedUrl.Path, actualUrl.Path)
|
||||
assert.Equal(t, expectedUrl.Query(), actualUrl.Query())
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseAzureUrl(t *testing.T) {
|
||||
type args struct {
|
||||
url string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *azureOptions
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Expected SSH URL format starting with ssh://",
|
||||
args: args{
|
||||
url: "ssh://git@ssh.dev.azure.com:v3/Organisation/Project/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Expected SSH URL format starting with git@ssh",
|
||||
args: args{
|
||||
url: "git@ssh.dev.azure.com:v3/Organisation/Project/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Unexpected SSH URL format",
|
||||
args: args{
|
||||
url: "git@ssh.dev.azure.com:v3/Organisation/Repository",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Expected HTTPS URL format",
|
||||
args: args{
|
||||
url: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
username: "Organisation",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with credentials",
|
||||
args: args{
|
||||
url: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL with password",
|
||||
args: args{
|
||||
url: "https://:password@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "Organisation",
|
||||
project: "Project",
|
||||
repository: "Repository",
|
||||
password: "password",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Visual Studio HTTPS URL with credentials",
|
||||
args: args{
|
||||
url: "https://username:password@organisation.visualstudio.com/project/_git/repository",
|
||||
},
|
||||
want: &azureOptions{
|
||||
organisation: "organisation",
|
||||
project: "project",
|
||||
repository: "repository",
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Unexpected HTTPS URL format",
|
||||
args: args{
|
||||
url: "https://Organisation@dev.azure.com/Project/_git/Repository",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := parseUrl(tt.args.url)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("parseUrl() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isAzureUrl(t *testing.T) {
|
||||
type args struct {
|
||||
s string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Is Azure url",
|
||||
args: args{
|
||||
s: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Is Azure url",
|
||||
args: args{
|
||||
s: "https://portainer.visualstudio.com/project/_git/repository",
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Is NOT Azure url",
|
||||
args: args{
|
||||
s: "https://github.com/Organisation/Repository",
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, isAzureUrl(tt.args.s))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
type args struct {
|
||||
options cloneOptions
|
||||
}
|
||||
type basicAuth struct {
|
||||
username, password string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *basicAuth
|
||||
}{
|
||||
{
|
||||
name: "username, password embedded",
|
||||
args: args{
|
||||
options: cloneOptions{
|
||||
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
},
|
||||
want: &basicAuth{
|
||||
username: "username",
|
||||
password: "password",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "username, password embedded, clone options take precedence",
|
||||
args: args{
|
||||
options: cloneOptions{
|
||||
repositoryUrl: "https://username:password@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
username: "u",
|
||||
password: "p",
|
||||
},
|
||||
},
|
||||
want: &basicAuth{
|
||||
username: "u",
|
||||
password: "p",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no credentials",
|
||||
args: args{
|
||||
options: cloneOptions{
|
||||
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var zipRequestAuth *basicAuth
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if username, password, ok := r.BasicAuth(); ok {
|
||||
zipRequestAuth = &basicAuth{username, password}
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound) // this makes function under test to return an error
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
a := &azureDownloader{
|
||||
client: server.Client(),
|
||||
baseUrl: server.URL,
|
||||
}
|
||||
_, err := a.downloadZipFromAzureDevOps(context.Background(), tt.args.options)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.want, zipRequestAuth)
|
||||
})
|
||||
}
|
||||
}
|
||||
100
api/git/git.go
100
api/git/git.go
@@ -1,21 +1,72 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/src-d/go-git.v4"
|
||||
"gopkg.in/src-d/go-git.v4/plumbing"
|
||||
"gopkg.in/src-d/go-git.v4/plumbing/transport/client"
|
||||
githttp "gopkg.in/src-d/go-git.v4/plumbing/transport/http"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/client"
|
||||
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
)
|
||||
|
||||
type cloneOptions struct {
|
||||
repositoryUrl string
|
||||
username string
|
||||
password string
|
||||
referenceName string
|
||||
depth int
|
||||
}
|
||||
|
||||
type downloader interface {
|
||||
download(ctx context.Context, dst string, opt cloneOptions) error
|
||||
}
|
||||
|
||||
type gitClient struct {
|
||||
preserveGitDirectory bool
|
||||
}
|
||||
|
||||
func (c gitClient) download(ctx context.Context, dst string, opt cloneOptions) error {
|
||||
gitOptions := git.CloneOptions{
|
||||
URL: opt.repositoryUrl,
|
||||
Depth: opt.depth,
|
||||
}
|
||||
|
||||
if opt.password != "" || opt.username != "" {
|
||||
gitOptions.Auth = &githttp.BasicAuth{
|
||||
Username: opt.username,
|
||||
Password: opt.password,
|
||||
}
|
||||
}
|
||||
|
||||
if opt.referenceName != "" {
|
||||
gitOptions.ReferenceName = plumbing.ReferenceName(opt.referenceName)
|
||||
}
|
||||
|
||||
_, err := git.PlainCloneContext(ctx, dst, false, &gitOptions)
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to clone git repository")
|
||||
}
|
||||
|
||||
if !c.preserveGitDirectory {
|
||||
os.RemoveAll(filepath.Join(dst, ".git"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service represents a service for managing Git.
|
||||
type Service struct {
|
||||
httpsCli *http.Client
|
||||
azure downloader
|
||||
git downloader
|
||||
}
|
||||
|
||||
// NewService initializes a new service.
|
||||
@@ -31,32 +82,29 @@ func NewService() *Service {
|
||||
|
||||
return &Service{
|
||||
httpsCli: httpsCli,
|
||||
azure: NewAzureDownloader(httpsCli),
|
||||
git: gitClient{},
|
||||
}
|
||||
}
|
||||
|
||||
// ClonePublicRepository clones a public git repository using the specified URL in the specified
|
||||
// CloneRepository clones a git repository using the specified URL in the specified
|
||||
// destination folder.
|
||||
func (service *Service) ClonePublicRepository(repositoryURL, referenceName string, destination string) error {
|
||||
return cloneRepository(repositoryURL, referenceName, destination)
|
||||
}
|
||||
|
||||
// ClonePrivateRepositoryWithBasicAuth clones a private git repository using the specified URL in the specified
|
||||
// destination folder. It will use the specified username and password for basic HTTP authentication.
|
||||
func (service *Service) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error {
|
||||
credentials := username + ":" + url.PathEscape(password)
|
||||
repositoryURL = strings.Replace(repositoryURL, "://", "://"+credentials+"@", 1)
|
||||
return cloneRepository(repositoryURL, referenceName, destination)
|
||||
}
|
||||
|
||||
func cloneRepository(repositoryURL, referenceName, destination string) error {
|
||||
options := &git.CloneOptions{
|
||||
URL: repositoryURL,
|
||||
func (service *Service) CloneRepository(destination, repositoryURL, referenceName, username, password string) error {
|
||||
options := cloneOptions{
|
||||
repositoryUrl: repositoryURL,
|
||||
username: username,
|
||||
password: password,
|
||||
referenceName: referenceName,
|
||||
depth: 1,
|
||||
}
|
||||
|
||||
if referenceName != "" {
|
||||
options.ReferenceName = plumbing.ReferenceName(referenceName)
|
||||
return service.cloneRepository(destination, options)
|
||||
}
|
||||
|
||||
func (service *Service) cloneRepository(destination string, options cloneOptions) error {
|
||||
if isAzureUrl(options.repositoryUrl) {
|
||||
return service.azure.download(context.TODO(), destination, options)
|
||||
}
|
||||
|
||||
_, err := git.PlainClone(destination, false, options)
|
||||
return err
|
||||
return service.git.download(context.TODO(), destination, options)
|
||||
}
|
||||
|
||||
27
api/git/git_integration_test.go
Normal file
27
api/git/git_integration_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := NewService()
|
||||
|
||||
dst, err := ioutils.TempDir("", "clone")
|
||||
assert.NoError(t, err)
|
||||
defer os.RemoveAll(dst)
|
||||
|
||||
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
|
||||
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, pat)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
176
api/git/git_test.go
Normal file
176
api/git/git_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var bareRepoDir string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if err := testMain(m); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// testMain does extra setup/teardown before/after testing.
|
||||
// The function is separated from TestMain due to necessity to call os.Exit/log.Fatal in the latter.
|
||||
func testMain(m *testing.M) error {
|
||||
dir, err := ioutil.TempDir("", "git-repo-")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create a temp dir")
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
bareRepoDir = filepath.Join(dir, "test-clone.git")
|
||||
|
||||
file, err := os.OpenFile("./testdata/test-clone-git-repo.tar.gz", os.O_RDONLY, 0755)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to open an archive")
|
||||
}
|
||||
err = archive.ExtractTarGz(file, dir)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to extract file from the archive to a folder %s\n", dir)
|
||||
}
|
||||
|
||||
m.Run()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_ClonePublicRepository_Shallow(t *testing.T) {
|
||||
service := Service{git: gitClient{preserveGitDirectory: true}} // no need for http client since the test access the repo via file system.
|
||||
repositoryURL := bareRepoDir
|
||||
referenceName := "refs/heads/main"
|
||||
destination := "shallow"
|
||||
|
||||
dir, err := ioutil.TempDir("", destination)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create a temp dir")
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err = service.CloneRepository(dir, repositoryURL, referenceName, "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
func Test_ClonePublicRepository_NoGitDirectory(t *testing.T) {
|
||||
service := Service{git: gitClient{preserveGitDirectory: false}} // no need for http client since the test access the repo via file system.
|
||||
repositoryURL := bareRepoDir
|
||||
referenceName := "refs/heads/main"
|
||||
destination := "shallow"
|
||||
|
||||
dir, err := ioutil.TempDir("", destination)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create a temp dir")
|
||||
}
|
||||
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
t.Logf("Cloning into %s", dir)
|
||||
err = service.CloneRepository(dir, repositoryURL, referenceName, "", "")
|
||||
assert.NoError(t, err)
|
||||
assert.NoDirExists(t, filepath.Join(dir, ".git"))
|
||||
}
|
||||
|
||||
func Test_cloneRepository(t *testing.T) {
|
||||
service := Service{git: gitClient{preserveGitDirectory: true}} // no need for http client since the test access the repo via file system.
|
||||
|
||||
repositoryURL := bareRepoDir
|
||||
referenceName := "refs/heads/main"
|
||||
destination := "shallow"
|
||||
|
||||
dir, err := ioutil.TempDir("", destination)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create a temp dir")
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
t.Logf("Cloning into %s", dir)
|
||||
|
||||
err = service.cloneRepository(dir, cloneOptions{
|
||||
repositoryUrl: repositoryURL,
|
||||
referenceName: referenceName,
|
||||
depth: 10,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 3, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
func getCommitHistoryLength(t *testing.T, err error, dir string) int {
|
||||
repo, err := git.PlainOpen(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("can't open a git repo at %s with error %v", dir, err)
|
||||
}
|
||||
iter, err := repo.Log(&git.LogOptions{All: true})
|
||||
if err != nil {
|
||||
t.Fatalf("can't get a commit history iterator with error %v", err)
|
||||
}
|
||||
count := 0
|
||||
err = iter.ForEach(func(_ *object.Commit) error {
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("can't iterate over the commit history with error %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type testDownloader struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *testDownloader) download(_ context.Context, _ string, _ cloneOptions) error {
|
||||
t.called = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_cloneRepository_azure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
called bool
|
||||
}{
|
||||
{
|
||||
name: "Azure HTTP URL",
|
||||
url: "https://Organisation@dev.azure.com/Organisation/Project/_git/Repository",
|
||||
called: true,
|
||||
},
|
||||
{
|
||||
name: "Azure SSH URL",
|
||||
url: "git@ssh.dev.azure.com:v3/Organisation/Project/Repository",
|
||||
called: true,
|
||||
},
|
||||
{
|
||||
name: "Something else",
|
||||
url: "https://example.com",
|
||||
called: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
azure := &testDownloader{}
|
||||
git := &testDownloader{}
|
||||
|
||||
s := &Service{azure: azure, git: git}
|
||||
s.cloneRepository("", cloneOptions{repositoryUrl: tt.url, depth: 1})
|
||||
|
||||
// if azure API is called, git isn't and vice versa
|
||||
assert.Equal(t, tt.called, azure.called)
|
||||
assert.Equal(t, tt.called, !git.called)
|
||||
})
|
||||
}
|
||||
}
|
||||
BIN
api/git/testdata/azure-repo.zip
vendored
Normal file
BIN
api/git/testdata/azure-repo.zip
vendored
Normal file
Binary file not shown.
BIN
api/git/testdata/test-clone-git-repo.tar.gz
vendored
Normal file
BIN
api/git/testdata/test-clone-git-repo.tar.gz
vendored
Normal file
Binary file not shown.
7
api/git/types/types.go
Normal file
7
api/git/types/types.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package gittypes
|
||||
|
||||
type RepoConfig struct {
|
||||
URL string
|
||||
ReferenceName string
|
||||
ConfigFilePath string
|
||||
}
|
||||
11
api/go.mod
11
api/go.mod
@@ -3,7 +3,7 @@ module github.com/portainer/portainer/api
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.14
|
||||
github.com/Microsoft/go-winio v0.4.16
|
||||
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a
|
||||
github.com/boltdb/bolt v1.3.1
|
||||
github.com/containerd/containerd v1.3.1 // indirect
|
||||
@@ -13,12 +13,13 @@ require (
|
||||
github.com/docker/cli v0.0.0-20191126203649-54d085b857e9
|
||||
github.com/docker/docker v0.0.0-00010101000000-000000000000
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||
github.com/go-git/go-git/v5 v5.3.0
|
||||
github.com/go-ldap/ldap/v3 v3.1.8
|
||||
github.com/gofrs/uuid v3.2.0+incompatible
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/websocket v1.4.1
|
||||
github.com/imdario/mergo v0.3.8 // indirect
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/jpillora/chisel v0.0.0-20190724232113-f3a8df20e389
|
||||
github.com/json-iterator/go v1.1.8
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
|
||||
@@ -30,14 +31,14 @@ require (
|
||||
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1
|
||||
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 // indirect
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
gopkg.in/src-d/go-git.v4 v4.13.1
|
||||
k8s.io/api v0.17.2
|
||||
k8s.io/apimachinery v0.17.2
|
||||
k8s.io/client-go v0.17.2
|
||||
)
|
||||
|
||||
replace github.com/docker/docker => github.com/docker/engine v1.4.2-0.20200204220554-5f6d6f3f2203
|
||||
|
||||
replace golang.org/x/sys => golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456
|
||||
|
||||
101
api/go.sum
101
api/go.sum
@@ -11,10 +11,10 @@ github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxB
|
||||
github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
|
||||
github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/Microsoft/go-winio v0.3.8 h1:dvxbxtpTIjdAbx2OtL26p4eq0iEvys/U5yrsTJb3NZI=
|
||||
github.com/Microsoft/go-winio v0.3.8/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA=
|
||||
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
|
||||
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
|
||||
github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk=
|
||||
github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0=
|
||||
github.com/Microsoft/hcsshim v0.8.6 h1:ZfF0+zZeYdzMIVMZHKtDKJvLHj76XCuVae/jNkjj0IA=
|
||||
github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg=
|
||||
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
|
||||
@@ -47,7 +47,7 @@ github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc h1:TP+534wVl
|
||||
github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
|
||||
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v0.0.0-20151105211317-5215b55f46b2/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -92,6 +92,15 @@ github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0=
|
||||
github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.3.1 h1:gvPdv/Hr++TRFCl0UbPFHC54P9N9jgsRPnmnr419Uck=
|
||||
github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4=
|
||||
github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E=
|
||||
github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
|
||||
github.com/go-git/go-billy/v5 v5.1.0 h1:4pl5BV4o7ZG/lterP4S6WzJ6xr49Ba5ET9ygheTYahk=
|
||||
github.com/go-git/go-billy/v5 v5.1.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12 h1:PbKy9zOy4aAKrJ5pibIRpVO2BXnK1Tlcg+caKI7Ox5M=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw=
|
||||
github.com/go-git/go-git/v5 v5.3.0 h1:8WKMtJR2j8RntEXR/uvTKagfEt4GYlwQ7mntE4+0GWc=
|
||||
github.com/go-git/go-git/v5 v5.3.0/go.mod h1:xdX4bWJ48aOrdhnl2XqHYstHbbp6+LFS4r4X+lNVprw=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-ldap/ldap/v3 v3.1.8 h1:5vU/2jOh9HqprwXp8aF915s9p6Z8wmbSEVF7/gdTFhM=
|
||||
github.com/go-ldap/ldap/v3 v3.1.8/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q=
|
||||
@@ -105,7 +114,6 @@ github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dp
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.1.1 h1:72R+M5VuhED/KujmZVcIquuo8mBgX4oVda//DQb3PXo=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I=
|
||||
github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
|
||||
@@ -147,11 +155,13 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/imdario/mergo v0.3.8 h1:CGgOkSJeqMRmt0D9XLWExdT4m4F1vd3FV3VPt+0VxkQ=
|
||||
github.com/imdario/mergo v0.3.8/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
|
||||
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
|
||||
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
|
||||
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
|
||||
github.com/jpillora/ansi v0.0.0-20170202005112-f496b27cd669 h1:l5rH/CnVVu+HPxjtxjM90nHrm4nov3j3RF9/62UjgLs=
|
||||
github.com/jpillora/ansi v0.0.0-20170202005112-f496b27cd669/go.mod h1:kOeLNvjNBGSV3uYtFjvb72+fnZCMFJF1XDvRIjdom0g=
|
||||
github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
|
||||
@@ -168,8 +178,8 @@ github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46O
|
||||
github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
|
||||
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c h1:N7A4JCA2G+j5fuFxCsJqjFU/sZe0mj8H0sSoSwbaikw=
|
||||
@@ -177,13 +187,14 @@ github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c/go.mod h1:Nn
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v0.0.0-20150511174710-5cf931ef8f76/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mattn/go-shellwords v1.0.6 h1:9Jok5pILi5S1MnDirGVTufYGtksUs/V2BWUP3ZkeUUI=
|
||||
github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
|
||||
@@ -205,6 +216,7 @@ github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5
|
||||
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
@@ -218,10 +230,8 @@ github.com/opencontainers/runc v0.0.0-20161109192122-51371867a01c h1:iOMba/KmaXg
|
||||
github.com/opencontainers/runc v0.0.0-20161109192122-51371867a01c/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6 h1:lNCW6THrCKBiJBpz8kbVGjC7MgdCGKwuvBgc7LoD6sw=
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI=
|
||||
github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=
|
||||
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -248,9 +258,8 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.3 h1:CTwfnzjQ+8dS6MhHHu4YswVAD99sL2wjPqP+VkURmKE=
|
||||
github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ=
|
||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
@@ -258,15 +267,10 @@ github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd
|
||||
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4=
|
||||
github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
@@ -274,8 +278,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
|
||||
github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ=
|
||||
github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70=
|
||||
github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4=
|
||||
github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI=
|
||||
github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
@@ -288,10 +292,9 @@ golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnf
|
||||
golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1 h1:anGSYQpPhQwXlwsu5wmfq0nWkCNaMEMUwAv13Y92hd8=
|
||||
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -308,12 +311,10 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
|
||||
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 h1:e6HwijUxhDe+hPNjZQQn9bA5PW3vNmnN64U2ZW759Lk=
|
||||
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs=
|
||||
golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0=
|
||||
@@ -324,27 +325,16 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181019160139-8e24a49d80f8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190209173611-3b5209105503/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3 h1:4y9KwBHBgBNwDbtu44R5o1fdOCQUEXhbk/P4A9WmJq0=
|
||||
golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 h1:ng0gs1AKnRRuEMZoTLLlbOd+C17zUDepwGQBb/n+JVg=
|
||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -356,13 +346,11 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7 h1:ZUjXAXmrAyrmmCPHgCA/vChHcpsX27MZ3yBonD/z1KE=
|
||||
@@ -373,25 +361,22 @@ google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
|
||||
gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOAJK+LsJg=
|
||||
gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g=
|
||||
gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE=
|
||||
gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
|
||||
@@ -236,16 +236,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) (
|
||||
projectPath := handler.FileService.GetCustomTemplateProjectPath(strconv.Itoa(customTemplateID))
|
||||
customTemplate.ProjectPath = projectPath
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
}
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package customtemplates
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
if parameters.authentication {
|
||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||
}
|
||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||
}
|
||||
@@ -212,16 +212,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por
|
||||
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
}
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package edgestacks
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
if parameters.authentication {
|
||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||
}
|
||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||
}
|
||||
@@ -471,6 +471,7 @@ func (handler *Handler) saveEndpointAndUpdateAuthorizations(endpoint *portainer.
|
||||
AllowVolumeBrowserForRegularUsers: false,
|
||||
EnableHostManagementFeatures: false,
|
||||
|
||||
AllowSysctlSettingForRegularUsers: true,
|
||||
AllowBindMountsForRegularUsers: true,
|
||||
AllowPrivilegedModeForRegularUsers: true,
|
||||
AllowHostNamespaceForRegularUsers: true,
|
||||
|
||||
@@ -169,19 +169,10 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
}
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
|
||||
}
|
||||
|
||||
@@ -173,21 +173,12 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
referenceName: payload.RepositoryReferenceName,
|
||||
path: projectPath,
|
||||
authentication: payload.RepositoryAuthentication,
|
||||
username: payload.RepositoryUsername,
|
||||
password: payload.RepositoryPassword,
|
||||
}
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
|
||||
}
|
||||
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package stacks
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
if parameters.authentication {
|
||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||
}
|
||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||
}
|
||||
@@ -54,6 +54,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/stacks/{id}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/stacks/{id}/git",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdateGit))).Methods(http.MethodPut)
|
||||
h.Handle("/stacks/{id}/file",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet)
|
||||
h.Handle("/stacks/{id}/migrate",
|
||||
|
||||
@@ -2,6 +2,7 @@ package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
@@ -226,3 +228,18 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
|
||||
stack.ResourceControl = resourceControl
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneAndSaveConfig(stack *portainer.Stack, projectPath, repositoryURL, refName, configFilePath string, auth bool, username, password string) error {
|
||||
|
||||
err := handler.GitService.CloneRepository(projectPath, repositoryURL, refName, username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to clone git repository: %w", err)
|
||||
}
|
||||
|
||||
stack.GitConfig = &gittypes.RepoConfig{
|
||||
URL: repositoryURL,
|
||||
ReferenceName: refName,
|
||||
ConfigFilePath: configFilePath,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
29
api/http/handler/stacks/stack_create_test.go
Normal file
29
api/http/handler/stacks/stack_create_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_stackHandler_cloneAndSaveConfig_shouldCallGitCloneAndSaveConfigOnStack(t *testing.T) {
|
||||
handler := NewHandler(&security.RequestBouncer{})
|
||||
handler.GitService = testhelpers.NewGitService()
|
||||
|
||||
url := "url"
|
||||
refName := "ref"
|
||||
configPath := "path"
|
||||
stack := &portainer.Stack{}
|
||||
err := handler.cloneAndSaveConfig(stack, "", url, refName, configPath, false, "", "")
|
||||
assert.NoError(t, err, "clone and save should not fail")
|
||||
|
||||
assert.Equal(t, gittypes.RepoConfig{
|
||||
URL: url,
|
||||
ReferenceName: refName,
|
||||
ConfigFilePath: configPath,
|
||||
}, *stack.GitConfig)
|
||||
}
|
||||
@@ -191,6 +191,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
err = handler.deploySwarmStack(config)
|
||||
if err != nil {
|
||||
|
||||
180
api/http/handler/stacks/stack_update_git.go
Normal file
180
api/http/handler/stacks/stack_update_git.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
|
||||
type updateStackGitPayload struct {
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
}
|
||||
|
||||
func (payload *updateStackGitPayload) Validate(r *http.Request) error {
|
||||
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
|
||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PUT request on /api/stacks/:id/git?endpointId=<endpointId>
|
||||
func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err}
|
||||
}
|
||||
|
||||
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
if stack.GitConfig == nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Stack is not created from git", err}
|
||||
}
|
||||
|
||||
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
|
||||
// The EndpointID property is not available for these stacks, this API endpoint
|
||||
// can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack.
|
||||
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
|
||||
}
|
||||
if endpointID != int(stack.EndpointID) {
|
||||
stack.EndpointID = portainer.EndpointID(endpointID)
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
var payload updateStackGitPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
|
||||
|
||||
backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath)
|
||||
err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to move git repository directory", err}
|
||||
}
|
||||
|
||||
repositoryUsername := payload.RepositoryUsername
|
||||
repositoryPassword := payload.RepositoryPassword
|
||||
if !payload.RepositoryAuthentication {
|
||||
repositoryUsername = ""
|
||||
repositoryPassword = ""
|
||||
}
|
||||
|
||||
err = handler.GitService.CloneRepository(stack.ProjectPath, stack.GitConfig.URL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
|
||||
if err != nil {
|
||||
restoreError := filesystem.MoveDirectory(backupProjectPath, stack.ProjectPath)
|
||||
if restoreError != nil {
|
||||
log.Printf("[WARN] [http,stacks,git] [error: %s] [message: failed restoring backup folder]", restoreError)
|
||||
}
|
||||
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||
}
|
||||
|
||||
defer func() {
|
||||
err = handler.FileService.RemoveDirectory(backupProjectPath)
|
||||
if err != nil {
|
||||
log.Printf("[WARN] [http,stacks,git] [error: %s] [message: unable to remove git repository directory]", err)
|
||||
}
|
||||
}()
|
||||
|
||||
httpErr := handler.deployStack(r, stack, endpoint)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
if stack.Type == portainer.DockerSwarmStack {
|
||||
config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
err := handler.deploySwarmStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
}
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
err := handler.deployComposeStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
}
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package templates
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
referenceName string
|
||||
path string
|
||||
authentication bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneGitRepository(parameters *cloneRepositoryParameters) error {
|
||||
if parameters.authentication {
|
||||
return handler.GitService.ClonePrivateRepositoryWithBasicAuth(parameters.url, parameters.referenceName, parameters.path, parameters.username, parameters.password)
|
||||
}
|
||||
return handler.GitService.ClonePublicRepository(parameters.url, parameters.referenceName, parameters.path)
|
||||
}
|
||||
@@ -63,12 +63,7 @@ func (handler *Handler) templateFile(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
defer handler.cleanUp(projectPath)
|
||||
|
||||
gitCloneParams := &cloneRepositoryParameters{
|
||||
url: payload.RepositoryURL,
|
||||
path: projectPath,
|
||||
}
|
||||
|
||||
err = handler.cloneGitRepository(gitCloneParams)
|
||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, "", "", "")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err}
|
||||
}
|
||||
|
||||
12
api/internal/testhelpers/git_service.go
Normal file
12
api/internal/testhelpers/git_service.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package testhelpers
|
||||
|
||||
type gitService struct{}
|
||||
|
||||
// NewGitService creates new mock for portainer.GitService.
|
||||
func NewGitService() *gitService {
|
||||
return &gitService{}
|
||||
}
|
||||
|
||||
func (service *gitService) CloneRepository(destination string, repositoryURL, referenceName string, username, password string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package portainer
|
||||
import (
|
||||
"io"
|
||||
"time"
|
||||
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -697,6 +699,8 @@ type (
|
||||
UpdateDate int64 `example:"1587399600"`
|
||||
// The username which last updated this stack
|
||||
UpdatedBy string `example:"bob"`
|
||||
// The git config of this stack
|
||||
GitConfig *gittypes.RepoConfig
|
||||
}
|
||||
|
||||
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
|
||||
@@ -1138,8 +1142,7 @@ type (
|
||||
|
||||
// GitService represents a service for managing Git
|
||||
GitService interface {
|
||||
ClonePublicRepository(repositoryURL, referenceName string, destination string) error
|
||||
ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error
|
||||
CloneRepository(destination string, repositoryURL, referenceName, username, password string) error
|
||||
}
|
||||
|
||||
// JWTService represents a service for managing JWT tokens
|
||||
|
||||
@@ -1023,3 +1023,8 @@ json-tree .branch-preview {
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* !uib-typeahead override */
|
||||
|
||||
.my-8 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@@ -71,13 +71,13 @@
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-click="$ctrl.copy()"
|
||||
ng-disabled="($ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0]) || !$ctrl.state.filteredLogs.length"
|
||||
ng-disabled="($ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0].line) || !$ctrl.state.filteredLogs.length"
|
||||
><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy</button
|
||||
>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-click="$ctrl.copySelection()"
|
||||
ng-disabled="($ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0]) || !$ctrl.state.filteredLogs.length || !$ctrl.state.selectedLines.length"
|
||||
ng-disabled="($ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0].line) || !$ctrl.state.filteredLogs.length || !$ctrl.state.selectedLines.length"
|
||||
><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy selected lines</button
|
||||
>
|
||||
<button class="btn btn-primary btn-sm" ng-click="$ctrl.clearSelection()" ng-disabled="$ctrl.state.selectedLines.length === 0"
|
||||
@@ -97,9 +97,9 @@
|
||||
<div class="row" style="height: 54%;">
|
||||
<div class="col-sm-12" style="height: 100%;">
|
||||
<pre ng-class="{ wrap_lines: $ctrl.state.wrapLines }" class="log_viewer" scroll-glue="$ctrl.state.autoScroll" force-glue>
|
||||
<div ng-repeat="line in $ctrl.state.filteredLogs = ($ctrl.data | filter:$ctrl.state.search) track by $index" class="line" ng-if="line"><p class="inner_line" ng-click="$ctrl.selectLine(line)" ng-class="{ 'line_selected': $ctrl.state.selectedLines.indexOf(line) > -1 }">{{ line }}</p></div>
|
||||
<div ng-repeat="log in $ctrl.state.filteredLogs = ($ctrl.data | filter:{ 'line': $ctrl.state.search }) track by $index" class="line" ng-if="log.line"><p class="inner_line" ng-click="$ctrl.selectLine(log.line)" ng-class="{ 'line_selected': $ctrl.state.selectedLines.indexOf(log.line) > -1 }"><span ng-repeat="span in log.spans" ng-style="{ 'color': span.foregroundColor, 'background-color': span.backgroundColor }">{{ span.text }}</span></p></div>
|
||||
<div ng-if="!$ctrl.state.filteredLogs.length" class="line"><p class="inner_line">No log line matching the '{{ $ctrl.state.search }}' filter</p></div>
|
||||
<div ng-if="$ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0]" class="line"><p class="inner_line">No logs available</p></div>
|
||||
<div ng-if="$ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0].line" class="line"><p class="inner_line">No logs available</p></div>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ angular.module('portainer.docker').controller('LogViewerController', [
|
||||
};
|
||||
|
||||
this.copy = function () {
|
||||
clipboard.copyText(this.state.filteredLogs);
|
||||
clipboard.copyText(this.state.filteredLogs.map((log) => log.line));
|
||||
$('#refreshRateChange').show();
|
||||
$('#refreshRateChange').fadeOut(2000);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,137 @@
|
||||
import tokenize from '@nxmix/tokenize-ansi';
|
||||
import x256 from 'x256';
|
||||
|
||||
const FOREGROUND_COLORS_BY_ANSI = {
|
||||
black: x256.colors[0],
|
||||
red: x256.colors[1],
|
||||
green: x256.colors[2],
|
||||
yellow: x256.colors[3],
|
||||
blue: x256.colors[4],
|
||||
magenta: x256.colors[5],
|
||||
cyan: x256.colors[6],
|
||||
white: x256.colors[7],
|
||||
brightBlack: x256.colors[8],
|
||||
brightRed: x256.colors[9],
|
||||
brightGreen: x256.colors[10],
|
||||
brightYellow: x256.colors[11],
|
||||
brightBlue: x256.colors[12],
|
||||
brightMagenta: x256.colors[13],
|
||||
brightCyan: x256.colors[14],
|
||||
brightWhite: x256.colors[15],
|
||||
};
|
||||
|
||||
const BACKGROUND_COLORS_BY_ANSI = {
|
||||
bgBlack: x256.colors[0],
|
||||
bgRed: x256.colors[1],
|
||||
bgGreen: x256.colors[2],
|
||||
bgYellow: x256.colors[3],
|
||||
bgBlue: x256.colors[4],
|
||||
bgMagenta: x256.colors[5],
|
||||
bgCyan: x256.colors[6],
|
||||
bgWhite: x256.colors[7],
|
||||
bgBrightBlack: x256.colors[8],
|
||||
bgBrightRed: x256.colors[9],
|
||||
bgBrightGreen: x256.colors[10],
|
||||
bgBrightYellow: x256.colors[11],
|
||||
bgBrightBlue: x256.colors[12],
|
||||
bgBrightMagenta: x256.colors[13],
|
||||
bgBrightCyan: x256.colors[14],
|
||||
bgBrightWhite: x256.colors[15],
|
||||
};
|
||||
|
||||
angular.module('portainer.docker').factory('LogHelper', [
|
||||
function LogHelperFactory() {
|
||||
'use strict';
|
||||
var helper = {};
|
||||
|
||||
// Return an array with each line being an entry.
|
||||
// It will also remove any ANSI code related character sequences.
|
||||
// If the skipHeaders param is specified, it will strip the 8 first characters of each line.
|
||||
helper.formatLogs = function (logs, skipHeaders) {
|
||||
logs = logs.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
||||
function stripHeaders(logs) {
|
||||
logs = logs.substring(8);
|
||||
logs = logs.replace(/\n(.{8})/g, '\n\r');
|
||||
|
||||
if (skipHeaders) {
|
||||
logs = logs.substring(8);
|
||||
logs = logs.replace(/\n(.{8})/g, '\n\r');
|
||||
return logs;
|
||||
}
|
||||
|
||||
function stripEscapeCodes(logs) {
|
||||
return logs.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
|
||||
}
|
||||
|
||||
function cssColorFromRgb(rgb) {
|
||||
const [r, g, b] = rgb;
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function extendedColorForToken(token) {
|
||||
const colorMode = token[1];
|
||||
|
||||
if (colorMode === 2) {
|
||||
return cssColorFromRgb(token.slice(2));
|
||||
}
|
||||
|
||||
return logs.split('\n');
|
||||
if (colorMode === 5 && x256.colors[token[2]]) {
|
||||
return cssColorFromRgb(x256.colors[token[2]]);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Return an array with each log including a line and styled spans for each entry.
|
||||
// If the skipHeaders param is specified, it will strip the 8 first characters of each line.
|
||||
helper.formatLogs = function (logs, skipHeaders) {
|
||||
if (skipHeaders) {
|
||||
logs = stripHeaders(logs);
|
||||
}
|
||||
|
||||
const tokens = tokenize(logs);
|
||||
const formattedLogs = [];
|
||||
|
||||
let foregroundColor = null;
|
||||
let backgroundColor = null;
|
||||
let line = '';
|
||||
let spans = [];
|
||||
|
||||
for (const token of tokens) {
|
||||
const type = token[0];
|
||||
|
||||
if (FOREGROUND_COLORS_BY_ANSI[type]) {
|
||||
foregroundColor = cssColorFromRgb(FOREGROUND_COLORS_BY_ANSI[type]);
|
||||
} else if (type === 'moreColor') {
|
||||
foregroundColor = extendedColorForToken(token);
|
||||
} else if (type === 'fgDefault') {
|
||||
foregroundColor = null;
|
||||
} else if (BACKGROUND_COLORS_BY_ANSI[type]) {
|
||||
backgroundColor = cssColorFromRgb(BACKGROUND_COLORS_BY_ANSI[type]);
|
||||
} else if (type === 'bgMoreColor') {
|
||||
backgroundColor = extendedColorForToken(token);
|
||||
} else if (type === 'bgDefault') {
|
||||
backgroundColor = null;
|
||||
} else if (type === 'reset') {
|
||||
foregroundColor = null;
|
||||
backgroundColor = null;
|
||||
} else if (type === 'text') {
|
||||
const tokenLines = token[1].split('\n');
|
||||
|
||||
for (let i = 0; i < tokenLines.length; i++) {
|
||||
if (i !== 0) {
|
||||
formattedLogs.push({ line, spans });
|
||||
|
||||
line = '';
|
||||
spans = [];
|
||||
}
|
||||
|
||||
const text = stripEscapeCodes(tokenLines[i]);
|
||||
|
||||
line += text;
|
||||
spans.push({ foregroundColor, backgroundColor, text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (line) {
|
||||
formattedLogs.push({ line, spans });
|
||||
}
|
||||
|
||||
return formattedLogs;
|
||||
};
|
||||
|
||||
return helper;
|
||||
|
||||
@@ -136,78 +136,7 @@
|
||||
</div>
|
||||
<!-- !upload -->
|
||||
<!-- repository -->
|
||||
<div ng-show="$ctrl.state.Method === 'repository'">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Git repository
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
You can use the URL of a git repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.formValues.RepositoryURL"
|
||||
id="stack_repository_url"
|
||||
placeholder="https://github.com/portainer/portainer-compose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Specify a reference of the repository using the following syntax: branches with
|
||||
<code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If not specified, will use the default <code>HEAD</code> reference normally the
|
||||
<code>master</code> branch.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository reference</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.RepositoryReferenceName" id="stack_repository_reference_name" placeholder="refs/heads/master" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Indicate the path to the Compose file from the root of your repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.ComposeFilePathInRepository" id="stack_repository_path" placeholder="docker-compose.yml" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Authentication
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="$ctrl.formValues.RepositoryAuthentication" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
If your git account has 2FA enabled, you may receive an
|
||||
<code>authentication required</code> error when deploying your stack. In this case, you will need to provide a personal-access token instead of your password.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.formValues.RepositoryAuthentication">
|
||||
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.RepositoryUsername" name="repository_username" placeholder="myGitUser" />
|
||||
</div>
|
||||
<label for="repository_password" class="col-sm-1 control-label text-left">
|
||||
Password
|
||||
</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input type="password" class="form-control" ng-model="$ctrl.formValues.RepositoryPassword" name="repository_password" placeholder="myPassword" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<git-form ng-show="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
||||
<!-- !repository -->
|
||||
<!-- template -->
|
||||
<div ng-show="$ctrl.state.Method === 'template'">
|
||||
|
||||
@@ -40,6 +40,7 @@ export class CreateEdgeStackViewController {
|
||||
this.onChangeTemplate = this.onChangeTemplate.bind(this);
|
||||
this.onChangeTemplateAsync = this.onChangeTemplateAsync.bind(this);
|
||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||
this.onChangeFormValues = this.onChangeFormValues.bind(this);
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
@@ -161,6 +162,10 @@ export class CreateEdgeStackViewController {
|
||||
return this.EdgeStackService.createStackFromGitRepository(name, repositoryOptions, this.formValues.Groups);
|
||||
}
|
||||
|
||||
onChangeFormValues(values) {
|
||||
this.formValues = values;
|
||||
}
|
||||
|
||||
editorUpdate(cm) {
|
||||
this.formValues.StackFileContent = cm.getValue();
|
||||
this.state.isEditorDirty = true;
|
||||
|
||||
@@ -100,6 +100,16 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([
|
||||
},
|
||||
};
|
||||
|
||||
const applicationStats = {
|
||||
name: 'kubernetes.applications.application.stats',
|
||||
url: '/:pod/:container/stats',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesApplicationStatsView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stacks = {
|
||||
name: 'kubernetes.stacks',
|
||||
url: '/stacks',
|
||||
@@ -259,6 +269,7 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([
|
||||
$stateRegistryProvider.register(applicationEdit);
|
||||
$stateRegistryProvider.register(applicationConsole);
|
||||
$stateRegistryProvider.register(applicationLogs);
|
||||
$stateRegistryProvider.register(applicationStats);
|
||||
$stateRegistryProvider.register(stacks);
|
||||
$stateRegistryProvider.register(stack);
|
||||
$stateRegistryProvider.register(stackLogs);
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
>
|
||||
<td ng-if="!$ctrl.isPod">{{ item.PodName }}</td>
|
||||
<td>{{ item.Name }}</td>
|
||||
<td>{{ item.Image }}</td>
|
||||
<td title="{{ item.Image }}">{{ item.Image | truncate: 64 }}</td>
|
||||
<td>{{ item.ImagePullPolicy }}</td>
|
||||
<td
|
||||
><span class="label label-{{ item.Status | kubernetesPodStatusColor }}">{{ item.Status }}</span></td
|
||||
@@ -131,6 +131,13 @@
|
||||
</td>
|
||||
<td>{{ item.CreationDate | getisodate }}</td>
|
||||
<td>
|
||||
<a
|
||||
ng-if="item.Status === 'Running' && $ctrl.useServerMetrics"
|
||||
ui-sref="kubernetes.applications.application.stats({ pod: item.PodName, container: item.Name })"
|
||||
style="margin-right: 10px;"
|
||||
>
|
||||
<i class="fa fa-chart-area" aria-hidden="true"></i> Stats
|
||||
</a>
|
||||
<a ui-sref="kubernetes.applications.application.logs({ pod: item.PodName, container: item.Name })"> <i class="fa fa-file-alt" aria-hidden="true"></i> Logs </a>
|
||||
<a ng-if="item.Status === 'Running'" ui-sref="kubernetes.applications.application.console({ pod: item.PodName, container: item.Name })" style="margin-left: 10px;">
|
||||
<i class="fa fa-terminal" aria-hidden="true"></i> Console
|
||||
|
||||
@@ -9,5 +9,6 @@ angular.module('portainer.kubernetes').component('kubernetesContainersDatatable'
|
||||
orderBy: '@',
|
||||
refreshCallback: '<',
|
||||
isPod: '<',
|
||||
useServerMetrics: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('ResourcePool')">
|
||||
Resource pool
|
||||
Namespace
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourcePool' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourcePool' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
@@ -147,8 +147,8 @@
|
||||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
|
||||
</td>
|
||||
<td
|
||||
>{{ item.Image }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
|
||||
<td title="{{ item.Image }}"
|
||||
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
|
||||
>
|
||||
<td>{{ item.ApplicationType | kubernetesApplicationTypeText }}</td>
|
||||
<td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.POD">
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('ResourcePool')">
|
||||
Resource pool
|
||||
Namespace
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourcePool' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourcePool' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('Namespace')">
|
||||
Resource Pool
|
||||
Namespace
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Namespace' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Namespace' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
><a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a></td
|
||||
>
|
||||
<td>{{ item.StackName }}</td>
|
||||
<td>{{ item.Image }}</td>
|
||||
<td title="{{ item.Image }}">{{ item.Image | truncate: 64 }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
<td colspan="3" class="text-center text-muted">Loading...</td>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('ResourcePool')">
|
||||
Resource pool
|
||||
Namespace
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourcePool' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourcePool' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
@@ -118,8 +118,8 @@
|
||||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
|
||||
</td>
|
||||
<td
|
||||
>{{ item.Image }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
|
||||
<td title="{{ item.Image }}"
|
||||
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
|
||||
>
|
||||
<td>{{ item.CPU | kubernetesApplicationCPUValue }}</td>
|
||||
<td>{{ item.Memory | humansize }}</td>
|
||||
|
||||
@@ -107,8 +107,8 @@
|
||||
<span style="margin-left: 5px;" class="label label-primary image-tag" ng-if="$ctrl.isExternalApplication(item)">external</span>
|
||||
</td>
|
||||
<td>{{ item.StackName }}</td>
|
||||
<td
|
||||
>{{ item.Image }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
|
||||
<td title="{{ item.Image }}"
|
||||
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
|
||||
>
|
||||
<td>{{ item.CPU | kubernetesApplicationCPUValue }}</td>
|
||||
<td>{{ item.Memory | humansize }}</td>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="kubernetes.resourcePools.new">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add resource pool
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add namespace
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
@@ -130,7 +130,7 @@
|
||||
<td colspan="4" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
||||
<td colspan="4" class="text-center text-muted">No resource pool available.</td>
|
||||
<td colspan="4" class="text-center text-muted">No namespace available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('ResourcePool.Namespace.Name')">
|
||||
Resource pool
|
||||
Namespace
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourcePool.Namespace.Name' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourcePool.Namespace.Name' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a ui-sref="kubernetes.dashboard({endpointId: $ctrl.endpointId})" ui-sref-active="active">Dashboard <span class="menu-icon fa fa-tachometer-alt fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="kubernetes.resourcePools({endpointId: $ctrl.endpointId})" ui-sref-active="active">Resource pools <span class="menu-icon fa fa-layer-group fa-fw"></span></a>
|
||||
<a ui-sref="kubernetes.resourcePools({endpointId: $ctrl.endpointId})" ui-sref-active="active">Namespaces <span class="menu-icon fa fa-layer-group fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list">
|
||||
<a ui-sref="kubernetes.applications({endpointId: $ctrl.endpointId})" ui-sref-active="active">Applications <span class="menu-icon fa fa-laptop-code fa-fw"></span></a>
|
||||
|
||||
@@ -132,4 +132,14 @@ angular
|
||||
};
|
||||
return values[text] || text;
|
||||
};
|
||||
})
|
||||
.filter('kubernetesApplicationIngressEmptyHostname', function () {
|
||||
'use strict';
|
||||
return function (value) {
|
||||
if (value === '') {
|
||||
return '<use IP>';
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ class KubernetesFormValidationHelper {
|
||||
const groupped = _.groupBy(names);
|
||||
const res = {};
|
||||
_.forEach(names, (name, index) => {
|
||||
if (groupped[name].length > 1 && name) {
|
||||
if (name && groupped[name].length > 1) {
|
||||
res[index] = name;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,6 +29,8 @@ class KubernetesResourceReservationHelper {
|
||||
let res = parseInt(cpu, 10);
|
||||
if (_.endsWith(cpu, 'm')) {
|
||||
res /= 1000;
|
||||
} else if (_.endsWith(cpu, 'n')) {
|
||||
res /= 1000000000;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -2,22 +2,18 @@ import _ from 'lodash-es';
|
||||
import * as JsonPatch from 'fast-json-patch';
|
||||
|
||||
import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper';
|
||||
import { KubernetesResourcePoolIngressClassAnnotationFormValue, KubernetesResourcePoolIngressClassFormValue } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import {
|
||||
KubernetesResourcePoolIngressClassAnnotationFormValue,
|
||||
KubernetesResourcePoolIngressClassFormValue,
|
||||
KubernetesResourcePoolIngressClassHostFormValue,
|
||||
} from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesIngress, KubernetesIngressRule } from './models';
|
||||
import { KubernetesIngressCreatePayload, KubernetesIngressRuleCreatePayload, KubernetesIngressRulePathCreatePayload } from './payloads';
|
||||
import { KubernetesIngressClassAnnotation, KubernetesIngressClassRewriteTargetAnnotations } from './constants';
|
||||
|
||||
export class KubernetesIngressConverter {
|
||||
// TODO: refactor @LP
|
||||
// currently only allows the first non-empty host to be used as the "configured" host.
|
||||
// As we currently only allow a single host to be used for a Portianer-managed ingress
|
||||
// it's working as the only non-empty host will be the one defined by the admin
|
||||
// but it will take a random existing host for non Portainer ingresses (CLI deployed)
|
||||
// Also won't support multiple hosts if we make it available in the future
|
||||
static apiToModel(data) {
|
||||
let host = undefined;
|
||||
const paths = _.flatMap(data.spec.rules, (rule) => {
|
||||
host = host || rule.host; // TODO: refactor @LP - read above
|
||||
return !rule.http
|
||||
? []
|
||||
: _.map(rule.http.paths, (path) => {
|
||||
@@ -41,7 +37,11 @@ export class KubernetesIngressConverter {
|
||||
? data.metadata.annotations[KubernetesIngressClassAnnotation]
|
||||
: data.spec.ingressClassName;
|
||||
res.Paths = paths;
|
||||
res.Host = host;
|
||||
res.Hosts = _.uniq(_.map(data.spec.rules, 'host'));
|
||||
const idx = _.findIndex(res.Hosts, (h) => h === undefined);
|
||||
if (idx >= 0) {
|
||||
res.Hosts.splice(idx, 1, '');
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export class KubernetesIngressConverter {
|
||||
_.extend(res.Annotations, KubernetesIngressClassRewriteTargetAnnotations[formValues.IngressClass.Type]);
|
||||
}
|
||||
res.Annotations[KubernetesIngressClassAnnotation] = formValues.IngressClass.Name;
|
||||
res.Host = formValues.Host;
|
||||
res.Hosts = formValues.Hosts;
|
||||
res.Paths = formValues.Paths;
|
||||
return res;
|
||||
}
|
||||
@@ -96,7 +96,13 @@ export class KubernetesIngressConverter {
|
||||
if (ingress) {
|
||||
fv.Selected = true;
|
||||
fv.WasSelected = true;
|
||||
fv.Host = ingress.Host;
|
||||
fv.Hosts = _.map(ingress.Hosts, (host) => {
|
||||
const hfv = new KubernetesResourcePoolIngressClassHostFormValue();
|
||||
hfv.Host = host;
|
||||
hfv.PreviousHost = host;
|
||||
hfv.IsNew = false;
|
||||
return hfv;
|
||||
});
|
||||
const [[rewriteKey]] = _.toPairs(KubernetesIngressClassRewriteTargetAnnotations[ic.Type]);
|
||||
const annotations = _.map(_.toPairs(ingress.Annotations), ([key, value]) => {
|
||||
if (key === rewriteKey) {
|
||||
@@ -128,16 +134,17 @@ export class KubernetesIngressConverter {
|
||||
p.Host = '';
|
||||
}
|
||||
});
|
||||
const hostsWithRules = [];
|
||||
const groups = _.groupBy(data.Paths, 'Host');
|
||||
const rules = _.map(groups, (paths, host) => {
|
||||
let rules = _.map(groups, (paths, host) => {
|
||||
const updatedHost = _.find(data.Hosts, (h) => {
|
||||
return h === host || h.PreviousHost === host;
|
||||
});
|
||||
host = updatedHost.Host || updatedHost;
|
||||
if (updatedHost.NeedsDeletion) {
|
||||
return;
|
||||
}
|
||||
const rule = new KubernetesIngressRuleCreatePayload();
|
||||
|
||||
if (host === 'undefined' || _.isEmpty(host)) {
|
||||
host = data.Host;
|
||||
}
|
||||
if (host === data.PreviousHost && host !== data.Host) {
|
||||
host = data.Host;
|
||||
}
|
||||
KubernetesCommonHelper.assignOrDeleteIfEmpty(rule, 'host', host);
|
||||
rule.http.paths = _.map(paths, (p) => {
|
||||
const path = new KubernetesIngressRulePathCreatePayload();
|
||||
@@ -146,11 +153,27 @@ export class KubernetesIngressConverter {
|
||||
path.backend.servicePort = p.Port;
|
||||
return path;
|
||||
});
|
||||
hostsWithRules.push(host);
|
||||
return rule;
|
||||
});
|
||||
rules = _.without(rules, undefined);
|
||||
const keptHosts = _.without(
|
||||
_.map(data.Hosts, (h) => (h.NeedsDeletion ? undefined : h.Host || h)),
|
||||
undefined
|
||||
);
|
||||
const hostsWithoutRules = _.without(keptHosts, ...hostsWithRules);
|
||||
const emptyRules = _.map(hostsWithoutRules, (host) => {
|
||||
return { host: host };
|
||||
});
|
||||
rules = _.concat(rules, emptyRules);
|
||||
KubernetesCommonHelper.assignOrDeleteIfEmpty(res, 'spec.rules', rules);
|
||||
} else if (data.Host) {
|
||||
res.spec.rules = [{ host: data.Host }];
|
||||
} else if (data.Hosts) {
|
||||
res.spec.rules = [];
|
||||
_.forEach(data.Hosts, (host) => {
|
||||
if (!host.NeedsDeletion) {
|
||||
res.spec.rules.push({ host: host.Host });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
delete res.spec.rules;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ export function KubernetesIngress() {
|
||||
Name: '',
|
||||
Namespace: '',
|
||||
Annotations: {},
|
||||
Host: undefined,
|
||||
PreviousHost: undefined, // only use for RP ingress host edit
|
||||
// Host: undefined,
|
||||
Hosts: [],
|
||||
// PreviousHost: undefined, // only use for RP ingress host edit
|
||||
Paths: [],
|
||||
IngressClassName: '',
|
||||
};
|
||||
|
||||
53
app/kubernetes/metrics/metrics.js
Normal file
53
app/kubernetes/metrics/metrics.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import angular from 'angular';
|
||||
import PortainerError from 'Portainer/error';
|
||||
import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
|
||||
|
||||
class KubernetesMetricsService {
|
||||
/* @ngInject */
|
||||
constructor($async, KubernetesMetrics) {
|
||||
this.$async = $async;
|
||||
this.KubernetesMetrics = KubernetesMetrics;
|
||||
|
||||
this.capabilitiesAsync = this.capabilitiesAsync.bind(this);
|
||||
this.getPodAsync = this.getPodAsync.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET
|
||||
*/
|
||||
async capabilitiesAsync(endpointID) {
|
||||
try {
|
||||
await this.KubernetesMetrics().capabilities({ endpointId: endpointID }).$promise;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to retrieve metrics', err);
|
||||
}
|
||||
}
|
||||
|
||||
capabilities(endpointID) {
|
||||
return this.$async(this.capabilitiesAsync, endpointID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats
|
||||
*
|
||||
* @param {string} namespace
|
||||
* @param {string} podName
|
||||
*/
|
||||
async getPodAsync(namespace, podName) {
|
||||
try {
|
||||
const params = new KubernetesCommonParams();
|
||||
params.id = podName;
|
||||
const data = await this.KubernetesMetrics(namespace).getPod(params).$promise;
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to retrieve pod stats', err);
|
||||
}
|
||||
}
|
||||
|
||||
getPod(namespace, podName) {
|
||||
return this.$async(this.getPodAsync, namespace, podName);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesMetricsService;
|
||||
angular.module('portainer.kubernetes').service('KubernetesMetricsService', KubernetesMetricsService);
|
||||
27
app/kubernetes/metrics/rest.js
Normal file
27
app/kubernetes/metrics/rest.js
Normal file
@@ -0,0 +1,27 @@
|
||||
angular.module('portainer.kubernetes').factory('KubernetesMetrics', [
|
||||
'$resource',
|
||||
'API_ENDPOINT_ENDPOINTS',
|
||||
'EndpointProvider',
|
||||
function KubernetesMetrics($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
||||
'use strict';
|
||||
return function (namespace) {
|
||||
const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/metrics.k8s.io/v1beta1';
|
||||
const podUrl = `${url}${namespace ? '/namespaces/:namespace' : ''}/pods/:id`;
|
||||
|
||||
return $resource(
|
||||
url,
|
||||
{
|
||||
endpointId: EndpointProvider.endpointID,
|
||||
namespace: namespace,
|
||||
},
|
||||
{
|
||||
capabilities: { method: 'GET' },
|
||||
getPod: {
|
||||
method: 'GET',
|
||||
url: podUrl,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
]);
|
||||
@@ -18,12 +18,12 @@ const _KubernetesApplicationFormValues = Object.freeze({
|
||||
AutoScaler: {},
|
||||
Containers: [],
|
||||
EnvironmentVariables: [], // KubernetesApplicationEnvironmentVariableFormValue list
|
||||
DataAccessPolicy: KubernetesApplicationDataAccessPolicies.SHARED,
|
||||
DataAccessPolicy: KubernetesApplicationDataAccessPolicies.ISOLATED,
|
||||
PersistedFolders: [], // KubernetesApplicationPersistedFolderFormValue list
|
||||
Configurations: [], // KubernetesApplicationConfigurationFormValue list
|
||||
PublishingType: KubernetesApplicationPublishingTypes.INTERNAL,
|
||||
PublishedPorts: [], // KubernetesApplicationPublishedPortFormValue list
|
||||
PlacementType: KubernetesApplicationPlacementTypes.PREFERRED,
|
||||
PlacementType: KubernetesApplicationPlacementTypes.MANDATORY,
|
||||
Placements: [], // KubernetesApplicationPlacementFormValue list
|
||||
OriginalIngresses: undefined,
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export function KubernetesResourcePoolIngressClassFormValue(ingressClass) {
|
||||
IngressClass: ingressClass,
|
||||
RewriteTarget: false,
|
||||
Annotations: [], // KubernetesResourcePoolIngressClassAnnotationFormValue
|
||||
Host: undefined,
|
||||
Hosts: [],
|
||||
Selected: false,
|
||||
WasSelected: false,
|
||||
AdvancedConfig: false,
|
||||
@@ -31,3 +31,12 @@ export function KubernetesResourcePoolIngressClassAnnotationFormValue() {
|
||||
Value: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function KubernetesResourcePoolIngressClassHostFormValue() {
|
||||
return {
|
||||
Host: '',
|
||||
PreviousHost: '',
|
||||
NeedsDeletion: false,
|
||||
IsNew: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Application console" state="kubernetes.applications.application.console" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> >
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.application.ResourcePool })">{{ ctrl.application.ResourcePool }}</a> >
|
||||
<a ui-sref="kubernetes.applications">Applications</a> >
|
||||
<a ui-sref="kubernetes.applications.application({ name: ctrl.application.Name, namespace: ctrl.application.ResourcePool })">{{ ctrl.application.Name }}</a> > Pods >
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a ui-sref="kubernetes.applications">Applications</a> > Create an application
|
||||
</kubernetes-view-header>
|
||||
<kubernetes-view-header ng-if="ctrl.state.isEdit" title="Edit application" state="kubernetes.applications.application.edit" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> >
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.application.ResourcePool })">{{ ctrl.application.ResourcePool }}</a> >
|
||||
<a ui-sref="kubernetes.applications">Applications</a> >
|
||||
<a ui-sref="kubernetes.applications.application({ name: ctrl.application.Name, namespace: ctrl.application.ResourcePool })">{{ ctrl.application.Name }}</a> > Edit
|
||||
@@ -44,7 +44,7 @@
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.alreadyExists"
|
||||
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> An application with the same name already exists inside the selected resource pool.</p
|
||||
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> An application with the same name already exists inside the selected namespace.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,11 +84,11 @@
|
||||
<!-- #endregion -->
|
||||
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Resource pool
|
||||
Namespace
|
||||
</div>
|
||||
<!-- #region RESOURCE POOL -->
|
||||
<!-- #region NAMESPACE -->
|
||||
<div class="form-group">
|
||||
<label for="resource-pool-selector" class="col-sm-1 control-label text-left">Resource pool</label>
|
||||
<label for="resource-pool-selector" class="col-sm-1 control-label text-left">Namespace</label>
|
||||
<div class="col-sm-11">
|
||||
<select
|
||||
class="form-control"
|
||||
@@ -103,8 +103,8 @@
|
||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded()">
|
||||
<div class="col-sm-12 small text-danger">
|
||||
<i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
This resource pool has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
|
||||
resource pool.
|
||||
This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
|
||||
namespace.
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
@@ -554,39 +554,6 @@
|
||||
<!-- access policy options -->
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<div class="boxselector_wrapper">
|
||||
<div ng-if="!ctrl.state.isEdit || (ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED)">
|
||||
<input
|
||||
type="radio"
|
||||
id="data_access_shared"
|
||||
ng-value="ctrl.ApplicationDataAccessPolicies.SHARED"
|
||||
ng-model="ctrl.formValues.DataAccessPolicy"
|
||||
ng-change="ctrl.resetDeploymentType()"
|
||||
/>
|
||||
<label for="data_access_shared">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-cube" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Shared
|
||||
</div>
|
||||
<p>All the instances of this application will use the same data</p>
|
||||
</label>
|
||||
</div>
|
||||
<div style="color: #767676;" ng-if="ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.ISOLATED">
|
||||
<input type="radio" id="data_access_shared" disabled />
|
||||
<label
|
||||
for="data_access_shared"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the data access policy is not allowed"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-cube" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Shared
|
||||
</div>
|
||||
<p>All the instances of this application will use the same data</p>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
ng-if="
|
||||
(!ctrl.state.isEdit && !ctrl.state.persistedFoldersUseExistingVolumes) ||
|
||||
@@ -605,7 +572,7 @@
|
||||
<i class="fa fa-cubes" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Isolated
|
||||
</div>
|
||||
<p>Every instance of this application will use their own data</p>
|
||||
<p>Application will be deployed as a StatefulSet with each instantiating their own data</p>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
@@ -625,7 +592,40 @@
|
||||
<i class="fa fa-cubes" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Isolated
|
||||
</div>
|
||||
<p>Every instance of this application will use their own data</p>
|
||||
<p>Application will be deployed as a StatefulSet with each instantiating their own data</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="!ctrl.state.isEdit || (ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.SHARED)">
|
||||
<input
|
||||
type="radio"
|
||||
id="data_access_shared"
|
||||
ng-value="ctrl.ApplicationDataAccessPolicies.SHARED"
|
||||
ng-model="ctrl.formValues.DataAccessPolicy"
|
||||
ng-change="ctrl.resetDeploymentType()"
|
||||
/>
|
||||
<label for="data_access_shared">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-cube" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Shared
|
||||
</div>
|
||||
<p>Application will be deployed as a Deployment with a shared storage access</p>
|
||||
</label>
|
||||
</div>
|
||||
<div style="color: #767676;" ng-if="ctrl.state.isEdit && ctrl.formValues.DataAccessPolicy === ctrl.ApplicationDataAccessPolicies.ISOLATED">
|
||||
<input type="radio" id="data_access_shared" disabled />
|
||||
<label
|
||||
for="data_access_shared"
|
||||
tooltip-append-to-body="true"
|
||||
tooltip-placement="bottom"
|
||||
tooltip-class="portainer-tooltip"
|
||||
uib-tooltip="Changing the data access policy is not allowed"
|
||||
style="cursor: pointer; border-color: #767676;"
|
||||
>
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-cube" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Shared
|
||||
</div>
|
||||
<p>Application will be deployed as a Deployment with a shared storage access</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -648,16 +648,16 @@
|
||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && !ctrl.resourceQuotaCapacityExceeded()">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
A resource quota is set on this resource pool, you must specify resource reservations. Resource reservations are applied per instance of the application. Maximums
|
||||
are inherited from the resource pool quota.
|
||||
A resource quota is set on this namespace, you must specify resource reservations. Resource reservations are applied per instance of the application. Maximums are
|
||||
inherited from the namespace quota.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded()">
|
||||
<div class="col-sm-12 small text-danger">
|
||||
<i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
This resource pool has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
|
||||
resource pool.
|
||||
This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
|
||||
namespace.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -768,7 +768,7 @@
|
||||
<i class="fa fa-cubes" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Global
|
||||
</div>
|
||||
<p>Deploy an instance of this container on each node of the cluster</p>
|
||||
<p>Application will be deployed as a DaemonSet with an instance on each node of the cluster</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="ctrl.supportGlobalDeployment()">
|
||||
@@ -784,7 +784,7 @@
|
||||
<i class="fa fa-cubes" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Global
|
||||
</div>
|
||||
<p>Deploy an instance of this container on each node of the cluster</p>
|
||||
<p>Application will be deployed as a DaemonSet with an instance on each node of the cluster</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1037,16 +1037,6 @@
|
||||
<!-- placement policy options -->
|
||||
<div class="form-group" style="margin-bottom: 0;" ng-if="ctrl.formValues.Placements.length">
|
||||
<div class="boxselector_wrapper">
|
||||
<div>
|
||||
<input type="radio" id="placement_soft" ng-value="ctrl.ApplicationPlacementTypes.PREFERRED" ng-model="ctrl.formValues.PlacementType" />
|
||||
<label for="placement_soft">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Preferred
|
||||
</div>
|
||||
<p>Schedule this application on nodes that match the rules if possible</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="placement_hard" ng-value="ctrl.ApplicationPlacementTypes.MANDATORY" ng-model="ctrl.formValues.PlacementType" />
|
||||
<label for="placement_hard">
|
||||
@@ -1057,6 +1047,16 @@
|
||||
<p>Schedule this application <b>ONLY</b> on nodes that match <b>ALL</b> Rules</p>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="placement_soft" ng-value="ctrl.ApplicationPlacementTypes.PREFERRED" ng-model="ctrl.formValues.PlacementType" />
|
||||
<label for="placement_soft">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Preferred
|
||||
</div>
|
||||
<p>Schedule this application on nodes that match the rules if possible</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !placement policy options -->
|
||||
@@ -1264,7 +1264,14 @@
|
||||
tooltip-enable="ctrl.disableLoadBalancerEdit()"
|
||||
uib-tooltip="Edition is not allowed while the Load Balancer is in 'Pending' state"
|
||||
>
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-class="{ striked: publishedPort.NeedsDeletion }">
|
||||
<div
|
||||
class="input-group input-group-sm"
|
||||
ng-class="{
|
||||
striked: publishedPort.NeedsDeletion,
|
||||
'col-sm-2': ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS,
|
||||
'col-sm-3': ctrl.formValues.PublishingType !== ctrl.ApplicationPublishingTypes.INGRESS
|
||||
}"
|
||||
>
|
||||
<span class="input-group-addon">container port</span>
|
||||
<input
|
||||
type="number"
|
||||
@@ -1327,7 +1334,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-3 input-group input-group-sm"
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
@@ -1349,7 +1356,28 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-3 input-group input-group-sm"
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
(!publishedPort.IsNew && ctrl.savedFormValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS)
|
||||
"
|
||||
>
|
||||
<span class="input-group-addon">hostname</span>
|
||||
<select
|
||||
class="form-control"
|
||||
name="ingress_hostname_{{ $index }}"
|
||||
ng-model="publishedPort.IngressHost"
|
||||
ng-options="host as (host | kubernetesApplicationIngressEmptyHostname) for host in ctrl.ingressHostnames"
|
||||
ng-change="ctrl.onChangePublishedPorts()"
|
||||
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
|
||||
>
|
||||
<option selected disabled hidden value="">Select a hostname</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="col-sm-2 input-group input-group-sm"
|
||||
ng-class="{ striked: publishedPort.NeedsDeletion }"
|
||||
ng-if="
|
||||
(publishedPort.IsNew && ctrl.formValues.PublishingType === ctrl.ApplicationPublishingTypes.INGRESS) ||
|
||||
|
||||
@@ -320,7 +320,7 @@ class KubernetesCreateApplicationController {
|
||||
const p = new KubernetesApplicationPublishedPortFormValue();
|
||||
const ingresses = this.filteredIngresses;
|
||||
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
|
||||
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Host : undefined;
|
||||
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined;
|
||||
if (this.formValues.PublishedPorts.length) {
|
||||
p.Protocol = this.formValues.PublishedPorts[0].Protocol;
|
||||
}
|
||||
@@ -331,7 +331,7 @@ class KubernetesCreateApplicationController {
|
||||
const ingresses = this.filteredIngresses;
|
||||
_.forEach(this.formValues.PublishedPorts, (p) => {
|
||||
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
|
||||
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Host : undefined;
|
||||
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Hosts[0] : undefined;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -388,7 +388,8 @@ class KubernetesCreateApplicationController {
|
||||
onChangePortMappingIngress(index) {
|
||||
const publishedPort = this.formValues.PublishedPorts[index];
|
||||
const ingress = _.find(this.filteredIngresses, { Name: publishedPort.IngressName });
|
||||
publishedPort.IngressHost = ingress.Host;
|
||||
this.ingressHostnames = ingress.Hosts;
|
||||
publishedPort.IngressHost = this.ingressHostnames.length ? this.ingressHostnames[0] : [];
|
||||
this.onChangePublishedPorts();
|
||||
}
|
||||
|
||||
@@ -780,6 +781,7 @@ class KubernetesCreateApplicationController {
|
||||
|
||||
refreshIngresses(namespace) {
|
||||
this.filteredIngresses = _.filter(this.ingresses, { Namespace: namespace });
|
||||
this.ingressHostnames = this.filteredIngresses.length ? this.filteredIngresses[0].Hosts : [];
|
||||
if (!this.publishViaIngressEnabled()) {
|
||||
if (this.savedFormValues) {
|
||||
this.formValues.PublishingType = this.savedFormValues.PublishingType;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Application details" state="kubernetes.applications.application" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> >
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.application.ResourcePool })">{{ ctrl.application.ResourcePool }}</a> >
|
||||
<a ui-sref="kubernetes.applications">Applications</a> > {{ ctrl.application.Name }}
|
||||
</kubernetes-view-header>
|
||||
@@ -29,7 +29,7 @@
|
||||
<td>{{ ctrl.application.StackName }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Resource pool</td>
|
||||
<td>Namespace</td>
|
||||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.application.ResourcePool })">{{ ctrl.application.ResourcePool }}</a>
|
||||
<span style="margin-left: 5px;" class="label label-info image-tag" ng-if="ctrl.isSystemNamespace(item)">system</span>
|
||||
@@ -587,6 +587,7 @@
|
||||
table-key="kubernetes.application.containers"
|
||||
is-pod="ctrl.application.ApplicationType === ctrl.KubernetesApplicationTypes.POD"
|
||||
order-by="{{ ctrl.application.ApplicationType === ctrl.KubernetesApplicationTypes.POD ? 'Name' : 'PodName' }}"
|
||||
use-server-metrics="ctrl.state.useServerMetrics"
|
||||
>
|
||||
</kubernetes-containers-datatable>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,8 @@ class KubernetesApplicationController {
|
||||
KubernetesStackService,
|
||||
KubernetesPodService,
|
||||
KubernetesNodeService,
|
||||
KubernetesNamespaceHelper
|
||||
KubernetesNamespaceHelper,
|
||||
EndpointProvider
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
@@ -125,6 +126,8 @@ class KubernetesApplicationController {
|
||||
|
||||
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
|
||||
this.KubernetesApplicationTypes = KubernetesApplicationTypes;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
|
||||
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
|
||||
this.KubernetesServiceTypes = KubernetesServiceTypes;
|
||||
this.KubernetesPodContainerTypes = KubernetesPodContainerTypes;
|
||||
@@ -335,6 +338,7 @@ class KubernetesApplicationController {
|
||||
placementWarning: false,
|
||||
expandedNote: false,
|
||||
useIngress: false,
|
||||
useServerMetrics: this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.UseServerMetrics,
|
||||
};
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('application');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Application logs" state="kubernetes.applications.application.logs" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> >
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.application.ResourcePool })">{{ ctrl.application.ResourcePool }}</a> >
|
||||
<a ui-sref="kubernetes.applications">Applications</a> >
|
||||
<a ui-sref="kubernetes.applications.application({ name: ctrl.application.Name, namespace: ctrl.application.ResourcePool })">{{ ctrl.application.Name }}</a> > Pods >
|
||||
|
||||
82
app/kubernetes/views/applications/stats/stats.html
Normal file
82
app/kubernetes/views/applications/stats/stats.html
Normal file
@@ -0,0 +1,82 @@
|
||||
<kubernetes-view-header title="Application stats" state="kubernetes.applications.application.stats" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.state.transition.namespace })">{{ ctrl.state.transition.namespace }}</a> >
|
||||
<a ui-sref="kubernetes.applications">Applications</a> >
|
||||
<a ui-sref="kubernetes.applications.application({ name: ctrl.state.transition.applicationName, namespace: ctrl.state.transition.namespace })">{{
|
||||
ctrl.state.transition.applicationName
|
||||
}}</a>
|
||||
> Pods > {{ ctrl.state.transition.podName }} > Containers > {{ ctrl.state.transition.containerName }} > Stats
|
||||
</kubernetes-view-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
|
||||
<span class="small text-muted">
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
|
||||
configured.
|
||||
</span>
|
||||
</information-panel>
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-md-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-info-circle" title-text="About statistics"> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left">
|
||||
Refresh rate
|
||||
</label>
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<span>
|
||||
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted"> <i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i> Network stats are unavailable for this container. </span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-lg-6 col-md-12 col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-chart-area" title-text="Memory usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative;">
|
||||
<canvas id="memoryChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-chart-area" title-text="CPU usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative;">
|
||||
<canvas id="cpuChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
8
app/kubernetes/views/applications/stats/stats.js
Normal file
8
app/kubernetes/views/applications/stats/stats.js
Normal file
@@ -0,0 +1,8 @@
|
||||
angular.module('portainer.kubernetes').component('kubernetesApplicationStatsView', {
|
||||
templateUrl: './stats.html',
|
||||
controller: 'KubernetesApplicationStatsController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
$transition$: '<',
|
||||
},
|
||||
});
|
||||
164
app/kubernetes/views/applications/stats/statsController.js
Normal file
164
app/kubernetes/views/applications/stats/statsController.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import angular from 'angular';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import KubernetesPodConverter from 'Kubernetes/pod/converter';
|
||||
|
||||
class KubernetesApplicationStatsController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $interval, $document, Notifications, KubernetesPodService, KubernetesNodeService, KubernetesMetricsService, ChartService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$interval = $interval;
|
||||
this.$document = $document;
|
||||
this.Notifications = Notifications;
|
||||
this.KubernetesPodService = KubernetesPodService;
|
||||
this.KubernetesNodeService = KubernetesNodeService;
|
||||
this.KubernetesMetricsService = KubernetesMetricsService;
|
||||
this.ChartService = ChartService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
}
|
||||
|
||||
changeUpdateRepeater() {
|
||||
var cpuChart = this.cpuChart;
|
||||
var memoryChart = this.memoryChart;
|
||||
|
||||
this.stopRepeater();
|
||||
this.setUpdateRepeater(cpuChart, memoryChart);
|
||||
$('#refreshRateChange').show();
|
||||
$('#refreshRateChange').fadeOut(1500);
|
||||
}
|
||||
|
||||
updateCPUChart() {
|
||||
const label = moment(this.stats.read).format('HH:mm:ss');
|
||||
|
||||
this.ChartService.UpdateCPUChart(label, this.stats.CPUUsage, this.cpuChart);
|
||||
}
|
||||
|
||||
updateMemoryChart() {
|
||||
const label = moment(this.stats.read).format('HH:mm:ss');
|
||||
|
||||
this.ChartService.UpdateMemoryChart(label, this.stats.MemoryUsage, this.stats.MemoryCache, this.memoryChart);
|
||||
}
|
||||
|
||||
stopRepeater() {
|
||||
var repeater = this.repeater;
|
||||
if (angular.isDefined(repeater)) {
|
||||
this.$interval.cancel(repeater);
|
||||
repeater = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
setUpdateRepeater() {
|
||||
const refreshRate = this.state.refreshRate;
|
||||
|
||||
this.repeater = this.$interval(async () => {
|
||||
try {
|
||||
await this.getStats();
|
||||
|
||||
this.updateCPUChart();
|
||||
this.updateMemoryChart();
|
||||
} catch (error) {
|
||||
this.stopRepeater();
|
||||
this.Notifications.error('Failure', error);
|
||||
}
|
||||
}, refreshRate * 1000);
|
||||
}
|
||||
|
||||
initCharts() {
|
||||
const cpuChartCtx = $('#cpuChart');
|
||||
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
|
||||
this.cpuChart = cpuChart;
|
||||
|
||||
const memoryChartCtx = $('#memoryChart');
|
||||
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
|
||||
this.memoryChart = memoryChart;
|
||||
|
||||
this.updateCPUChart();
|
||||
this.updateMemoryChart();
|
||||
this.setUpdateRepeater();
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const stats = await this.KubernetesMetricsService.getPod(this.state.transition.namespace, this.state.transition.podName);
|
||||
const container = _.find(stats.containers, { name: this.state.transition.containerName });
|
||||
if (container) {
|
||||
const memory = filesizeParser(container.usage.memory);
|
||||
const cpu = KubernetesResourceReservationHelper.parseCPU(container.usage.cpu);
|
||||
this.stats = {
|
||||
read: stats.timestamp,
|
||||
preread: '',
|
||||
MemoryCache: 0,
|
||||
MemoryUsage: memory,
|
||||
NumProcs: '',
|
||||
isWindows: false,
|
||||
PreviousCPUTotalUsage: 0,
|
||||
CPUUsage: (cpu / this.nodeCPU) * 100,
|
||||
CPUCores: 0,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve application stats');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.stopRepeater();
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
autoRefresh: false,
|
||||
refreshRate: '30',
|
||||
viewReady: false,
|
||||
transition: {
|
||||
podName: this.$transition$.params().pod,
|
||||
containerName: this.$transition$.params().container,
|
||||
namespace: this.$transition$.params().namespace,
|
||||
applicationName: this.$transition$.params().name,
|
||||
},
|
||||
getMetrics: false,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.KubernetesMetricsService.getPod(this.state.transition.namespace, this.state.transition.podName);
|
||||
} catch (error) {
|
||||
this.state.getMetrics = false;
|
||||
this.state.viewReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const podRaw = await this.KubernetesPodService.get(this.state.transition.namespace, this.state.transition.podName);
|
||||
const pod = KubernetesPodConverter.apiToModel(podRaw.Raw);
|
||||
if (pod) {
|
||||
const node = await this.KubernetesNodeService.get(pod.Node);
|
||||
this.nodeCPU = node.CPU;
|
||||
} else {
|
||||
throw new Error('Unable to find pod');
|
||||
}
|
||||
await this.getStats();
|
||||
this.state.getMetrics = true;
|
||||
|
||||
this.$document.ready(() => {
|
||||
this.initCharts();
|
||||
});
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve application stats');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesApplicationStatsController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesApplicationStatsController', KubernetesApplicationStatsController);
|
||||
@@ -37,19 +37,19 @@
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.alreadyExist"
|
||||
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> A configuration with the same name already exists inside the selected resource pool.</p
|
||||
><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> A configuration with the same name already exists inside the selected namespace.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name -->
|
||||
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Resource pool
|
||||
Namespace
|
||||
</div>
|
||||
|
||||
<!-- resource-pool -->
|
||||
<div class="form-group">
|
||||
<label for="resource-pool-selector" class="col-sm-1 control-label text-left">Resource pool</label>
|
||||
<label for="resource-pool-selector" class="col-sm-1 control-label text-left">Namespace</label>
|
||||
<div class="col-sm-11">
|
||||
<select
|
||||
class="form-control"
|
||||
@@ -62,8 +62,8 @@
|
||||
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded()">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
This resource pool has exhausted its resource capacity and you will not be able to deploy the configuration. Contact your administrator to expand the capacity of
|
||||
the resource pool.
|
||||
This namespace has exhausted its resource capacity and you will not be able to deploy the configuration. Contact your administrator to expand the capacity of the
|
||||
namespace.
|
||||
</div>
|
||||
</div>
|
||||
<!-- !resource-pool -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Configuration details" state="kubernetes.configurations.configuration" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> >
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.configuration.Namespace })">{{ ctrl.configuration.Namespace }}</a> >
|
||||
<a ui-sref="kubernetes.configurations">Configurations</a> > {{ ctrl.configuration.Name }}
|
||||
</kubernetes-view-header>
|
||||
@@ -24,7 +24,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Resource Pool</td>
|
||||
<td>Namespace</td>
|
||||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.configuration.Namespace })">{{ ctrl.configuration.Namespace }}</a>
|
||||
<span style="margin-left: 5px;" class="label label-info image-tag" ng-if="ctrl.isSystemNamespace()">system</span>
|
||||
|
||||
@@ -196,7 +196,27 @@
|
||||
<label class="control-label text-left">
|
||||
Enable features using metrics server
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.UseServerMetrics" /><i></i> </label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="ctrl.formValues.UseServerMetrics" ng-change="ctrl.enableMetricsServer()" /><i></i>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="ctrl.state.metrics.pending && ctrl.state.metrics.userClick" class="col-sm-12 small text-muted" style="margin-top: 5px;">
|
||||
Checking metrics API... <i class="fa fa-spinner fa-spin" style="margin-left: 2px;"></i>
|
||||
</div>
|
||||
<div
|
||||
ng-if="!ctrl.state.metrics.pending && ctrl.state.metrics.isServerRunning && ctrl.state.metrics.userClick"
|
||||
class="col-sm-12 small text-muted"
|
||||
style="margin-top: 5px;"
|
||||
>
|
||||
<i class="fa fa-check green-icon" aria-hidden="true" style="margin-right: 2px;"></i> Successfully reached metrics API
|
||||
</div>
|
||||
<div
|
||||
ng-if="!ctrl.state.metrics.pending && !ctrl.state.metrics.isServerRunning && ctrl.state.metrics.userClick"
|
||||
class="col-sm-12 small text-muted"
|
||||
style="margin-top: 5px;"
|
||||
>
|
||||
<i class="fa fa-times red-icon" aria-hidden="true" style="margin-right: 2px;"></i> Unable to reach metrics API, make sure metrics server is properly deployed inside
|
||||
that cluster.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ class KubernetesConfigureController {
|
||||
ModalService,
|
||||
KubernetesNamespaceHelper,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesIngressService
|
||||
KubernetesIngressService,
|
||||
KubernetesMetricsService
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
@@ -41,6 +42,7 @@ class KubernetesConfigureController {
|
||||
this.KubernetesNamespaceHelper = KubernetesNamespaceHelper;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.KubernetesIngressService = KubernetesIngressService;
|
||||
this.KubernetesMetricsService = KubernetesMetricsService;
|
||||
|
||||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
|
||||
@@ -161,6 +163,27 @@ class KubernetesConfigureController {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
enableMetricsServer() {
|
||||
if (this.formValues.UseServerMetrics) {
|
||||
this.state.metrics.userClick = true;
|
||||
this.state.metrics.pending = true;
|
||||
this.KubernetesMetricsService.capabilities(this.endpoint.Id)
|
||||
.then(() => {
|
||||
this.state.metrics.isServerRunning = true;
|
||||
this.state.metrics.pending = false;
|
||||
this.formValues.UseServerMetrics = true;
|
||||
})
|
||||
.catch(() => {
|
||||
this.state.metrics.isServerRunning = false;
|
||||
this.state.metrics.pending = false;
|
||||
this.formValues.UseServerMetrics = false;
|
||||
});
|
||||
} else {
|
||||
this.state.metrics.userClick = false;
|
||||
this.formValues.UseServerMetrics = false;
|
||||
}
|
||||
}
|
||||
|
||||
async configureAsync() {
|
||||
try {
|
||||
@@ -222,6 +245,11 @@ class KubernetesConfigureController {
|
||||
duplicates: {
|
||||
ingressClasses: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
metrics: {
|
||||
pending: false,
|
||||
isServerRunning: false,
|
||||
userClick: false,
|
||||
},
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<i class="fa fa-layer-group"></i>
|
||||
</div>
|
||||
<div class="title">{{ ctrl.pools.length }}</div>
|
||||
<div class="comment">{{ ctrl.pools.length === 1 ? 'Resource pool' : 'Resource pools' }}</div>
|
||||
<div class="comment">{{ ctrl.pools.length === 1 ? 'Namespace' : 'Namespaces' }}</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</a>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<form class="form-horizontal" style="margin-top: 20px;">
|
||||
<div class="form-group">
|
||||
<label for="target_node" class="col-lg-1 col-sm-2 control-label text-left">Resource pool</label>
|
||||
<label for="target_node" class="col-lg-1 col-sm-2 control-label text-left">Namespace</label>
|
||||
<div class="col-lg-11 col-sm-10">
|
||||
<select class="form-control" ng-model="ctrl.formValues.Namespace" ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces"></select>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ class KubernetesDeployController {
|
||||
this.namespaces = namespaces;
|
||||
this.formValues.Namespace = this.namespaces[0].Name;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load resource pools data');
|
||||
this.Notifications.error('Failure', err, 'Unable to load namespaces data');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Resource pool access management" state="kubernetes.resourcePools.resourcePool.access" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> >
|
||||
<kubernetes-view-header title="Namespace access management" state="kubernetes.resourcePools.resourcePool.access" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({id: ctrl.pool.Namespace.Name})">{{ ctrl.pool.Namespace.Name }}</a> > Access management
|
||||
</kubernetes-view-header>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="row" ng-if="ctrl.pool">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-plug" title-text="Resource pool"></rd-widget-header>
|
||||
<rd-widget-header icon="fa-plug" title-text="Namespace"></rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
|
||||
@@ -78,7 +78,7 @@ class KubernetesResourcePoolAccessController {
|
||||
}
|
||||
this.availableUsersAndTeams = _.without(endpointAccesses.authorizedUsersAndTeams, ...this.authorizedUsersAndTeams);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve resource pool information');
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespace information');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Create a resource pool" state="kubernetes.resourcePools.new" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> > Create a resource pool
|
||||
<kubernetes-view-header title="Create a namespace" state="kubernetes.resourcePools.new" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> > Create a namespace
|
||||
</kubernetes-view-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
@@ -36,7 +36,7 @@
|
||||
with an alphanumeric character.</p
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.isAlreadyExist"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> A resource pool with the same name already exists.</p>
|
||||
<p ng-if="ctrl.state.isAlreadyExist"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> A namespace with the same name already exists.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
@@ -50,7 +50,7 @@
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p>
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
A resource pool segments the underyling physical Kubernetes cluster into smaller virtual clusters. You should assign a capped limit of resources to this pool or
|
||||
A namespace segments the underlying physical Kubernetes cluster into smaller virtual clusters. You should assign a capped limit of resources to this namespace or
|
||||
disable for the safe operation of your platform.
|
||||
</p>
|
||||
</div>
|
||||
@@ -137,8 +137,8 @@
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
You can set a quota on the amount of external load balancers that can be created inside this resource pool. Set this quota to 0 to effectively disable the use of
|
||||
load balancers in this resource pool.
|
||||
You can set a quota on the amount of external load balancers that can be created inside this namespace. Set this quota to 0 to effectively disable the use of load
|
||||
balancers in this namespace.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -164,7 +164,7 @@
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Quotas can be set on each storage option to prevent users from exceeding a specific threshold when deploying applications. You can set a quota to 0 to effectively
|
||||
prevent the usage of a specific storage option inside this resource pool.
|
||||
prevent the usage of a specific storage option inside this namespace.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
@@ -195,7 +195,7 @@
|
||||
<div class="col-sm-12 small text-muted">
|
||||
The ingress feature must be enabled in the
|
||||
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: ctrl.endpoint.Id})">endpoint configuration view</a> to be able to register ingresses inside this
|
||||
resource pool.
|
||||
namespace.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses">
|
||||
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses track by ic.IngressClass.Name">
|
||||
<div class="text-muted col-sm-12" style="width: 100%;">
|
||||
<div style="border-bottom: 1px solid #cdcdcd; padding-bottom: 5px;">
|
||||
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i> {{ ic.IngressClass.Name }}
|
||||
@@ -225,30 +225,58 @@
|
||||
|
||||
<div ng-if="ic.Selected">
|
||||
<div class="form-group">
|
||||
<label class="control-label text-left col-sm-2">
|
||||
Hostname
|
||||
<portainer-tooltip
|
||||
position="bottom"
|
||||
message="Hostname associated to the ingress inside this resource pool. Users will be able to expose and access their applications over the ingress via this hostname."
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input class="form-control" name="ingress_host_{{ $index }}" ng-model="ic.Host" placeholder="host.com" ng-change="ctrl.onChangeIngressHostname()" required />
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Hostnames
|
||||
<portainer-tooltip
|
||||
position="bottom"
|
||||
message="Hostnames associated to the ingress inside this namespace. Users will be able to expose and access their applications over the ingress via one of these hostname."
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addHostname(ic)">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add hostname
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="resourcePoolCreationForm['ingress_host_' + $index].$invalid || ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="resourcePoolCreationForm['ingress_host_' + $index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
<div class="col-sm-12" style="margin-top: 10px;">
|
||||
<div ng-repeat="item in ic.Hosts track by $index" style="margin-top: 2px;">
|
||||
<div class="form-inline">
|
||||
<div class="col-sm-10 input-group input-group-sm">
|
||||
<span class="input-group-addon">Hostname</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}"
|
||||
ng-model="item.Host"
|
||||
ng-change="ctrl.onChangeIngressHostname()"
|
||||
placeholder="foo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-1 input-group input-group-sm" ng-if="$index > 0">
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeHostname(ic, $index)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-show="
|
||||
resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined
|
||||
"
|
||||
>
|
||||
<ng-messages for="resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Hostname is required.</p>
|
||||
</ng-messages>
|
||||
<p ng-if="ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This hostname is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
This host is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
@@ -323,7 +351,7 @@
|
||||
ng-click="ctrl.createResourcePool()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Create resource pool</span>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Create namespace</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,11 @@ import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassAnnotationFormValue } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import {
|
||||
KubernetesResourcePoolFormValues,
|
||||
KubernetesResourcePoolIngressClassAnnotationFormValue,
|
||||
KubernetesResourcePoolIngressClassHostFormValue,
|
||||
} from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
@@ -34,17 +38,43 @@ class KubernetesCreateResourcePoolController {
|
||||
|
||||
onChangeIngressHostname() {
|
||||
const state = this.state.duplicates.ingressHosts;
|
||||
|
||||
const hosts = _.map(this.formValues.IngressClasses, 'Host');
|
||||
const allHosts = _.map(this.allIngresses, 'Host');
|
||||
const duplicates = KubernetesFormValidationHelper.getDuplicates(hosts);
|
||||
_.forEach(hosts, (host, idx) => {
|
||||
if (_.includes(allHosts, host) && host !== undefined) {
|
||||
duplicates[idx] = host;
|
||||
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
|
||||
const hostnames = _.map(hosts, 'Host');
|
||||
const hostnamesWithoutRemoved = _.filter(hostnames, (h) => !h.NeedsDeletion);
|
||||
const allHosts = _.flatMap(this.allIngresses, 'Hosts');
|
||||
const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnamesWithoutRemoved);
|
||||
_.forEach(hostnames, (host, idx) => {
|
||||
if (host !== undefined && _.includes(allHosts, host)) {
|
||||
formDuplicates[idx] = host;
|
||||
}
|
||||
});
|
||||
const duplicates = {};
|
||||
let count = 0;
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
duplicates[ic.IngressClass.Name] = {};
|
||||
_.forEach(ic.Hosts, (hostFV, hostIdx) => {
|
||||
if (hostFV.Host === formDuplicates[count]) {
|
||||
duplicates[ic.IngressClass.Name][hostIdx] = hostFV.Host;
|
||||
}
|
||||
count++;
|
||||
});
|
||||
});
|
||||
state.refs = duplicates;
|
||||
state.hasRefs = Object.keys(duplicates).length > 0;
|
||||
state.hasRefs = false;
|
||||
_.forIn(duplicates, (value) => {
|
||||
if (Object.keys(value).length > 0) {
|
||||
state.hasRefs = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addHostname(ingressClass) {
|
||||
ingressClass.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
|
||||
}
|
||||
|
||||
removeHostname(ingressClass, index) {
|
||||
ingressClass.Hosts.splice(index, 1);
|
||||
this.onChangeIngressHostname();
|
||||
}
|
||||
|
||||
/* #region ANNOTATIONS MANAGEMENT */
|
||||
@@ -85,7 +115,7 @@ class KubernetesCreateResourcePoolController {
|
||||
}
|
||||
}
|
||||
|
||||
/* #region CREATE RESOURCE POOL */
|
||||
/* #region CREATE NAMESPACE */
|
||||
async createResourcePoolAsync() {
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
@@ -93,10 +123,10 @@ class KubernetesCreateResourcePoolController {
|
||||
const owner = this.Authentication.getUserDetails().username;
|
||||
this.formValues.Owner = owner;
|
||||
await this.KubernetesResourcePoolService.create(this.formValues);
|
||||
this.Notifications.success('Resource pool successfully created', this.formValues.Name);
|
||||
this.Notifications.success('Namespace successfully created', this.formValues.Name);
|
||||
this.$state.go('kubernetes.resourcePools');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create resource pool');
|
||||
this.Notifications.error('Failure', err, 'Unable to create namespace');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
@@ -121,12 +151,12 @@ class KubernetesCreateResourcePoolController {
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET RESOURCE POOLS */
|
||||
/* #region GET NAMESPACES */
|
||||
async getResourcePoolsAsync() {
|
||||
try {
|
||||
this.resourcePools = await this.KubernetesResourcePoolService.get();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve resource pools');
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespaces');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +198,11 @@ class KubernetesCreateResourcePoolController {
|
||||
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
|
||||
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses);
|
||||
}
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
if (ic.Hosts.length === 0) {
|
||||
ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load view data');
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Resource pool details" state="kubernetes.resourcePools.resourcePool" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> > {{ ctrl.pool.Namespace.Name }}
|
||||
<kubernetes-view-header title="Namespace details" state="kubernetes.resourcePools.resourcePool" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> > {{ ctrl.pool.Namespace.Name }}
|
||||
</kubernetes-view-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
@@ -11,7 +11,7 @@
|
||||
<rd-widget-body classes="no-padding">
|
||||
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
|
||||
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
|
||||
<uib-tab-heading> <i class="fa fa-layer-group space-right" aria-hidden="true"></i> Resource pool </uib-tab-heading>
|
||||
<uib-tab-heading> <i class="fa fa-layer-group space-right" aria-hidden="true"></i> Namespace </uib-tab-heading>
|
||||
<form class="form-horizontal" autocomplete="off" name="resourcePoolEditForm" style="padding: 20px; margin-top: 10px;">
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
@@ -39,7 +39,7 @@
|
||||
<div ng-if="ctrl.formValues.HasQuota">
|
||||
<kubernetes-resource-reservation
|
||||
ng-if="ctrl.pool.Quota"
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this resource pool."
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
|
||||
cpu="ctrl.state.cpuUsed"
|
||||
memory="ctrl.state.memoryUsed"
|
||||
cpu-limit="ctrl.formValues.CpuLimit"
|
||||
@@ -128,8 +128,8 @@
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
You can set a quota on the amount of external load balancers that can be created inside this resource pool. Set this quota to 0 to effectively disable the use
|
||||
of load balancers in this resource pool.
|
||||
You can set a quota on the amount of external load balancers that can be created inside this namespace. Set this quota to 0 to effectively disable the use of
|
||||
load balancers in this namespace.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -154,7 +154,7 @@
|
||||
<div class="col-sm-12 small text-muted">
|
||||
The ingress feature must be enabled in the
|
||||
<a ui-sref="portainer.endpoints.endpoint.kubernetesConfig({id: ctrl.endpoint.Id})">endpoint configuration view</a> to be able to register ingresses inside
|
||||
this resource pool.
|
||||
this namespace.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses">
|
||||
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses track by ic.IngressClass.Name">
|
||||
<div class="text-muted col-sm-12" style="width: 100%;">
|
||||
<div style="border-bottom: 1px solid #cdcdcd; padding-bottom: 5px;">
|
||||
<i class="fa fa-route" aria-hidden="true" style="margin-right: 2px;"></i> {{ ic.IngressClass.Name }}
|
||||
@@ -184,34 +184,57 @@
|
||||
|
||||
<div ng-if="ic.Selected">
|
||||
<div class="form-group">
|
||||
<label class="control-label text-left col-sm-2">
|
||||
Hostname
|
||||
<portainer-tooltip
|
||||
position="bottom"
|
||||
message="Hostname associated to the ingress inside this resource pool. Users will be able to expose and access their applications over the ingress via this hostname."
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
class="form-control"
|
||||
name="ingress_host_{{ $index }}"
|
||||
ng-model="ic.Host"
|
||||
placeholder="host.com"
|
||||
ng-change="ctrl.onChangeIngressHostname()"
|
||||
required
|
||||
/>
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Hostnames
|
||||
<portainer-tooltip
|
||||
position="bottom"
|
||||
message="Hostnames associated to the ingress inside this namespace. Users will be able to expose and access their applications over the ingress via one of these hostname."
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addHostname(ic)">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> add hostname
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="resourcePoolEditForm['ingress_host_' + $index].$invalid || ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="resourcePoolEditForm['ingress_host_' + $index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
<div class="col-sm-12" style="margin-top: 10px;">
|
||||
<div ng-repeat="item in ic.Hosts track by $index" style="margin-top: 2px;">
|
||||
<div class="form-inline">
|
||||
<div class="col-sm-10 input-group input-group-sm" ng-class="{ striked: item.NeedsDeletion }">
|
||||
<span class="input-group-addon">Hostname</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}"
|
||||
ng-model="item.Host"
|
||||
ng-change="ctrl.onChangeIngressHostname()"
|
||||
placeholder="foo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-1 input-group input-group-sm" ng-if="$index > 0">
|
||||
<button ng-if="!item.NeedsDeletion" class="btn btn-sm btn-danger" type="button" ng-click="ctrl.removeHostname(ic, $index)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button ng-if="item.NeedsDeletion" class="btn btn-sm btn-primary" type="button" ng-click="ctrl.restoreHostname(item)">
|
||||
<i class="fa fa-trash-restore" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px;"
|
||||
ng-show="resourcePoolEditForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid || item.Duplicate"
|
||||
>
|
||||
<ng-messages for="resourcePoolEditForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Hostname is required.</p>
|
||||
</ng-messages>
|
||||
<p ng-if="item.Duplicate">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
This hostname is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.ingressHosts.refs[$index] !== undefined">
|
||||
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
This host is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +307,7 @@
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Quotas can be set on each storage option to prevent users from exceeding a specific threshold when deploying applications. You can set a quota to 0 to
|
||||
effectively prevent the usage of a specific storage option inside this resource pool.
|
||||
effectively prevent the usage of a specific storage option inside this namespace.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
@@ -319,7 +342,7 @@
|
||||
ng-click="ctrl.updateResourcePool()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Update resource pool</span>
|
||||
<span ng-hide="ctrl.state.actionInProgress">Update namespace</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Update in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -366,7 +389,7 @@
|
||||
order-by="Name"
|
||||
refresh-callback="ctrl.getApplications"
|
||||
loading="ctrl.state.applicationsLoading"
|
||||
title-text="Applications running in this resource pool"
|
||||
title-text="Applications running in this namespace"
|
||||
title-icon="fa-laptop-code"
|
||||
>
|
||||
</kubernetes-resource-pool-applications-datatable>
|
||||
|
||||
@@ -4,7 +4,11 @@ import filesizeParser from 'filesize-parser';
|
||||
import { KubernetesResourceQuota, KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
|
||||
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassAnnotationFormValue } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import {
|
||||
KubernetesResourcePoolFormValues,
|
||||
KubernetesResourcePoolIngressClassAnnotationFormValue,
|
||||
KubernetesResourcePoolIngressClassHostFormValue,
|
||||
} from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
@@ -62,22 +66,6 @@ class KubernetesResourcePoolController {
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
onChangeIngressHostname() {
|
||||
const state = this.state.duplicates.ingressHosts;
|
||||
|
||||
const hosts = _.map(this.formValues.IngressClasses, 'Host');
|
||||
const otherIngresses = _.without(this.allIngresses, ...this.ingresses);
|
||||
const allHosts = _.map(otherIngresses, 'Host');
|
||||
const duplicates = KubernetesFormValidationHelper.getDuplicates(hosts);
|
||||
_.forEach(hosts, (host, idx) => {
|
||||
if (_.includes(allHosts, host) && host !== undefined) {
|
||||
duplicates[idx] = host;
|
||||
}
|
||||
});
|
||||
state.refs = duplicates;
|
||||
state.hasRefs = Object.keys(duplicates).length > 0;
|
||||
}
|
||||
|
||||
/* #region ANNOTATIONS MANAGEMENT */
|
||||
addAnnotation(ingressClass) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue());
|
||||
@@ -85,9 +73,59 @@ class KubernetesResourcePoolController {
|
||||
|
||||
removeAnnotation(ingressClass, index) {
|
||||
ingressClass.Annotations.splice(index, 1);
|
||||
this.onChangeIngressHostname();
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region INGRESS MANAGEMENT */
|
||||
onChangeIngressHostname() {
|
||||
const state = this.state.duplicates.ingressHosts;
|
||||
const otherIngresses = _.without(this.allIngresses, ...this.ingresses);
|
||||
const allHosts = _.flatMap(otherIngresses, 'Hosts');
|
||||
|
||||
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
|
||||
const hostsWithoutRemoved = _.filter(hosts, { NeedsDeletion: false });
|
||||
const hostnames = _.map(hostsWithoutRemoved, 'Host');
|
||||
const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnames);
|
||||
_.forEach(hostnames, (host, idx) => {
|
||||
if (host !== undefined && _.includes(allHosts, host)) {
|
||||
formDuplicates[idx] = host;
|
||||
}
|
||||
});
|
||||
const duplicatedHostnames = Object.values(formDuplicates);
|
||||
state.hasRefs = false;
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
_.forEach(ic.Hosts, (hostFV) => {
|
||||
if (_.includes(duplicatedHostnames, hostFV.Host) && hostFV.NeedsDeletion === false) {
|
||||
hostFV.Duplicate = true;
|
||||
state.hasRefs = true;
|
||||
} else {
|
||||
hostFV.Duplicate = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addHostname(ingressClass) {
|
||||
ingressClass.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
|
||||
}
|
||||
|
||||
removeHostname(ingressClass, index) {
|
||||
if (!ingressClass.Hosts[index].IsNew) {
|
||||
ingressClass.Hosts[index].NeedsDeletion = true;
|
||||
} else {
|
||||
ingressClass.Hosts.splice(index, 1);
|
||||
}
|
||||
this.onChangeIngressHostname();
|
||||
}
|
||||
|
||||
restoreHostname(host) {
|
||||
if (!host.IsNew) {
|
||||
host.NeedsDeletion = false;
|
||||
}
|
||||
}
|
||||
/* #endregion*/
|
||||
|
||||
selectTab(index) {
|
||||
this.LocalStorage.storeActiveTab('resourcePool', index);
|
||||
}
|
||||
@@ -141,16 +179,16 @@ class KubernetesResourcePoolController {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* #region UPDATE RESOURCE POOL */
|
||||
/* #region UPDATE NAMESPACE */
|
||||
async updateResourcePoolAsync() {
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
this.checkDefaults();
|
||||
await this.KubernetesResourcePoolService.patch(this.savedFormValues, this.formValues);
|
||||
this.Notifications.success('Resource pool successfully updated', this.pool.Namespace.Name);
|
||||
this.Notifications.success('Namespace successfully updated', this.pool.Namespace.Name);
|
||||
this.$state.reload();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create resource pool');
|
||||
this.Notifications.error('Failure', err, 'Unable to create namespace');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
@@ -166,7 +204,7 @@ class KubernetesResourcePoolController {
|
||||
if (warnings.quota || warnings.ingress) {
|
||||
const messages = {
|
||||
quota:
|
||||
'Reducing the quota assigned to an "in-use" resource pool may have unintended consequences, including preventing running applications from functioning correctly and potentially even blocking them from running at all.',
|
||||
'Reducing the quota assigned to an "in-use" namespace may have unintended consequences, including preventing running applications from functioning correctly and potentially even blocking them from running at all.',
|
||||
ingress: 'Deactivating ingresses may cause applications to be unaccessible. All ingress configurations from affected applications will be removed.',
|
||||
};
|
||||
const displayedMessage = `${warnings.quota ? messages.quota : ''}${warnings.quota && warnings.ingress ? '<br/><br/>' : ''}
|
||||
@@ -194,7 +232,7 @@ class KubernetesResourcePoolController {
|
||||
this.events = await this.KubernetesEventService.get(this.pool.Namespace.Name);
|
||||
this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve resource pool related events');
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespace related events');
|
||||
} finally {
|
||||
this.state.eventsLoading = false;
|
||||
}
|
||||
@@ -312,6 +350,11 @@ class KubernetesResourcePoolController {
|
||||
await this.getIngresses();
|
||||
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
|
||||
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses, this.ingresses);
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
if (ic.Hosts.length === 0) {
|
||||
ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
|
||||
}
|
||||
});
|
||||
}
|
||||
this.savedFormValues = angular.copy(this.formValues);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Resource pool list" state="kubernetes.resourcePools" view-ready="ctrl.state.viewReady">
|
||||
Resource pools
|
||||
<kubernetes-view-header title="Namespace list" state="kubernetes.resourcePools" view-ready="ctrl.state.viewReady">
|
||||
Namespaces
|
||||
</kubernetes-view-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
@@ -21,11 +21,11 @@ class KubernetesResourcePoolsController {
|
||||
for (const pool of selectedItems) {
|
||||
try {
|
||||
await this.KubernetesResourcePoolService.delete(pool);
|
||||
this.Notifications.success('Resource pool successfully removed', pool.Namespace.Name);
|
||||
this.Notifications.success('Namespace successfully removed', pool.Namespace.Name);
|
||||
const index = this.resourcePools.indexOf(pool);
|
||||
this.resourcePools.splice(index, 1);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to remove resource pool');
|
||||
this.Notifications.error('Failure', err, 'Unable to remove namespace');
|
||||
} finally {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
@@ -37,7 +37,7 @@ class KubernetesResourcePoolsController {
|
||||
|
||||
removeAction(selectedItems) {
|
||||
this.ModalService.confirmDeletion(
|
||||
'Do you want to remove the selected resource pool(s)? All the resources associated to the selected resource pool(s) will be removed too.',
|
||||
'Do you want to remove the selected namespace(s)? All the resources associated to the selected namespace(s) will be removed too.',
|
||||
(confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.removeActionAsync, selectedItems);
|
||||
@@ -50,7 +50,7 @@ class KubernetesResourcePoolsController {
|
||||
try {
|
||||
this.resourcePools = await this.KubernetesResourcePoolService.get();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retreive resource pools');
|
||||
this.Notifications.error('Failure', err, 'Unable to retreive namespaces');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Stacks logs" state="kubernetes.stacks.stack.logs" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a>
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a>
|
||||
> <a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.state.transition.namespace })">{{ ctrl.state.transition.namespace }}</a> >
|
||||
<a ui-sref="kubernetes.applications">Applications</a> > Stacks > {{ ctrl.state.transition.name }} > Logs
|
||||
</kubernetes-view-header>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<kubernetes-view-header title="Volume details" state="kubernetes.volumes.volume" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> >
|
||||
<a ui-sref="kubernetes.resourcePools">Namespaces</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.volume.ResourcePool.Namespace.Name })">{{ ctrl.volume.ResourcePool.Namespace.Name }}</a> >
|
||||
<a ui-sref="kubernetes.volumes">Volumes</a> > {{ ctrl.volume.PersistentVolumeClaim.Name }}
|
||||
</kubernetes-view-header>
|
||||
@@ -27,7 +27,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Resource pool</td>
|
||||
<td>Namespace</td>
|
||||
<td>
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.volume.ResourcePool.Namespace.Name })">{{ ctrl.volume.ResourcePool.Namespace.Name }}</a>
|
||||
<span style="margin-left: 5px;" class="label label-info image-tag" ng-if="ctrl.isSystemNamespace(item)">system</span>
|
||||
|
||||
@@ -98,7 +98,7 @@ class KubernetesVolumesController {
|
||||
});
|
||||
this.storages = buildStorages(storages, volumes);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retreive resource pools');
|
||||
this.Notifications.error('Failure', err, 'Unable to retreive namespaces');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import componentsModule from './components';
|
||||
|
||||
async function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
||||
authManager.checkAuthOnRefresh();
|
||||
// The unauthenticated event is broadcasted by the jwtInterceptor when
|
||||
@@ -15,7 +17,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat
|
||||
return await Authentication.init();
|
||||
}
|
||||
|
||||
angular.module('portainer.app', ['portainer.oauth']).config([
|
||||
angular.module('portainer.app', ['portainer.oauth', componentsModule]).config([
|
||||
'$stateRegistryProvider',
|
||||
function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
class GitFormComposeAuthFieldsetController {
|
||||
/* @ngInject */
|
||||
constructor() {
|
||||
this.onChangeField = this.onChangeField.bind(this);
|
||||
this.onChangeAuth = this.onChangeField('RepositoryAuthentication');
|
||||
this.onChangeUsername = this.onChangeField('RepositoryUsername');
|
||||
this.onChangePassword = this.onChangeField('RepositoryPassword');
|
||||
}
|
||||
|
||||
onChangeField(field) {
|
||||
return (value) => {
|
||||
this.onChange({
|
||||
...this.model,
|
||||
[field]: value,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default GitFormComposeAuthFieldsetController;
|
||||
@@ -0,0 +1,39 @@
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field ng-model="$ctrl.model.RepositoryAuthentication" label="Authentication" on-change="($ctrl.onChangeAuth)"></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="$ctrl.model.RepositoryAuthentication">
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
If your git account has 2FA enabled, you may receive an <code>authentication required</code> error when deploying your stack. In this case, you will need to provide a
|
||||
personal-access token instead of your password.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="repository_username" class="col-sm-1 control-label text-left">Username</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.model.RepositoryUsername"
|
||||
name="repository_username"
|
||||
placeholder="myGitUser"
|
||||
ng-change="$ctrl.onChangeUsername($ctrl.model.RepositoryUsername)"
|
||||
/>
|
||||
</div>
|
||||
<label for="repository_password" class="col-sm-1 control-label text-left">
|
||||
Password
|
||||
</label>
|
||||
<div class="col-sm-11 col-md-5">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.model.RepositoryPassword"
|
||||
name="repository_password"
|
||||
placeholder="myPassword"
|
||||
ng-change="$ctrl.onChangePassword($ctrl.model.RepositoryPassword)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
import controller from './git-form-auth-fieldset.controller.js';
|
||||
|
||||
export const gitFormAuthFieldset = {
|
||||
templateUrl: './git-form-auth-fieldset.html',
|
||||
controller,
|
||||
bindings: {
|
||||
model: '<',
|
||||
onChange: '<',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Indicate the path to the Compose file from the root of your repository.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.value" ng-change="$ctrl.onChange($ctrl.value)" id="stack_repository_path" placeholder="docker-compose.yml" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
export const gitFormComposePathField = {
|
||||
templateUrl: './git-form-compose-path-field.html',
|
||||
bindings: {
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Specify a reference of the repository using the following syntax: branches with <code>refs/heads/branch_name</code> or tags with <code>refs/tags/tag_name</code>. If not
|
||||
specified, will use the default <code>HEAD</code> reference normally the <code>master</code> branch.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stack_repository_reference_name" class="col-sm-2 control-label text-left">Repository reference</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" ng-model="$ctrl.value" id="stack_repository_reference_name" placeholder="refs/heads/master" ng-change="$ctrl.onChange($ctrl.value)" />
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user