Compare commits
1 Commits
fix/EE-156
...
feat/EE-11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e237fc22a |
@@ -57,10 +57,6 @@ You can join the Portainer Community by visiting community.portainer.io. This wi
|
||||
|
||||
- Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to <security@portainer.io>.
|
||||
|
||||
## WORK FOR US
|
||||
|
||||
If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to info@portainer.io with your details and we will be in touch.
|
||||
|
||||
## Privacy
|
||||
|
||||
**To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.**
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
)
|
||||
|
||||
@@ -33,7 +32,7 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
|
||||
}
|
||||
|
||||
for _, filename := range filesToBackup {
|
||||
err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath)
|
||||
err := copyPath(filepath.Join(filestorePath, filename), backupDirPath)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to create backup file")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package filesystem
|
||||
package backup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CopyPath copies file or directory defined by the path to the toDir path
|
||||
func CopyPath(path string, toDir string) error {
|
||||
func copyPath(path string, toDir string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
// skip copy if file does not exist
|
||||
@@ -21,30 +20,17 @@ func CopyPath(path string, toDir string) error {
|
||||
return copyFile(path, destination)
|
||||
}
|
||||
|
||||
return CopyDir(path, toDir, true)
|
||||
return copyDir(path, toDir)
|
||||
}
|
||||
|
||||
// CopyDir copies contents of fromDir to toDir.
|
||||
// When keepParent is true, contents will be copied with their immediate parent dir,
|
||||
// i.e. given /from/dirA and /to/dirB with keepParent == true, result will be /to/dirB/dirA/<children>
|
||||
func CopyDir(fromDir, toDir string, keepParent bool) error {
|
||||
func copyDir(fromDir, toDir string) error {
|
||||
cleanedSourcePath := filepath.Clean(fromDir)
|
||||
parentDirectory := filepath.Dir(cleanedSourcePath)
|
||||
err := filepath.Walk(cleanedSourcePath, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var destination string
|
||||
if keepParent {
|
||||
destination = filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory))
|
||||
} else {
|
||||
destination = filepath.Join(toDir, strings.TrimPrefix(path, cleanedSourcePath))
|
||||
}
|
||||
|
||||
if destination == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
destination := filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory))
|
||||
if info.IsDir() {
|
||||
return nil // skip directory creations
|
||||
}
|
||||
105
api/backup/copy_test.go
Normal file
105
api/backup/copy_test.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/docker/pkg/ioutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func listFiles(dir string) []string {
|
||||
items := make([]string, 0)
|
||||
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
items = append(items, path)
|
||||
return nil
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func contains(t *testing.T, list []string, path string) {
|
||||
assert.Contains(t, list, path)
|
||||
copyContent, _ := ioutil.ReadFile(path)
|
||||
assert.Equal(t, "content\n", string(copyContent))
|
||||
}
|
||||
|
||||
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
err := copyFile("does-not-exist", tmpdir)
|
||||
assert.NotNil(t, err)
|
||||
}
|
||||
|
||||
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
ioutil.WriteFile(path.Join(tmpdir, "origin"), content, 0600)
|
||||
|
||||
err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy"))
|
||||
assert.Nil(t, err)
|
||||
|
||||
copyContent, _ := ioutil.ReadFile(path.Join(tmpdir, "copy"))
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
|
||||
destination, _ := ioutils.TempDir("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := copyDir("./test_assets/copy_test", destination)
|
||||
assert.Nil(t, err)
|
||||
|
||||
createdFiles := listFiles(destination)
|
||||
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer"))
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner"))
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
err := copyPath("does-not-exists", tmpdir)
|
||||
assert.Nil(t, err)
|
||||
|
||||
assert.Empty(t, listFiles(tmpdir))
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldCopyFile(t *testing.T) {
|
||||
tmpdir, _ := ioutils.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
ioutil.WriteFile(path.Join(tmpdir, "file"), content, 0600)
|
||||
|
||||
os.MkdirAll(path.Join(tmpdir, "backup"), 0700)
|
||||
err := copyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup"))
|
||||
assert.Nil(t, err)
|
||||
|
||||
copyContent, err := ioutil.ReadFile(path.Join(tmpdir, "backup", "file"))
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_backupPath_shouldCopyDir(t *testing.T) {
|
||||
destination, _ := ioutils.TempDir("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := copyPath("./test_assets/copy_test", destination)
|
||||
assert.Nil(t, err)
|
||||
|
||||
createdFiles := listFiles(destination)
|
||||
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer"))
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner"))
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
)
|
||||
|
||||
@@ -60,7 +59,7 @@ func extractArchive(r io.Reader, destinationDirPath string) error {
|
||||
|
||||
func restoreFiles(srcDir string, destinationDir string) error {
|
||||
for _, filename := range filesToRestore {
|
||||
err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir)
|
||||
err := copyPath(filepath.Join(srcDir, filename), destinationDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ func (store *Store) Init() error {
|
||||
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
|
||||
TemplatesURL: portainer.DefaultTemplatesURL,
|
||||
UserSessionTimeout: portainer.DefaultUserSessionTimeout,
|
||||
KubeconfigExpiry: portainer.DefaultKubeconfigExpiry,
|
||||
}
|
||||
|
||||
err = store.SettingsService.UpdateSettings(defaultSettings)
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
endpointutils "github.com/portainer/portainer/api/internal/endpoint"
|
||||
snapshotutils "github.com/portainer/portainer/api/internal/snapshot"
|
||||
)
|
||||
|
||||
@@ -24,10 +24,6 @@ func (m *Migrator) migrateDBVersionToDB32() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.kubeconfigExpiryToDB32(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -215,12 +211,3 @@ func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Migrator) kubeconfigExpiryToDB32() error {
|
||||
settings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry
|
||||
return m.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func (m *Migrator) migrateDBVersionTo33() error {
|
||||
err := migrateStackEntryPoint(m.stackService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateStackEntryPoint(stackService portainer.StackService) error {
|
||||
stacks, err := stackService.Stacks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range stacks {
|
||||
stack := &stacks[i]
|
||||
if stack.GitConfig == nil {
|
||||
continue
|
||||
}
|
||||
stack.GitConfig.ConfigFilePath = stack.EntryPoint
|
||||
if err := stackService.UpdateStack(stack.ID, stack); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
"github.com/portainer/portainer/api/bolt/stack"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMigrateStackEntryPoint(t *testing.T) {
|
||||
dbConn, err := bolt.Open(path.Join(t.TempDir(), "portainer-ee-mig-33.db"), 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
assert.NoError(t, err, "failed to init testing DB connection")
|
||||
defer dbConn.Close()
|
||||
|
||||
stackService, err := stack.NewService(&internal.DbConnection{DB: dbConn})
|
||||
assert.NoError(t, err, "failed to init testing Stack service")
|
||||
|
||||
stacks := []*portainer.Stack{
|
||||
{
|
||||
ID: 1,
|
||||
EntryPoint: "dir/sub/compose.yml",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
EntryPoint: "dir/sub/compose.yml",
|
||||
GitConfig: &gittypes.RepoConfig{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range stacks {
|
||||
err := stackService.CreateStack(s)
|
||||
assert.NoError(t, err, "failed to create stack")
|
||||
}
|
||||
|
||||
err = migrateStackEntryPoint(stackService)
|
||||
assert.NoError(t, err, "failed to migrate entry point to Git ConfigFilePath")
|
||||
|
||||
s, err := stackService.Stack(1)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, s.GitConfig, "first stack should not have git config")
|
||||
|
||||
s, err = stackService.Stack(2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "dir/sub/compose.yml", s.GitConfig.ConfigFilePath, "second stack should have config file path migrated")
|
||||
}
|
||||
@@ -381,11 +381,5 @@ func (m *Migrator) Migrate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentDBVersion < 33 {
|
||||
if err := m.migrateDBVersionTo33(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return m.versionService.StoreDBVersion(portainer.DBVersion)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package stack
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
pkgerrors "github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -136,76 +133,3 @@ func (service *Service) DeleteStack(ID portainer.StackID) error {
|
||||
identifier := internal.Itob(int(ID))
|
||||
return internal.DeleteObject(service.connection, BucketName, identifier)
|
||||
}
|
||||
|
||||
// StackByWebhookID returns a pointer to a stack object by webhook ID.
|
||||
// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID.
|
||||
func (service *Service) StackByWebhookID(id string) (*portainer.Stack, error) {
|
||||
if id == "" {
|
||||
return nil, pkgerrors.New("webhook ID can't be empty string")
|
||||
}
|
||||
var stack portainer.Stack
|
||||
found := false
|
||||
|
||||
err := service.connection.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
cursor := bucket.Cursor()
|
||||
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var t struct {
|
||||
AutoUpdate *struct {
|
||||
WebhookID string `json:"Webhook"`
|
||||
} `json:"AutoUpdate"`
|
||||
}
|
||||
|
||||
err := internal.UnmarshalObject(v, &t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if t.AutoUpdate != nil && strings.EqualFold(t.AutoUpdate.WebhookID, id) {
|
||||
found = true
|
||||
err := internal.UnmarshalObject(v, &stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, errors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
return &stack, nil
|
||||
}
|
||||
|
||||
// RefreshableStacks returns stacks that are configured for a periodic update
|
||||
func (service *Service) RefreshableStacks() ([]portainer.Stack, error) {
|
||||
stacks := make([]portainer.Stack, 0)
|
||||
err := service.connection.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
cursor := bucket.Cursor()
|
||||
|
||||
var stack portainer.Stack
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
err := internal.UnmarshalObject(v, &stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" {
|
||||
stacks = append(stacks, stack)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return stacks, err
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
|
||||
"github.com/portainer/portainer/api/bolt/bolttest"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
)
|
||||
|
||||
func newGuidString(t *testing.T) string {
|
||||
uuid, err := uuid.NewV4()
|
||||
assert.NoError(t, err)
|
||||
|
||||
return uuid.String()
|
||||
}
|
||||
|
||||
type stackBuilder struct {
|
||||
t *testing.T
|
||||
count int
|
||||
store *bolt.Store
|
||||
}
|
||||
|
||||
func TestService_StackByWebhookID(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
|
||||
}
|
||||
store, teardown := bolttest.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
b := stackBuilder{t: t, store: store}
|
||||
b.createNewStack(newGuidString(t))
|
||||
for i := 0; i < 10; i++ {
|
||||
b.createNewStack("")
|
||||
}
|
||||
webhookID := newGuidString(t)
|
||||
stack := b.createNewStack(webhookID)
|
||||
|
||||
// can find a stack by webhook ID
|
||||
got, err := store.StackService.StackByWebhookID(webhookID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, stack, *got)
|
||||
|
||||
// returns nil and object not found error if there's no stack associated with the webhook
|
||||
got, err = store.StackService.StackByWebhookID(newGuidString(t))
|
||||
assert.Nil(t, got)
|
||||
assert.ErrorIs(t, err, bolterrors.ErrObjectNotFound)
|
||||
}
|
||||
|
||||
func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack {
|
||||
b.count++
|
||||
stack := portainer.Stack{
|
||||
ID: portainer.StackID(b.count),
|
||||
Name: "Name",
|
||||
Type: portainer.DockerComposeStack,
|
||||
EndpointID: 2,
|
||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
||||
Env: []portainer.Pair{{"Name1", "Value1"}},
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
ProjectPath: "/tmp/project",
|
||||
CreatedBy: "test",
|
||||
}
|
||||
|
||||
if webhookID == "" {
|
||||
if b.count%2 == 0 {
|
||||
stack.AutoUpdate = &portainer.StackAutoUpdate{
|
||||
Interval: "",
|
||||
Webhook: "",
|
||||
}
|
||||
} // else keep AutoUpdate nil
|
||||
} else {
|
||||
stack.AutoUpdate = &portainer.StackAutoUpdate{Webhook: webhookID}
|
||||
}
|
||||
|
||||
err := b.store.StackService.CreateStack(&stack)
|
||||
assert.NoError(b.t, err)
|
||||
|
||||
return stack
|
||||
}
|
||||
|
||||
func Test_RefreshableStacks(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping test in short mode. Normally takes ~1s to run.")
|
||||
}
|
||||
store, teardown := bolttest.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
staticStack := portainer.Stack{ID: 1}
|
||||
stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.StackAutoUpdate{Webhook: "webhook"}}
|
||||
refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.StackAutoUpdate{Interval: "1m"}}
|
||||
|
||||
for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} {
|
||||
err := store.Stack().CreateStack(stack)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
stacks, err := store.Stack().RefreshableStacks()
|
||||
assert.NoError(t, err)
|
||||
assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
wrapper "github.com/portainer/docker-compose-wrapper"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
@@ -30,8 +31,6 @@ import (
|
||||
"github.com/portainer/portainer/api/ldap"
|
||||
"github.com/portainer/portainer/api/libcompose"
|
||||
"github.com/portainer/portainer/api/oauth"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks"
|
||||
)
|
||||
|
||||
func initCLI() *portainer.CLIFlags {
|
||||
@@ -89,8 +88,12 @@ func shutdownDatastore(shutdownCtx context.Context, datastore portainer.DataStor
|
||||
func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper, err := exec.NewComposeStackManager(assetsPath, dataStorePath, proxyManager)
|
||||
if err != nil {
|
||||
log.Printf("[INFO] [main,compose] [message: falling-back to libcompose] [error: %s]", err)
|
||||
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
|
||||
if err == wrapper.ErrBinaryNotFound {
|
||||
log.Printf("[INFO] [message: docker-compose binary not found, falling back to libcompose]")
|
||||
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
|
||||
}
|
||||
|
||||
log.Fatalf("failed initalizing compose stack manager; err=%s", err)
|
||||
}
|
||||
|
||||
return composeWrapper
|
||||
@@ -114,7 +117,7 @@ func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error)
|
||||
settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout
|
||||
dataStore.Settings().UpdateSettings(settings)
|
||||
}
|
||||
jwtService, err := jwt.NewService(settings.UserSessionTimeout, dataStore)
|
||||
jwtService, err := jwt.NewService(settings.UserSessionTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -522,10 +525,6 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatalf("failed to fetch ssl settings from DB")
|
||||
}
|
||||
|
||||
scheduler := scheduler.NewScheduler(shutdownCtx)
|
||||
stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager)
|
||||
stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
|
||||
|
||||
return &http.Server{
|
||||
AuthorizationService: authorizationService,
|
||||
ReverseTunnelService: reverseTunnelService,
|
||||
@@ -551,10 +550,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
SSLService: sslService,
|
||||
DockerClientFactory: dockerClientFactory,
|
||||
KubernetesClientFactory: kubernetesClientFactory,
|
||||
Scheduler: scheduler,
|
||||
ShutdownCtx: shutdownCtx,
|
||||
ShutdownTrigger: shutdownTrigger,
|
||||
StackDeployer: stackDeployer,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
wrapper "github.com/portainer/docker-compose-wrapper"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
@@ -35,6 +35,12 @@ func NewComposeStackManager(binaryPath string, configPath string, proxyManager *
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (w *ComposeStackManager) NormalizeStackName(name string) string {
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
|
||||
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
|
||||
func (w *ComposeStackManager) ComposeSyntaxMaxVersion() string {
|
||||
return portainer.ComposeSyntaxMaxVersion
|
||||
@@ -44,7 +50,7 @@ func (w *ComposeStackManager) ComposeSyntaxMaxVersion() string {
|
||||
func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := w.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to featch endpoint proxy")
|
||||
return err
|
||||
}
|
||||
|
||||
if proxy != nil {
|
||||
@@ -53,12 +59,13 @@ func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.End
|
||||
|
||||
envFilePath, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create env file")
|
||||
return err
|
||||
}
|
||||
|
||||
filePaths := append([]string{stack.EntryPoint}, stack.AdditionalFiles...)
|
||||
_, err = w.wrapper.Up(filePaths, stack.ProjectPath, url, stack.Name, envFilePath, w.configPath)
|
||||
return errors.Wrap(err, "failed to deploy a stack")
|
||||
filePath := stackFilePath(stack)
|
||||
|
||||
_, err = w.wrapper.Up([]string{filePath}, url, stack.Name, envFilePath, w.configPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command
|
||||
@@ -71,16 +78,14 @@ func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.E
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
filePaths := append([]string{stack.EntryPoint}, stack.AdditionalFiles...)
|
||||
filePath := stackFilePath(stack)
|
||||
|
||||
_, err = w.wrapper.Down(filePaths, stack.ProjectPath, url, stack.Name)
|
||||
_, err = w.wrapper.Down([]string{filePath}, url, stack.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (w *ComposeStackManager) NormalizeStackName(name string) string {
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
func stackFilePath(stack *portainer.Stack) string {
|
||||
return path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
}
|
||||
|
||||
func (w *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
|
||||
@@ -113,5 +118,5 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
return "stack.env", nil
|
||||
return envFilePath, nil
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// +build integration
|
||||
|
||||
package exec
|
||||
|
||||
import (
|
||||
|
||||
@@ -10,6 +10,47 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_stackFilePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected string
|
||||
}{
|
||||
// {
|
||||
// name: "should return empty result if stack is missing",
|
||||
// stack: nil,
|
||||
// expected: "",
|
||||
// },
|
||||
// {
|
||||
// name: "should return empty result if stack don't have entrypoint",
|
||||
// stack: &portainer.Stack{},
|
||||
// expected: "",
|
||||
// },
|
||||
{
|
||||
name: "should allow file name and dir",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: "dir",
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: path.Join("dir", "file"),
|
||||
},
|
||||
{
|
||||
name: "should allow file name only",
|
||||
stack: &portainer.Stack{
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: "file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := stackFilePath(tt.stack)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createEnvFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -19,6 +60,11 @@ func Test_createEnvFile(t *testing.T) {
|
||||
expected string
|
||||
expectedFile bool
|
||||
}{
|
||||
// {
|
||||
// name: "should not add env file option if stack is missing",
|
||||
// stack: nil,
|
||||
// expected: "",
|
||||
// },
|
||||
{
|
||||
name: "should not add env file option if stack doesn't have env variables",
|
||||
stack: &portainer.Stack{
|
||||
@@ -52,7 +98,7 @@ func Test_createEnvFile(t *testing.T) {
|
||||
result, _ := createEnvFile(tt.stack)
|
||||
|
||||
if tt.expected != "" {
|
||||
assert.Equal(t, "stack.env", result)
|
||||
assert.Equal(t, path.Join(tt.stack.ProjectPath, "stack.env"), result)
|
||||
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -14,10 +17,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
)
|
||||
@@ -81,7 +80,7 @@ func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *po
|
||||
// Otherwise it will use kubectl to deploy the manifest.
|
||||
func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment {
|
||||
token, err := deployer.getToken(request, endpoint, true)
|
||||
token, err := deployer.getToken(request, endpoint, true);
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -180,7 +179,7 @@ func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *port
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := deployer.getToken(request, endpoint, false)
|
||||
token, err := deployer.getToken(request, endpoint, false);
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -230,7 +229,7 @@ func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *port
|
||||
}
|
||||
|
||||
// ConvertCompose leverages the kompose binary to deploy a compose compliant manifest.
|
||||
func (deployer *KubernetesDeployer) ConvertCompose(data []byte) ([]byte, error) {
|
||||
func (deployer *KubernetesDeployer) ConvertCompose(data string) ([]byte, error) {
|
||||
command := path.Join(deployer.binaryPath, "kompose")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kompose.exe")
|
||||
@@ -242,7 +241,7 @@ func (deployer *KubernetesDeployer) ConvertCompose(data []byte) ([]byte, error)
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
cmd.Stdin = strings.NewReader(data)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
|
||||
// SwarmStackManager represents a service for managing stacks.
|
||||
@@ -64,23 +63,22 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
|
||||
|
||||
// Deploy executes the docker stack deploy command.
|
||||
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error {
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
|
||||
|
||||
if prune {
|
||||
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth")
|
||||
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
|
||||
} else {
|
||||
args = append(args, "stack", "deploy", "--with-registry-auth")
|
||||
args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
|
||||
}
|
||||
|
||||
args = configureFilePaths(args, filePaths)
|
||||
args = append(args, stack.Name)
|
||||
|
||||
env := make([]string, 0)
|
||||
for _, envvar := range stack.Env {
|
||||
env = append(env, envvar.Name+"="+envvar.Value)
|
||||
}
|
||||
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
|
||||
|
||||
stackFolder := path.Dir(stackFilePath)
|
||||
return runCommandAndCaptureStdErr(command, args, env, stackFolder)
|
||||
}
|
||||
|
||||
// Remove executes the docker stack rm command.
|
||||
@@ -193,10 +191,3 @@ func (manager *SwarmStackManager) NormalizeStackName(name string) string {
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
|
||||
func configureFilePaths(args []string, filePaths []string) []string {
|
||||
for _, path := range filePaths {
|
||||
args = append(args, "--compose-file", path)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestConfigFilePaths(t *testing.T) {
|
||||
args := []string{"stack", "deploy", "--with-registry-auth"}
|
||||
filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"}
|
||||
expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"}
|
||||
output := configureFilePaths(args, filePaths)
|
||||
assert.ElementsMatch(t, expected, output, "wrong output file paths")
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package filesystem
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) {
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
err := copyFile("does-not-exist", tmpdir)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func Test_copyFile_shouldMakeAbackup(t *testing.T) {
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
ioutil.WriteFile(path.Join(tmpdir, "origin"), content, 0600)
|
||||
|
||||
err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
copyContent, _ := ioutil.ReadFile(path.Join(tmpdir, "copy"))
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) {
|
||||
destination, _ := ioutil.TempDir("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := CopyDir("./testdata/copy_test", destination, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner"))
|
||||
}
|
||||
|
||||
func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) {
|
||||
destination, _ := ioutil.TempDir("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := CopyDir("./testdata/copy_test", destination, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "dir", ".dotfile"))
|
||||
assert.FileExists(t, filepath.Join(destination, "dir", "inner"))
|
||||
}
|
||||
|
||||
func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) {
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
err := CopyPath("does-not-exists", tmpdir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoFileExists(t, tmpdir)
|
||||
}
|
||||
|
||||
func Test_CopyPath_shouldCopyFile(t *testing.T) {
|
||||
tmpdir, _ := ioutil.TempDir("", "backup")
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
content := []byte("content")
|
||||
ioutil.WriteFile(path.Join(tmpdir, "file"), content, 0600)
|
||||
|
||||
os.MkdirAll(path.Join(tmpdir, "backup"), 0700)
|
||||
err := CopyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup"))
|
||||
assert.NoError(t, err)
|
||||
|
||||
copyContent, err := ioutil.ReadFile(path.Join(tmpdir, "backup", "file"))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, content, copyContent)
|
||||
}
|
||||
|
||||
func Test_CopyPath_shouldCopyDir(t *testing.T) {
|
||||
destination, _ := ioutil.TempDir("", "destination")
|
||||
defer os.RemoveAll(destination)
|
||||
err := CopyPath("./testdata/copy_test", destination)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "outer"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile"))
|
||||
assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner"))
|
||||
}
|
||||
@@ -2,17 +2,15 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/portainer/portainer/api/archive"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,7 +37,7 @@ type azureDownloader struct {
|
||||
|
||||
func NewAzureDownloader(client *http.Client) *azureDownloader {
|
||||
return &azureDownloader{
|
||||
client: client,
|
||||
client: client,
|
||||
baseUrl: "https://dev.azure.com",
|
||||
}
|
||||
}
|
||||
@@ -102,57 +100,6 @@ func (a *azureDownloader) downloadZipFromAzureDevOps(ctx context.Context, option
|
||||
return zipFile.Name(), nil
|
||||
}
|
||||
|
||||
func (a *azureDownloader) latestCommitID(ctx context.Context, options fetchOptions) (string, error) {
|
||||
config, err := parseUrl(options.repositoryUrl)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to parse url")
|
||||
}
|
||||
|
||||
refsUrl, err := a.buildRefsUrl(config, options.referenceName)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to build azure refs url")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", refsUrl, 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")
|
||||
}
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed to make an HTTP request")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to get repository refs with a status \"%v\"", resp.Status)
|
||||
}
|
||||
|
||||
var refs struct {
|
||||
Value []struct {
|
||||
Name string `json:"name"`
|
||||
ObjectId string `json:"objectId"`
|
||||
}
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&refs); err != nil {
|
||||
return "", errors.Wrap(err, "could not parse Azure Refs response")
|
||||
}
|
||||
|
||||
for _, ref := range refs.Value {
|
||||
if strings.EqualFold(ref.Name, options.referenceName) {
|
||||
return ref.ObjectId, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.Errorf("could not find ref %q in the repository", options.referenceName)
|
||||
}
|
||||
|
||||
func parseUrl(rawUrl string) (*azureOptions, error) {
|
||||
if strings.HasPrefix(rawUrl, "https://") || strings.HasPrefix(rawUrl, "http://") {
|
||||
return parseHttpUrl(rawUrl)
|
||||
@@ -246,27 +193,6 @@ func (a *azureDownloader) buildDownloadUrl(config *azureOptions, referenceName s
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (a *azureDownloader) buildRefsUrl(config *azureOptions, referenceName string) (string, error) {
|
||||
rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/refs",
|
||||
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 refs url path %s", rawUrl)
|
||||
}
|
||||
|
||||
// filterContains=main&api-version=6.0
|
||||
q := u.Query()
|
||||
q.Set("filterContains", formatReferenceName(referenceName))
|
||||
q.Set("api-version", "6.0")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
const (
|
||||
branchPrefix = "refs/heads/"
|
||||
tagPrefix = "refs/tags/"
|
||||
|
||||
@@ -78,18 +78,6 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) {
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
|
||||
func TestService_LatestCommitID_Azure(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
pat := getRequiredValue(t, "AZURE_DEVOPS_PAT")
|
||||
service := NewService()
|
||||
|
||||
repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration"
|
||||
id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", "", pat)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
|
||||
}
|
||||
|
||||
func getRequiredValue(t *testing.T, name string) string {
|
||||
value, ok := os.LookupEnv(name)
|
||||
if !ok {
|
||||
|
||||
@@ -2,12 +2,11 @@ package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_buildDownloadUrl(t *testing.T) {
|
||||
@@ -28,23 +27,6 @@ func Test_buildDownloadUrl(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_buildRefsUrl(t *testing.T) {
|
||||
a := NewAzureDownloader(nil)
|
||||
u, err := a.buildRefsUrl(&azureOptions{
|
||||
organisation: "organisation",
|
||||
project: "project",
|
||||
repository: "repository",
|
||||
}, "refs/heads/main")
|
||||
|
||||
expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/refs?filterContains=main&api-version=6.0")
|
||||
actualUrl, _ := url.Parse(u)
|
||||
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
|
||||
@@ -266,110 +248,3 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_azureDownloader_latestCommitID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
response := `{
|
||||
"value": [
|
||||
{
|
||||
"name": "refs/heads/feature/calcApp",
|
||||
"objectId": "ffe9cba521f00d7f60e322845072238635edb451",
|
||||
"creator": {
|
||||
"displayName": "Normal Paulk",
|
||||
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"_links": {
|
||||
"avatar": {
|
||||
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
|
||||
}
|
||||
},
|
||||
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"uniqueName": "dev@mailserver.com",
|
||||
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
|
||||
},
|
||||
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2FcalcApp"
|
||||
},
|
||||
{
|
||||
"name": "refs/heads/feature/replacer",
|
||||
"objectId": "917131a709996c5cfe188c3b57e9a6ad90e8b85c",
|
||||
"creator": {
|
||||
"displayName": "Normal Paulk",
|
||||
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"_links": {
|
||||
"avatar": {
|
||||
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
|
||||
}
|
||||
},
|
||||
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"uniqueName": "dev@mailserver.com",
|
||||
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
|
||||
},
|
||||
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2Freplacer"
|
||||
},
|
||||
{
|
||||
"name": "refs/heads/master",
|
||||
"objectId": "ffe9cba521f00d7f60e322845072238635edb451",
|
||||
"creator": {
|
||||
"displayName": "Normal Paulk",
|
||||
"url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"_links": {
|
||||
"avatar": {
|
||||
"href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
|
||||
}
|
||||
},
|
||||
"id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"uniqueName": "dev@mailserver.com",
|
||||
"imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9",
|
||||
"descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5"
|
||||
},
|
||||
"url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Fmaster"
|
||||
}
|
||||
],
|
||||
"count": 3
|
||||
}`
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(response))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
a := &azureDownloader{
|
||||
client: server.Client(),
|
||||
baseUrl: server.URL,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args fetchOptions
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "should be able to parse response",
|
||||
args: fetchOptions{
|
||||
referenceName: "refs/heads/master",
|
||||
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"},
|
||||
want: "ffe9cba521f00d7f60e322845072238635edb451",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "should be able to parse response",
|
||||
args: fetchOptions{
|
||||
referenceName: "refs/heads/unknown",
|
||||
repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"},
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id, err := a.latestCommitID(context.Background(), tt.args)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("azureDownloader.latestCommitID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tt.want, id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,26 +6,16 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/config"
|
||||
"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"
|
||||
"github.com/go-git/go-git/v5/storage/memory"
|
||||
)
|
||||
|
||||
type fetchOptions struct {
|
||||
repositoryUrl string
|
||||
username string
|
||||
password string
|
||||
referenceName string
|
||||
}
|
||||
|
||||
type cloneOptions struct {
|
||||
repositoryUrl string
|
||||
username string
|
||||
@@ -36,7 +26,6 @@ type cloneOptions struct {
|
||||
|
||||
type downloader interface {
|
||||
download(ctx context.Context, dst string, opt cloneOptions) error
|
||||
latestCommitID(ctx context.Context, opt fetchOptions) (string, error)
|
||||
}
|
||||
|
||||
type gitClient struct {
|
||||
@@ -47,7 +36,13 @@ func (c gitClient) download(ctx context.Context, dst string, opt cloneOptions) e
|
||||
gitOptions := git.CloneOptions{
|
||||
URL: opt.repositoryUrl,
|
||||
Depth: opt.depth,
|
||||
Auth: getAuth(opt.username, opt.password),
|
||||
}
|
||||
|
||||
if opt.password != "" || opt.username != "" {
|
||||
gitOptions.Auth = &githttp.BasicAuth{
|
||||
Username: opt.username,
|
||||
Password: opt.password,
|
||||
}
|
||||
}
|
||||
|
||||
if opt.referenceName != "" {
|
||||
@@ -67,44 +62,6 @@ func (c gitClient) download(ctx context.Context, dst string, opt cloneOptions) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c gitClient) latestCommitID(ctx context.Context, opt fetchOptions) (string, error) {
|
||||
remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{opt.repositoryUrl},
|
||||
})
|
||||
|
||||
listOptions := &git.ListOptions{
|
||||
Auth: getAuth(opt.username, opt.password),
|
||||
}
|
||||
|
||||
refs, err := remote.List(listOptions)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to list repository refs")
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
if strings.EqualFold(ref.Name().String(), opt.referenceName) {
|
||||
return ref.Hash().String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName)
|
||||
}
|
||||
|
||||
func getAuth(username, password string) *githttp.BasicAuth {
|
||||
if password != "" {
|
||||
if username == "" {
|
||||
username = "token"
|
||||
}
|
||||
|
||||
return &githttp.BasicAuth{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Service represents a service for managing Git.
|
||||
type Service struct {
|
||||
httpsCli *http.Client
|
||||
@@ -117,7 +74,6 @@ func NewService() *Service {
|
||||
httpsCli := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
Timeout: 300 * time.Second,
|
||||
}
|
||||
@@ -152,19 +108,3 @@ func (service *Service) cloneRepository(destination string, options cloneOptions
|
||||
|
||||
return service.git.download(context.TODO(), destination, options)
|
||||
}
|
||||
|
||||
// LatestCommitID returns SHA1 of the latest commit of the specified reference
|
||||
func (service *Service) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) {
|
||||
options := fetchOptions{
|
||||
repositoryUrl: repositoryURL,
|
||||
username: username,
|
||||
password: password,
|
||||
referenceName: referenceName,
|
||||
}
|
||||
|
||||
if isAzureUrl(options.repositoryUrl) {
|
||||
return service.azure.latestCommitID(context.TODO(), options)
|
||||
}
|
||||
|
||||
return service.git.latestCommitID(context.TODO(), options)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
pat := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := NewService()
|
||||
|
||||
@@ -21,20 +21,7 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) {
|
||||
defer os.RemoveAll(dst)
|
||||
|
||||
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
|
||||
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, accessToken)
|
||||
err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, pat)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, filepath.Join(dst, "README.md"))
|
||||
}
|
||||
|
||||
func TestService_LatestCommitID_GitHub(t *testing.T) {
|
||||
ensureIntegrationTest(t)
|
||||
|
||||
accessToken := getRequiredValue(t, "GITHUB_PAT")
|
||||
username := getRequiredValue(t, "GITHUB_USERNAME")
|
||||
service := NewService()
|
||||
|
||||
repositoryUrl := "https://github.com/portainer/private-test-repository.git"
|
||||
id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", username, accessToken)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty")
|
||||
}
|
||||
|
||||
@@ -105,19 +105,7 @@ func Test_cloneRepository(t *testing.T) {
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 4, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
func Test_latestCommitID(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"
|
||||
|
||||
id, err := service.LatestCommitID(repositoryURL, referenceName, "", "")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id)
|
||||
assert.Equal(t, 3, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth")
|
||||
}
|
||||
|
||||
func getCommitHistoryLength(t *testing.T, err error, dir string) int {
|
||||
@@ -149,10 +137,6 @@ func (t *testDownloader) download(_ context.Context, _ string, _ cloneOptions) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *testDownloader) latestCommitID(_ context.Context, _ fetchOptions) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func Test_cloneRepository_azure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
BIN
api/git/testdata/azure-repo copy.zip
vendored
BIN
api/git/testdata/azure-repo copy.zip
vendored
Binary file not shown.
BIN
api/git/testdata/test-clone-git-repo.tar.gz
vendored
BIN
api/git/testdata/test-clone-git-repo.tar.gz
vendored
Binary file not shown.
@@ -1,20 +1,10 @@
|
||||
package gittypes
|
||||
|
||||
// RepoConfig represents a configuration for a repo
|
||||
type RepoConfig struct {
|
||||
// The repo url
|
||||
URL string `example:"https://github.com/portainer/portainer.git"`
|
||||
URL string `example:"https://github.com/portainer/portainer-ee.git"`
|
||||
// The reference name
|
||||
ReferenceName string `example:"refs/heads/branch_name"`
|
||||
// Path to where the config file is in this url/refName
|
||||
ConfigFilePath string `example:"docker-compose.yml"`
|
||||
// Git credentials
|
||||
Authentication *GitAuthentication
|
||||
// Repository hash
|
||||
ConfigHash string `example:"bc4c183d756879ea4d173315338110b31004b8e0"`
|
||||
}
|
||||
|
||||
type GitAuthentication struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
@@ -27,17 +27,16 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210810234209-d01bc85eb481
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92
|
||||
github.com/portainer/libcompose v0.5.3
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
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/yaml.v3 v3.0.0-20210107192922-496545a6307b
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
|
||||
k8s.io/api v0.17.2
|
||||
k8s.io/apimachinery v0.17.2
|
||||
k8s.io/client-go v0.17.2
|
||||
|
||||
@@ -241,8 +241,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210810234209-d01bc85eb481 h1:5c8N9Gh21Ja/9EIpfyHFmQvTCKgOjnRhosmo0ZshkFk=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210810234209-d01bc85eb481/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92 h1:Hh7SHCf3SJblVywU0TTn5lpTKsH5W23LAKH5sqWggig=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92/go.mod h1:PF2O2O4UNYWdtPcp6n/mIKpKk+f1jhFTezS8txbf+XM=
|
||||
github.com/portainer/libcompose v0.5.3 h1:tE4WcPuGvo+NKeDkDWpwNavNLZ5GHIJ4RvuZXsI9uI8=
|
||||
github.com/portainer/libcompose v0.5.3/go.mod h1:7SKd/ho69rRKHDFSDUwkbMcol2TMKU5OslDsajr8Ro8=
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
|
||||
@@ -263,8 +263,6 @@ 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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
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=
|
||||
@@ -387,9 +385,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
|
||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -105,10 +105,9 @@ type customTemplateFromFileContentPayload struct {
|
||||
Note string `example:"This is my <b>custom</b> template"`
|
||||
// Platform associated to the template.
|
||||
// Valid values are: 1 - 'linux', 2 - 'windows'
|
||||
// Required for Docker stacks
|
||||
Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"`
|
||||
// Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes)
|
||||
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
|
||||
Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"`
|
||||
// Type of created stack (1 - swarm, 2 - compose)
|
||||
Type portainer.StackType `example:"1" enums:"1,2" validate:"required"`
|
||||
// Content of stack file
|
||||
FileContent string `validate:"required"`
|
||||
}
|
||||
@@ -123,10 +122,10 @@ func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) e
|
||||
if govalidator.IsNull(payload.FileContent) {
|
||||
return errors.New("Invalid file content")
|
||||
}
|
||||
if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
|
||||
if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
|
||||
return errors.New("Invalid custom template platform")
|
||||
}
|
||||
if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack {
|
||||
if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack {
|
||||
return errors.New("Invalid custom template type")
|
||||
}
|
||||
return nil
|
||||
@@ -172,8 +171,7 @@ type customTemplateFromGitRepositoryPayload struct {
|
||||
Note string `example:"This is my <b>custom</b> template"`
|
||||
// Platform associated to the template.
|
||||
// Valid values are: 1 - 'linux', 2 - 'windows'
|
||||
// Required for Docker stacks
|
||||
Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"`
|
||||
Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"`
|
||||
// Type of created stack (1 - swarm, 2 - compose)
|
||||
Type portainer.StackType `example:"1" enums:"1,2" validate:"required"`
|
||||
|
||||
@@ -207,11 +205,6 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
|
||||
if govalidator.IsNull(payload.ComposeFilePathInRepository) {
|
||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
if payload.Type == portainer.KubernetesStack {
|
||||
return errors.New("Creating a Kubernetes custom template from git is not supported")
|
||||
}
|
||||
|
||||
if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
|
||||
return errors.New("Invalid custom template platform")
|
||||
}
|
||||
@@ -285,21 +278,20 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
|
||||
note, _ := request.RetrieveMultiPartFormValue(r, "Note", true)
|
||||
payload.Note = note
|
||||
|
||||
platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true)
|
||||
templatePlatform := portainer.CustomTemplatePlatform(platform)
|
||||
if templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows {
|
||||
return errors.New("Invalid custom template platform")
|
||||
}
|
||||
payload.Platform = templatePlatform
|
||||
|
||||
typeNumeral, _ := request.RetrieveNumericMultiPartFormValue(r, "Type", true)
|
||||
templateType := portainer.StackType(typeNumeral)
|
||||
if templateType != portainer.KubernetesStack && templateType != portainer.DockerSwarmStack && templateType != portainer.DockerComposeStack {
|
||||
if templateType != portainer.DockerComposeStack && templateType != portainer.DockerSwarmStack {
|
||||
return errors.New("Invalid custom template type")
|
||||
}
|
||||
payload.Type = templateType
|
||||
|
||||
platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true)
|
||||
templatePlatform := portainer.CustomTemplatePlatform(platform)
|
||||
if templateType != portainer.KubernetesStack && templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows {
|
||||
return errors.New("Invalid custom template platform")
|
||||
}
|
||||
|
||||
payload.Platform = templatePlatform
|
||||
|
||||
composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "File")
|
||||
if err != nil {
|
||||
return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly")
|
||||
|
||||
@@ -2,9 +2,7 @@ package customtemplates
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -19,16 +17,10 @@ import (
|
||||
// @tags custom_templates
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param type query []int true "Template types" Enums(1,2,3)
|
||||
// @success 200 {array} portainer.CustomTemplate "Success"
|
||||
// @failure 500 "Server error"
|
||||
// @router /custom_templates [get]
|
||||
func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
templateTypes, err := parseTemplateTypes(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template type", err}
|
||||
}
|
||||
|
||||
customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err}
|
||||
@@ -60,52 +52,5 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
|
||||
customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs)
|
||||
}
|
||||
|
||||
customTemplates = filterByType(customTemplates, templateTypes)
|
||||
|
||||
return response.JSON(w, customTemplates)
|
||||
}
|
||||
|
||||
func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "failed to parse request params")
|
||||
}
|
||||
|
||||
types, exist := r.Form["type"]
|
||||
if !exist {
|
||||
return []portainer.StackType{}, nil
|
||||
}
|
||||
|
||||
res := []portainer.StackType{}
|
||||
for _, templateTypeStr := range types {
|
||||
templateType, err := strconv.Atoi(templateTypeStr)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "failed parsing template type")
|
||||
}
|
||||
|
||||
res = append(res, portainer.StackType(templateType))
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func filterByType(customTemplates []portainer.CustomTemplate, templateTypes []portainer.StackType) []portainer.CustomTemplate {
|
||||
if len(templateTypes) == 0 {
|
||||
return customTemplates
|
||||
}
|
||||
|
||||
typeSet := map[portainer.StackType]bool{}
|
||||
for _, templateType := range templateTypes {
|
||||
typeSet[templateType] = true
|
||||
}
|
||||
|
||||
filtered := []portainer.CustomTemplate{}
|
||||
|
||||
for _, template := range customTemplates {
|
||||
if typeSet[template.Type] {
|
||||
filtered = append(filtered, template)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
@@ -27,10 +27,9 @@ type customTemplateUpdatePayload struct {
|
||||
Note string `example:"This is my <b>custom</b> template"`
|
||||
// Platform associated to the template.
|
||||
// Valid values are: 1 - 'linux', 2 - 'windows'
|
||||
// Required for Docker stacks
|
||||
Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"`
|
||||
// Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes)
|
||||
Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
|
||||
Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"`
|
||||
// Type of created stack (1 - swarm, 2 - compose)
|
||||
Type portainer.StackType `example:"1" enums:"1,2" validate:"required"`
|
||||
// Content of stack file
|
||||
FileContent string `validate:"required"`
|
||||
}
|
||||
@@ -42,10 +41,10 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.FileContent) {
|
||||
return errors.New("Invalid file content")
|
||||
}
|
||||
if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
|
||||
if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
|
||||
return errors.New("Invalid custom template platform")
|
||||
}
|
||||
if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack {
|
||||
if payload.Type != portainer.DockerComposeStack && payload.Type != portainer.DockerSwarmStack {
|
||||
return errors.New("Invalid custom template type")
|
||||
}
|
||||
if govalidator.IsNull(payload.Description) {
|
||||
|
||||
@@ -29,10 +29,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI)))
|
||||
h.PathPrefix("/{id}/kubernetes").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI)))
|
||||
h.PathPrefix("/{id}/agent/docker").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI)))
|
||||
h.PathPrefix("/{id}/agent/kubernetes").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI)))
|
||||
h.PathPrefix("/{id}/storidge").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI)))
|
||||
return h
|
||||
|
||||
@@ -3,7 +3,6 @@ package endpointproxy
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -66,12 +65,6 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
id := strconv.Itoa(endpointID)
|
||||
|
||||
prefix := "/" + id + "/agent/docker";
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
prefix = "/" + id + "/docker";
|
||||
}
|
||||
|
||||
http.StripPrefix(prefix, proxy).ServeHTTP(w, r)
|
||||
http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -65,18 +65,17 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h
|
||||
}
|
||||
}
|
||||
|
||||
// For KubernetesLocalEnvironment
|
||||
requestPrefix := fmt.Sprintf("/%d/kubernetes", endpointID)
|
||||
|
||||
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
requestPrefix = fmt.Sprintf("/%d", endpointID)
|
||||
|
||||
agentPrefix := fmt.Sprintf("/%d/agent/kubernetes", endpointID)
|
||||
if strings.HasPrefix(r.URL.Path, agentPrefix) {
|
||||
requestPrefix = agentPrefix
|
||||
if isKubernetesRequest(strings.TrimPrefix(r.URL.String(), requestPrefix)) {
|
||||
requestPrefix = fmt.Sprintf("/%d", endpointID)
|
||||
}
|
||||
}
|
||||
|
||||
http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func isKubernetesRequest(requestURL string) bool {
|
||||
return strings.HasPrefix(requestURL, "/api") || strings.HasPrefix(requestURL, "/healthz")
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
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"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// @id EndpointAssociationDelete
|
||||
@@ -48,11 +45,6 @@ func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
endpoint.EdgeKey, err = handler.updateEdgeKey(endpoint.EdgeKey)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Invalid EdgeKey", err}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting endpoint in database", err}
|
||||
@@ -62,27 +54,3 @@ func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http
|
||||
|
||||
return response.JSON(w, endpoint)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateEdgeKey(edgeKey string) (string, error) {
|
||||
oldEdgeKeyByte, err := base64.RawStdEncoding.DecodeString(edgeKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
oldEdgeKeyStr := string(oldEdgeKeyByte)
|
||||
|
||||
httpPort := getPort(handler.BindAddress)
|
||||
httpsPort := getPort(handler.BindAddressHTTPS)
|
||||
|
||||
// replace "http://" with "https://" and replace ":9000" with ":9443", in the case of default values
|
||||
// oldEdgeKeyStr example: http://10.116.1.178:9000|10.116.1.178:8000|46:99:4a:8d:a6:de:6a:bd:d8:e2:1c:99:81:60:54:55|52
|
||||
r := regexp.MustCompile(fmt.Sprintf("^(http://)([^|]+)(:%s)(|.*)", httpPort))
|
||||
newEdgeKeyStr := r.ReplaceAllString(oldEdgeKeyStr, fmt.Sprintf("https://$2:%s$4", httpsPort))
|
||||
|
||||
return base64.RawStdEncoding.EncodeToString([]byte(newEdgeKeyStr)), nil
|
||||
}
|
||||
|
||||
func getPort(url string) string {
|
||||
items := strings.Split(url, ":")
|
||||
return items[len(items) - 1]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
endpointutils "github.com/portainer/portainer/api/internal/endpoint"
|
||||
)
|
||||
|
||||
// GET request on /endpoints/{id}/registries?namespace
|
||||
|
||||
@@ -32,8 +32,6 @@ type Handler struct {
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
AuthorizationService *authorization.Service
|
||||
BindAddress string
|
||||
BindAddressHTTPS string
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage endpoint operations.
|
||||
|
||||
@@ -48,8 +48,8 @@ type Handler struct {
|
||||
EndpointGroupHandler *endpointgroups.Handler
|
||||
EndpointHandler *endpoints.Handler
|
||||
EndpointProxyHandler *endpointproxy.Handler
|
||||
KubernetesHandler *kubernetes.Handler
|
||||
FileHandler *file.Handler
|
||||
KubernetesHandler *kubernetes.Handler
|
||||
MOTDHandler *motd.Handler
|
||||
RegistryHandler *registries.Handler
|
||||
ResourceControlHandler *resourcecontrols.Handler
|
||||
@@ -69,7 +69,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.6.3
|
||||
// @version 2.1.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
@@ -176,8 +176,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
|
||||
case strings.Contains(r.URL.Path, "/azure/"):
|
||||
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
|
||||
case strings.Contains(r.URL.Path, "/agent/"):
|
||||
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
|
||||
case strings.Contains(r.URL.Path, "/edge/"):
|
||||
http.StripPrefix("/api/endpoints", h.EndpointEdgeHandler).ServeHTTP(w, r)
|
||||
default:
|
||||
|
||||
@@ -1,71 +1,28 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler which will natively deal with to external endpoints.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
dataStore portainer.DataStore
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
authorizationService *authorization.Service
|
||||
JwtService portainer.JWTService
|
||||
DataStore portainer.DataStore
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to process pre-proxied requests to external APIs.
|
||||
func NewHandler(bouncer *security.RequestBouncer, authorizationService *authorization.Service, dataStore portainer.DataStore, kubernetesClientFactory *cli.ClientFactory) *Handler {
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
dataStore: dataStore,
|
||||
kubernetesClientFactory: kubernetesClientFactory,
|
||||
authorizationService: authorizationService,
|
||||
Router: mux.NewRouter(),
|
||||
}
|
||||
|
||||
kubeRouter := h.PathPrefix("/kubernetes/{id}").Subrouter()
|
||||
|
||||
kubeRouter.Use(bouncer.AuthenticatedAccess)
|
||||
kubeRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
|
||||
kubeRouter.Use(kubeOnlyMiddleware)
|
||||
|
||||
kubeRouter.PathPrefix("/config").Handler(
|
||||
h.PathPrefix("/kubernetes/{id}/config").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesConfig))).Methods(http.MethodGet)
|
||||
kubeRouter.PathPrefix("/nodes_limits").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesNodesLimits))).Methods(http.MethodGet)
|
||||
|
||||
// namespaces
|
||||
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
|
||||
// to keep it simple, we've decided to leave it like this.
|
||||
namespaceRouter := kubeRouter.PathPrefix("/namespaces/{namespace}").Subrouter()
|
||||
namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func kubeOnlyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
|
||||
endpoint, err := middlewares.FetchEndpoint(request)
|
||||
if err != nil {
|
||||
httperror.WriteError(rw, http.StatusInternalServerError, "Unable to find an endpoint on request context", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||
errMessage := "Endpoint is not a kubernetes endpoint"
|
||||
httperror.WriteError(rw, http.StatusBadRequest, errMessage, errors.New(errMessage))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, request)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ package kubernetes
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
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"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
kcli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
@@ -31,29 +34,37 @@ import (
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/{id}/config [get]
|
||||
func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
if r.TLS == nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Kubernetes config generation only supported on portainer instances running with TLS",
|
||||
Err: errors.New("missing request TLS config"),
|
||||
}
|
||||
}
|
||||
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
bearerToken, err := extractBearerToken(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err}
|
||||
}
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err}
|
||||
}
|
||||
|
||||
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
|
||||
}
|
||||
@@ -65,22 +76,34 @@ func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Reque
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to generate Kubeconfig", err}
|
||||
}
|
||||
|
||||
filenameBase := fmt.Sprintf("%s-%s", tokenData.Username, endpoint.Name)
|
||||
contentAcceptHeader := r.Header.Get("Accept")
|
||||
if contentAcceptHeader == "text/yaml" {
|
||||
yaml, err := kcli.GenerateYAML(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Failed to generate Kubeconfig", err}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.yaml", filenameBase))
|
||||
w.Header().Set("Content-Disposition", `attachment; filename=config.yaml`)
|
||||
return YAML(w, yaml)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase))
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="config.json"`)
|
||||
return response.JSON(w, config)
|
||||
}
|
||||
|
||||
// extractBearerToken extracts user's portainer bearer token from request auth header
|
||||
func extractBearerToken(r *http.Request) (string, error) {
|
||||
token := ""
|
||||
tokens := r.Header["Authorization"]
|
||||
if len(tokens) >= 1 {
|
||||
token = tokens[0]
|
||||
token = strings.TrimPrefix(token, "Bearer ")
|
||||
}
|
||||
if token == "" {
|
||||
return "", httperrors.ErrUnauthorized
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// getProxyUrl generates portainer proxy url which acts as proxy to k8s api server
|
||||
func getProxyUrl(r *http.Request, endpointID int) string {
|
||||
return fmt.Sprintf("https://%s/api/endpoints/%d/kubernetes", r.Host, endpointID)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
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"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// @id getKubernetesNodesLimits
|
||||
// @summary Get CPU and memory limits of all nodes within k8s cluster
|
||||
// @description Get CPU and memory limits of all nodes within k8s cluster
|
||||
// @description **Access policy**: authorized
|
||||
// @tags kubernetes
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Endpoint identifier"
|
||||
// @success 200 {object} K8sNodesLimits "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 401 "Unauthorized"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Endpoint not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/{id}/nodes_limits [get]
|
||||
func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
|
||||
}
|
||||
|
||||
nodesLimits, err := cli.GetNodesLimits()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve nodes limits", err}
|
||||
}
|
||||
|
||||
return response.JSON(w, nodesLimits)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
)
|
||||
|
||||
type namespacesToggleSystemPayload struct {
|
||||
// Toggle the system state of this namespace to true or false
|
||||
System bool `example:"true"`
|
||||
}
|
||||
|
||||
func (payload *namespacesToggleSystemPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id KubernetesNamespacesToggleSystem
|
||||
// @summary Toggle the system state for a namespace
|
||||
// @description Toggle the system state for a namespace
|
||||
// @description **Access policy**: administrator or endpoint admin
|
||||
// @security jwt
|
||||
// @tags kubernetes
|
||||
// @accept json
|
||||
// @param id path int true "Endpoint identifier"
|
||||
// @param namespace path string true "Namespace name"
|
||||
// @param body body namespacesToggleSystemPayload true "Update details"
|
||||
// @success 200 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 404 "Endpoint not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/system [put]
|
||||
func (handler *Handler) namespacesToggleSystem(rw http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint on request context", err}
|
||||
}
|
||||
|
||||
namespaceName, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid namespace identifier route variable", err}
|
||||
}
|
||||
|
||||
var payload namespacesToggleSystemPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
kubeClient, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create kubernetes client", err}
|
||||
}
|
||||
|
||||
err = kubeClient.ToggleSystemState(namespaceName, payload.System)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to toggle system status", err}
|
||||
}
|
||||
|
||||
return response.Empty(rw)
|
||||
|
||||
}
|
||||
@@ -32,8 +32,6 @@ type settingsUpdatePayload struct {
|
||||
EnableEdgeComputeFeatures *bool `example:"true"`
|
||||
// The duration of a user session
|
||||
UserSessionTimeout *string `example:"5m"`
|
||||
// The expiry of a Kubeconfig
|
||||
KubeconfigExpiry *string `example:"24h" default:"0"`
|
||||
// Whether telemetry is enabled
|
||||
EnableTelemetry *bool `example:"false"`
|
||||
}
|
||||
@@ -54,12 +52,6 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
return errors.New("Invalid user session timeout")
|
||||
}
|
||||
}
|
||||
if payload.KubeconfigExpiry != nil {
|
||||
_, err := time.ParseDuration(*payload.KubeconfigExpiry)
|
||||
if err != nil {
|
||||
return errors.New("Invalid Kubeconfig Expiry")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -143,10 +135,6 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval
|
||||
}
|
||||
|
||||
if payload.KubeconfigExpiry != nil {
|
||||
settings.KubeconfigExpiry = *payload.KubeconfigExpiry
|
||||
}
|
||||
|
||||
if payload.UserSessionTimeout != nil {
|
||||
settings.UserSessionTimeout = *payload.UserSessionTimeout
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks"
|
||||
)
|
||||
|
||||
func startAutoupdate(stackID portainer.StackID, interval string, scheduler *scheduler.Scheduler, stackDeployer stacks.StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) (jobID string, e *httperror.HandlerError) {
|
||||
d, err := time.ParseDuration(interval)
|
||||
if err != nil {
|
||||
return "", &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Unable to parse stack's auto update interval", Err: err}
|
||||
}
|
||||
|
||||
jobID = scheduler.StartJobEvery(d, func() {
|
||||
if err := stacks.RedeployWhenChanged(stackID, stackDeployer, datastore, gitService); err != nil {
|
||||
log.Printf("[ERROR] [http,stacks] [message: failed redeploying] [err: %s]\n", err)
|
||||
}
|
||||
})
|
||||
|
||||
return jobID, nil
|
||||
}
|
||||
|
||||
func stopAutoupdate(stackID portainer.StackID, jobID string, scheduler scheduler.Scheduler) {
|
||||
if jobID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if err := scheduler.StopJob(jobID); err != nil {
|
||||
log.Printf("[WARN] could not stop the job for the stack %v", stackID)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
@@ -8,12 +9,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -101,6 +100,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
|
||||
type composeStackFromGitRepositoryPayload struct {
|
||||
// Name of the stack
|
||||
Name string `example:"myStack" validate:"required"`
|
||||
|
||||
// URL of a Git repository hosting the Stack file
|
||||
RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"`
|
||||
// Reference name of a Git repository hosting the Stack file
|
||||
@@ -112,11 +112,8 @@ type composeStackFromGitRepositoryPayload struct {
|
||||
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
RepositoryPassword string `example:"myGitPassword"`
|
||||
// Path to the Stack file inside the Git repository
|
||||
ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
// Applicable when deploying with multiple stack files
|
||||
AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"`
|
||||
// Optional auto update configuration
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
|
||||
// A list of environment variables used during stack deployment
|
||||
Env []portainer.Pair
|
||||
}
|
||||
@@ -125,18 +122,14 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
|
||||
if govalidator.IsNull(payload.Name) {
|
||||
return errors.New("Invalid stack name")
|
||||
}
|
||||
|
||||
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
payload.RepositoryReferenceName = defaultGitReferenceName
|
||||
}
|
||||
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
|
||||
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
}
|
||||
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
|
||||
@@ -148,72 +141,42 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
}
|
||||
|
||||
payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name)
|
||||
if payload.ComposeFile == "" {
|
||||
payload.ComposeFile = filesystem.ComposeFileDefaultName
|
||||
if payload.ComposeFilePathInRepository == "" {
|
||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
}
|
||||
if !isUnique {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.Name), Err: errStackAlreadyExists}
|
||||
}
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for webhook ID collision", Err: err}
|
||||
}
|
||||
if !isUnique {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists}
|
||||
}
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name)
|
||||
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
|
||||
}
|
||||
|
||||
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackID),
|
||||
Name: payload.Name,
|
||||
Type: portainer.DockerComposeStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFile,
|
||||
AdditionalFiles: payload.AdditionalFiles,
|
||||
AutoUpdate: payload.AutoUpdate,
|
||||
Env: payload.Env,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: payload.RepositoryURL,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
ConfigFilePath: payload.ComposeFile,
|
||||
},
|
||||
ID: portainer.StackID(stackID),
|
||||
Name: payload.Name,
|
||||
Type: portainer.DockerComposeStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFilePathInRepository,
|
||||
Env: payload.Env,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
}
|
||||
}
|
||||
|
||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
err = handler.clone(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
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}
|
||||
}
|
||||
|
||||
commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err}
|
||||
}
|
||||
stack.GitConfig.ConfigHash = commitId
|
||||
|
||||
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
@@ -224,15 +187,6 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" {
|
||||
jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
stack.AutoUpdate.JobID = jobID
|
||||
}
|
||||
|
||||
stack.CreatedBy = config.user.Username
|
||||
|
||||
err = handler.DataStore.Stack().CreateStack(stack)
|
||||
@@ -377,7 +331,7 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai
|
||||
func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) error {
|
||||
isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to check user priviliges deploying a stack")
|
||||
return err
|
||||
}
|
||||
|
||||
securitySettings := &config.endpoint.SecuritySettings
|
||||
@@ -390,17 +344,15 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
|
||||
!securitySettings.AllowContainerCapabilitiesForRegularUsers) &&
|
||||
!isAdminOrEndpointAdmin {
|
||||
|
||||
for _, file := range append([]string{config.stack.EntryPoint}, config.stack.AdditionalFiles...) {
|
||||
path := path.Join(config.stack.ProjectPath, file)
|
||||
stackContent, err := handler.FileService.GetFileContent(path)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to get stack file content `%q`", path)
|
||||
}
|
||||
composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint)
|
||||
stackContent, err := handler.FileService.GetFileContent(composeFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = handler.isValidStackFile(stackContent, securitySettings)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "compose file is invalid")
|
||||
}
|
||||
err = handler.isValidStackFile(stackContent, securitySettings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +363,7 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
|
||||
|
||||
err = handler.ComposeStackManager.Up(config.stack, config.endpoint)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to start up the stack")
|
||||
return err
|
||||
}
|
||||
|
||||
return handler.SwarmStackManager.Logout(config.endpoint)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
@@ -8,15 +9,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
)
|
||||
|
||||
const defaultReferenceName = "refs/heads/master"
|
||||
@@ -38,12 +36,6 @@ type kubernetesGitDeploymentPayload struct {
|
||||
FilePathInRepository string
|
||||
}
|
||||
|
||||
type kubernetesManifestURLDeploymentPayload struct {
|
||||
Namespace string
|
||||
ComposeFormat bool
|
||||
ManifestURL string
|
||||
}
|
||||
|
||||
func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.StackFileContent) {
|
||||
return errors.New("Invalid stack file content")
|
||||
@@ -73,13 +65,6 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) {
|
||||
return errors.New("Invalid manifest URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type createKubernetesStackResponse struct {
|
||||
Output string `json:"Output"`
|
||||
}
|
||||
@@ -110,12 +95,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{
|
||||
StackID: stackID,
|
||||
Name: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "content",
|
||||
})
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||
}
|
||||
@@ -129,8 +109,6 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
|
||||
Output: output,
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
@@ -161,12 +139,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err}
|
||||
}
|
||||
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{
|
||||
StackID: stackID,
|
||||
Name: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "git",
|
||||
})
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||
}
|
||||
@@ -179,86 +152,23 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
resp := &createKubernetesStackResponse{
|
||||
Output: output,
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
|
||||
func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
var payload kubernetesManifestURLDeploymentPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackID),
|
||||
Type: portainer.KubernetesStack,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: filesystem.ManifestFileDefaultName,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
}
|
||||
|
||||
var manifestContent []byte
|
||||
manifestContent, err := client.Get(payload.ManifestURL, 30)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve manifest from URL", err}
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, manifestContent)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
|
||||
}
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, string(manifestContent), payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{
|
||||
StackID: stackID,
|
||||
Name: stack.Name,
|
||||
Owner: stack.CreatedBy,
|
||||
Kind: "url",
|
||||
})
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Stack().CreateStack(stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
resp := &createKubernetesStackResponse{
|
||||
Output: output,
|
||||
}
|
||||
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
func (handler *Handler) deployKubernetesStack(r *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) {
|
||||
func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) {
|
||||
handler.stackCreationMutex.Lock()
|
||||
defer handler.stackCreationMutex.Unlock()
|
||||
|
||||
manifest := []byte(stackConfig)
|
||||
if composeFormat {
|
||||
convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(manifest)
|
||||
convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(stackConfig)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest")
|
||||
return "", err
|
||||
}
|
||||
manifest = convertedConfig
|
||||
stackConfig = string(convertedConfig)
|
||||
}
|
||||
|
||||
manifest, err := k.AddAppLabels(manifest, appLabels)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to add application labels")
|
||||
}
|
||||
return handler.KubernetesDeployer.Deploy(request, endpoint, stackConfig, namespace)
|
||||
|
||||
return handler.KubernetesDeployer.Deploy(r, endpoint, string(manifest), namespace)
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) {
|
||||
|
||||
@@ -23,10 +23,6 @@ func (g *git) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName s
|
||||
return g.ClonePublicRepository(repositoryURL, referenceName, destination)
|
||||
}
|
||||
|
||||
func (g *git) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func TestCloneAndConvertGitRepoFile(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "kube-create-stack")
|
||||
assert.NoError(t, err, "failed to create a tmp dir")
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -122,11 +121,7 @@ type swarmStackFromGitRepositoryPayload struct {
|
||||
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||
RepositoryPassword string `example:"myGitPassword"`
|
||||
// Path to the Stack file inside the Git repository
|
||||
ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
// Applicable when deploying with multiple stack files
|
||||
AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"`
|
||||
// Optional auto update configuration
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
}
|
||||
|
||||
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
||||
@@ -139,14 +134,11 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
|
||||
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
payload.RepositoryReferenceName = defaultGitReferenceName
|
||||
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")
|
||||
}
|
||||
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
|
||||
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
}
|
||||
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
|
||||
return err
|
||||
if govalidator.IsNull(payload.ComposeFilePathInRepository) {
|
||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -155,74 +147,44 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||
var payload swarmStackFromGitRepositoryPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
}
|
||||
if !isUnique {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.Name), Err: errStackAlreadyExists}
|
||||
}
|
||||
|
||||
//make sure the webhook ID is unique
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" {
|
||||
isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for webhook ID collision", Err: err}
|
||||
}
|
||||
if !isUnique {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists}
|
||||
}
|
||||
errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", payload.Name)
|
||||
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
|
||||
}
|
||||
|
||||
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||
stack := &portainer.Stack{
|
||||
ID: portainer.StackID(stackID),
|
||||
Name: payload.Name,
|
||||
Type: portainer.DockerSwarmStack,
|
||||
SwarmID: payload.SwarmID,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFile,
|
||||
AdditionalFiles: payload.AdditionalFiles,
|
||||
AutoUpdate: payload.AutoUpdate,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: payload.RepositoryURL,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
ConfigFilePath: payload.ComposeFile,
|
||||
},
|
||||
ID: portainer.StackID(stackID),
|
||||
Name: payload.Name,
|
||||
Type: portainer.DockerSwarmStack,
|
||||
SwarmID: payload.SwarmID,
|
||||
EndpointID: endpoint.ID,
|
||||
EntryPoint: payload.ComposeFilePathInRepository,
|
||||
Env: payload.Env,
|
||||
Status: portainer.StackStatusActive,
|
||||
CreationDate: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
}
|
||||
}
|
||||
|
||||
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||
stack.ProjectPath = projectPath
|
||||
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
err = handler.clone(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
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}
|
||||
}
|
||||
|
||||
commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err}
|
||||
}
|
||||
stack.GitConfig.ConfigHash = commitId
|
||||
|
||||
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
if configErr != nil {
|
||||
return configErr
|
||||
@@ -230,23 +192,14 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||
|
||||
err = handler.deploySwarmStack(config)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" {
|
||||
jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
stack.AutoUpdate.JobID = jobID
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
}
|
||||
|
||||
stack.CreatedBy = config.user.Username
|
||||
|
||||
err = handler.DataStore.Stack().CreateStack(stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
|
||||
}
|
||||
|
||||
doCleanUp = false
|
||||
@@ -397,17 +350,16 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err
|
||||
settings := &config.endpoint.SecuritySettings
|
||||
|
||||
if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin {
|
||||
for _, file := range append([]string{config.stack.EntryPoint}, config.stack.AdditionalFiles...) {
|
||||
path := path.Join(config.stack.ProjectPath, file)
|
||||
stackContent, err := handler.FileService.GetFileContent(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint)
|
||||
|
||||
err = handler.isValidStackFile(stackContent, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stackContent, err := handler.FileService.GetFileContent(composeFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = handler.isValidStackFile(stackContent, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,30 +2,23 @@ package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks"
|
||||
)
|
||||
|
||||
const defaultGitReferenceName = "refs/heads/master"
|
||||
|
||||
var (
|
||||
errStackAlreadyExists = errors.New("A stack already exists with this name")
|
||||
errWebhookIDAlreadyExists = errors.New("A webhook ID already exists")
|
||||
errStackNotExternal = errors.New("Not an external stack")
|
||||
errStackAlreadyExists = errors.New("A stack already exists with this name")
|
||||
errStackNotExternal = errors.New("Not an external stack")
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle stack operations.
|
||||
@@ -41,8 +34,6 @@ type Handler struct {
|
||||
SwarmStackManager portainer.SwarmStackManager
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
Scheduler *scheduler.Scheduler
|
||||
StackDeployer stacks.StackDeployer
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage stack operations.
|
||||
@@ -66,9 +57,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
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.MethodPost)
|
||||
h.Handle("/stacks/{id}/git/redeploy",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackGitRedeploy))).Methods(http.MethodPut)
|
||||
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",
|
||||
@@ -77,9 +66,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStart))).Methods(http.MethodPost)
|
||||
h.Handle("/stacks/{id}/stop",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStop))).Methods(http.MethodPost)
|
||||
h.Handle("/stacks/webhooks/{webhookID}",
|
||||
httperror.LoggerHandler(h.webhookInvoke)).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -173,34 +159,3 @@ func (handler *Handler) checkUniqueName(endpoint *portainer.Endpoint, name strin
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) {
|
||||
_, err := handler.DataStore.Stack().StackByWebhookID(webhookID)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (handler *Handler) clone(projectPath, repositoryURL, refName string, auth bool, username, password string) error {
|
||||
if !auth {
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
|
||||
err := handler.GitService.CloneRepository(projectPath, repositoryURL, refName, username, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to clone git repository: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) latestCommitID(repositoryURL, refName string, auth bool, username, password string) (string, error) {
|
||||
if !auth {
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
|
||||
return handler.GitService.LatestCommitID(repositoryURL, refName, username, password)
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func validateStackAutoUpdate(autoUpdate *portainer.StackAutoUpdate) error {
|
||||
if autoUpdate == nil {
|
||||
return nil
|
||||
}
|
||||
if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) {
|
||||
return errors.New("invalid Webhook format")
|
||||
}
|
||||
if autoUpdate.Interval != "" {
|
||||
if _, err := time.ParseDuration(autoUpdate.Interval); err != nil {
|
||||
return errors.New("invalid Interval format")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_ValidateStackAutoUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value *portainer.StackAutoUpdate
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "webhook is not a valid UUID",
|
||||
value: &portainer.StackAutoUpdate{Webhook: "fake-webhook"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "incorrect interval value",
|
||||
value: &portainer.StackAutoUpdate{Interval: "1dd2hh3mm"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid auto update",
|
||||
value: &portainer.StackAutoUpdate{
|
||||
Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada",
|
||||
Interval: "5h30m40s10ms",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateStackAutoUpdate(tt.value)
|
||||
assert.Equalf(t, tt.wantErr, err != nil, "received %+v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,6 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
@@ -12,6 +9,8 @@ import (
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PUT request on /api/stacks/:id/associate?endpointId=<endpointId>&swarmId=<swarmId>&orphanedRunning=<orphanedRunning>
|
||||
@@ -88,10 +87,5 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) *
|
||||
|
||||
stack.ResourceControl = resourceControl
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
"github.com/docker/cli/cli/compose/types"
|
||||
"github.com/pkg/errors"
|
||||
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"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -127,7 +129,7 @@ func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Reques
|
||||
return handler.createComposeStackFromFileUpload(w, r, endpoint, userID)
|
||||
}
|
||||
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string, repository or file", Err: errors.New(request.ErrInvalidQueryParameter)}
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)}
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
|
||||
@@ -140,7 +142,7 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request,
|
||||
return handler.createSwarmStackFromFileUpload(w, r, endpoint, userID)
|
||||
}
|
||||
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string, repository or file", Err: errors.New(request.ErrInvalidQueryParameter)}
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)}
|
||||
}
|
||||
|
||||
func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
@@ -149,8 +151,6 @@ func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Req
|
||||
return handler.createKubernetesStackFromFileContent(w, r, endpoint)
|
||||
case "repository":
|
||||
return handler.createKubernetesStackFromGitRepository(w, r, endpoint)
|
||||
case "url":
|
||||
return handler.createKubernetesStackFromManifestURL(w, r, endpoint)
|
||||
}
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)}
|
||||
}
|
||||
@@ -232,11 +232,24 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
|
||||
}
|
||||
|
||||
stack.ResourceControl = resourceControl
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) cloneAndSaveConfig(stack *portainer.Stack, projectPath, repositoryURL, refName, configFilePath string, auth bool, username, password string) error {
|
||||
if !auth {
|
||||
username = ""
|
||||
password = ""
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -96,11 +96,6 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
|
||||
}
|
||||
}
|
||||
|
||||
// stop scheduler updates of the stack before removal
|
||||
if stack.AutoUpdate != nil {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
}
|
||||
|
||||
err = handler.deleteStack(stack, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
|
||||
|
||||
@@ -78,10 +78,5 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
|
||||
}
|
||||
}
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
@@ -81,13 +80,6 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
|
||||
stacks = authorization.FilterAuthorizedStacks(stacks, user, userTeamIDs)
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, stacks)
|
||||
}
|
||||
|
||||
|
||||
@@ -150,11 +150,6 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
|
||||
}
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
|
||||
@@ -85,17 +85,6 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Stack is already active", errors.New("Stack is already active")}
|
||||
}
|
||||
|
||||
if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
|
||||
jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
stack.AutoUpdate.JobID = jobID
|
||||
}
|
||||
|
||||
err = handler.startStack(stack, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start stack", err}
|
||||
@@ -107,11 +96,6 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err}
|
||||
}
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
|
||||
@@ -74,12 +74,6 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Stack is already inactive", errors.New("Stack is already inactive")}
|
||||
}
|
||||
|
||||
// stop scheduler updates of the stack before stopping
|
||||
if stack.AutoUpdate != nil && stack.AutoUpdate.JobID != "" {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
stack.AutoUpdate.JobID = ""
|
||||
}
|
||||
|
||||
err = handler.stopStack(stack, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to stop stack", err}
|
||||
@@ -91,11 +85,6 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err}
|
||||
}
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
|
||||
@@ -128,11 +128,6 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err}
|
||||
}
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -10,28 +13,22 @@ 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"
|
||||
"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 stackGitUpdatePayload struct {
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
Env []portainer.Pair
|
||||
type updateStackGitPayload struct {
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
}
|
||||
|
||||
func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
payload.RepositoryReferenceName = defaultGitReferenceName
|
||||
}
|
||||
|
||||
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
|
||||
return err
|
||||
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
|
||||
}
|
||||
@@ -56,23 +53,18 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
|
||||
func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
var payload stackGitUpdatePayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
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{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
|
||||
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{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
|
||||
} else if stack.GitConfig == nil {
|
||||
msg := "No Git config in the found stack"
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: msg, Err: errors.New(msg)}
|
||||
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
|
||||
@@ -80,7 +72,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
// 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{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err}
|
||||
}
|
||||
if endpointID != int(stack.EndpointID) {
|
||||
stack.EndpointID = portainer.EndpointID(endpointID)
|
||||
@@ -88,75 +80,117 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
|
||||
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{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
|
||||
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{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
|
||||
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{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
|
||||
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{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
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{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
//stop the autoupdate job if there is any
|
||||
if stack.AutoUpdate != nil {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
var payload updateStackGitPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
//update retrieved stack data based on the payload
|
||||
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
|
||||
stack.AutoUpdate = payload.AutoUpdate
|
||||
stack.Env = payload.Env
|
||||
|
||||
stack.GitConfig.Authentication = nil
|
||||
if payload.RepositoryAuthentication {
|
||||
password := payload.RepositoryPassword
|
||||
if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
|
||||
password = stack.GitConfig.Authentication.Password
|
||||
}
|
||||
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: password,
|
||||
}
|
||||
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}
|
||||
}
|
||||
|
||||
if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" {
|
||||
jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
stack.AutoUpdate.JobID = jobID
|
||||
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
|
||||
}
|
||||
|
||||
//save the updated stack to DB
|
||||
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err}
|
||||
}
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
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,190 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"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 stackGitRedployPayload struct {
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
Env []portainer.Pair
|
||||
}
|
||||
|
||||
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.RepositoryReferenceName) {
|
||||
payload.RepositoryReferenceName = defaultGitReferenceName
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PUT request on /api/stacks/:id/git?endpointId=<endpointId>
|
||||
func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
|
||||
}
|
||||
|
||||
if stack.GitConfig == nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Stack is not created from git", Err: 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{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: 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{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
|
||||
}
|
||||
|
||||
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
}
|
||||
|
||||
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
|
||||
}
|
||||
if !access {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
var payload stackGitRedployPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
|
||||
stack.Env = payload.Env
|
||||
|
||||
backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath)
|
||||
err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to move git repository directory", Err: err}
|
||||
}
|
||||
|
||||
repositoryUsername := ""
|
||||
repositoryPassword := ""
|
||||
if payload.RepositoryAuthentication {
|
||||
repositoryPassword = payload.RepositoryPassword
|
||||
if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
|
||||
repositoryPassword = stack.GitConfig.Authentication.Password
|
||||
}
|
||||
repositoryUsername = payload.RepositoryUsername
|
||||
}
|
||||
|
||||
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{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: 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{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err}
|
||||
}
|
||||
|
||||
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
|
||||
// sanitize password in the http response to minimise possible security leaks
|
||||
stack.GitConfig.Authentication.Password = ""
|
||||
}
|
||||
|
||||
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{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: 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{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
|
||||
}
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/portainer/libhttp/response"
|
||||
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/stacks"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
)
|
||||
|
||||
func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
webhookID, err := retrieveUUIDRouteVariableValue(r, "webhookID")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid webhook identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
stack, err := handler.DataStore.Stack().StackByWebhookID(webhookID.String())
|
||||
if err != nil {
|
||||
statusCode := http.StatusInternalServerError
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
statusCode = http.StatusNotFound
|
||||
}
|
||||
return &httperror.HandlerError{StatusCode: statusCode, Message: "Unable to find the stack by webhook ID", Err: err}
|
||||
}
|
||||
|
||||
if err = stacks.RedeployWhenChanged(stack.ID, handler.StackDeployer, handler.DataStore, handler.GitService); err != nil {
|
||||
log.Printf("[ERROR] %s\n", err)
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to update the stack", Err: err}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func retrieveUUIDRouteVariableValue(r *http.Request, name string) (uuid.UUID, error) {
|
||||
webhookID, err := request.RetrieveRouteVariableValue(r, name)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
uid, err := uuid.FromString(webhookID)
|
||||
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
|
||||
return uid, nil
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/portainer/portainer/api/bolt/bolttest"
|
||||
)
|
||||
|
||||
func TestHandler_webhookInvoke(t *testing.T) {
|
||||
store, teardown := bolttest.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
webhookID := newGuidString(t)
|
||||
store.StackService.CreateStack(&portainer.Stack{
|
||||
AutoUpdate: &portainer.StackAutoUpdate{
|
||||
Webhook: webhookID,
|
||||
},
|
||||
})
|
||||
|
||||
h := NewHandler(nil)
|
||||
h.DataStore = store
|
||||
|
||||
t.Run("invalid uuid results in http.StatusBadRequest", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest("notuuid")
|
||||
h.Router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
})
|
||||
t.Run("registered webhook ID in http.StatusNoContent", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest(webhookID)
|
||||
h.Router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNoContent, w.Code)
|
||||
})
|
||||
t.Run("unregistered webhook ID in http.StatusNotFound", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := newRequest(newGuidString(t))
|
||||
h.Router.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func newGuidString(t *testing.T) string {
|
||||
uuid, err := uuid.NewV4()
|
||||
assert.NoError(t, err)
|
||||
|
||||
return uuid.String()
|
||||
}
|
||||
|
||||
func newRequest(webhookID string) *http.Request {
|
||||
return httptest.NewRequest(http.MethodPost, "/stacks/webhooks/"+webhookID, nil)
|
||||
}
|
||||
@@ -135,25 +135,21 @@ func (handler *Handler) hijackPodExecStartOperation(
|
||||
stdoutReader, stdoutWriter := io.Pipe()
|
||||
defer stdoutWriter.Close()
|
||||
|
||||
// errorChan is used to propagate errors from the go routines to the caller.
|
||||
errorChan := make(chan error, 1)
|
||||
go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan)
|
||||
go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan)
|
||||
|
||||
// StartExecProcess is a blocking operation which streams IO to/from pod;
|
||||
// this must execute in asynchronously, since the websocketConn could return errors (e.g. client disconnects) before
|
||||
// the blocking operation is completed.
|
||||
go cli.StartExecProcess(serviceAccountToken, isAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter, errorChan)
|
||||
|
||||
err = <-errorChan
|
||||
|
||||
// websocket client successfully disconnected
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
|
||||
log.Printf("websocket error: %s \n", err.Error())
|
||||
return nil
|
||||
err = cli.StartExecProcess(serviceAccountToken, isAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err}
|
||||
}
|
||||
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err}
|
||||
err = <-errorChan
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
|
||||
log.Printf("websocket error: %s \n", err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) {
|
||||
|
||||
@@ -86,12 +86,17 @@ func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Req
|
||||
return nil
|
||||
}
|
||||
|
||||
serviceAccountToken, isAdminToken, err := handler.getToken(r, endpoint, false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user service account token", err}
|
||||
}
|
||||
|
||||
handlerErr := handler.hijackPodExecStartOperation(
|
||||
w,
|
||||
r,
|
||||
cli,
|
||||
"",
|
||||
true,
|
||||
serviceAccountToken,
|
||||
isAdminToken,
|
||||
endpoint,
|
||||
shellPod.Namespace,
|
||||
shellPod.PodName,
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
requesthelpers "github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
contextEndpoint = "endpoint"
|
||||
)
|
||||
|
||||
func WithEndpoint(endpointService portainer.EndpointService, endpointIDParam string) mux.MiddlewareFunc {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
|
||||
if endpointIDParam == "" {
|
||||
endpointIDParam = "id"
|
||||
}
|
||||
|
||||
endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam)
|
||||
if err != nil {
|
||||
httperror.WriteError(rw, http.StatusBadRequest, "Invalid endpoint identifier route variable", err)
|
||||
return
|
||||
}
|
||||
|
||||
endpoint, err := endpointService.Endpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
statusCode := http.StatusInternalServerError
|
||||
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
statusCode = http.StatusNotFound
|
||||
}
|
||||
httperror.WriteError(rw, statusCode, "Unable to find an endpoint with the specified identifier inside the database", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(request.Context(), contextEndpoint, endpoint)
|
||||
|
||||
next.ServeHTTP(rw, request.WithContext(ctx))
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func FetchEndpoint(request *http.Request) (*portainer.Endpoint, error) {
|
||||
contextData := request.Context().Value(contextEndpoint)
|
||||
if contextData == nil {
|
||||
return nil, errors.New("Unable to find endpoint data in request context")
|
||||
}
|
||||
|
||||
return contextData.(*portainer.Endpoint), nil
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/endpointproxy"
|
||||
"github.com/portainer/portainer/api/http/handler/endpoints"
|
||||
"github.com/portainer/portainer/api/http/handler/file"
|
||||
kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes"
|
||||
kube "github.com/portainer/portainer/api/http/handler/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/handler/motd"
|
||||
"github.com/portainer/portainer/api/http/handler/registries"
|
||||
"github.com/portainer/portainer/api/http/handler/resourcecontrols"
|
||||
@@ -50,8 +50,6 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/ssl"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
stackdeployer "github.com/portainer/portainer/api/stacks"
|
||||
)
|
||||
|
||||
// Server implements the portainer.Server interface
|
||||
@@ -81,10 +79,8 @@ type Server struct {
|
||||
DockerClientFactory *docker.ClientFactory
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
Scheduler *scheduler.Scheduler
|
||||
ShutdownCtx context.Context
|
||||
ShutdownTrigger context.CancelFunc
|
||||
StackDeployer stackdeployer.StackDeployer
|
||||
}
|
||||
|
||||
// Start starts the HTTP server
|
||||
@@ -143,8 +139,6 @@ func (server *Server) Start() error {
|
||||
endpointHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
endpointHandler.ComposeStackManager = server.ComposeStackManager
|
||||
endpointHandler.AuthorizationService = server.AuthorizationService
|
||||
endpointHandler.BindAddress = server.BindAddress
|
||||
endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS
|
||||
|
||||
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer)
|
||||
endpointEdgeHandler.DataStore = server.DataStore
|
||||
@@ -160,11 +154,12 @@ func (server *Server) Start() error {
|
||||
endpointProxyHandler.ProxyManager = server.ProxyManager
|
||||
endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.KubernetesClientFactory)
|
||||
kubernetesHandler.JwtService = server.JWTService
|
||||
|
||||
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"))
|
||||
|
||||
var kubernetesHandler = kube.NewHandler(requestBouncer)
|
||||
kubernetesHandler.DataStore = server.DataStore
|
||||
kubernetesHandler.KubernetesClientFactory = server.KubernetesClientFactory
|
||||
|
||||
var motdHandler = motd.NewHandler(requestBouncer)
|
||||
|
||||
var registryHandler = registries.NewHandler(requestBouncer)
|
||||
@@ -190,12 +185,10 @@ func (server *Server) Start() error {
|
||||
stackHandler.DataStore = server.DataStore
|
||||
stackHandler.DockerClientFactory = server.DockerClientFactory
|
||||
stackHandler.FileService = server.FileService
|
||||
stackHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||
stackHandler.GitService = server.GitService
|
||||
stackHandler.Scheduler = server.Scheduler
|
||||
stackHandler.SwarmStackManager = server.SwarmStackManager
|
||||
stackHandler.ComposeStackManager = server.ComposeStackManager
|
||||
stackHandler.StackDeployer = server.StackDeployer
|
||||
stackHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||
stackHandler.GitService = server.GitService
|
||||
|
||||
var tagHandler = tags.NewHandler(requestBouncer)
|
||||
tagHandler.DataStore = server.DataStore
|
||||
@@ -243,8 +236,8 @@ func (server *Server) Start() error {
|
||||
EndpointHandler: endpointHandler,
|
||||
EndpointEdgeHandler: endpointEdgeHandler,
|
||||
EndpointProxyHandler: endpointProxyHandler,
|
||||
KubernetesHandler: kubernetesHandler,
|
||||
FileHandler: fileHandler,
|
||||
KubernetesHandler: kubernetesHandler,
|
||||
MOTDHandler: motdHandler,
|
||||
RegistryHandler: registryHandler,
|
||||
ResourceControlHandler: resourceControlHandler,
|
||||
|
||||
17
api/internal/endpoint/endpoint.go
Normal file
17
api/internal/endpoint/endpoint.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package endpoint
|
||||
|
||||
import portainer "github.com/portainer/portainer/api"
|
||||
|
||||
// IsKubernetesEndpoint returns true if this is a kubernetes endpoint
|
||||
func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.KubernetesLocalEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||
}
|
||||
|
||||
// IsDockerEndpoint returns true if this is a docker endpoint
|
||||
func IsDockerEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.DockerEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnDockerEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// IsLocalEndpoint returns true if this is a local endpoint
|
||||
func IsLocalEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package stackutils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
@@ -11,12 +10,3 @@ import (
|
||||
func ResourceControlID(endpointID portainer.EndpointID, name string) string {
|
||||
return fmt.Sprintf("%d_%s", endpointID, name)
|
||||
}
|
||||
|
||||
// GetStackFilePaths returns a list of file paths based on stack project path
|
||||
func GetStackFilePaths(stack *portainer.Stack) []string {
|
||||
var filePaths []string
|
||||
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
|
||||
filePaths = append(filePaths, path.Join(stack.ProjectPath, file))
|
||||
}
|
||||
return filePaths
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package stackutils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_GetStackFilePaths(t *testing.T) {
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: "/tmp/stack/1",
|
||||
EntryPoint: "file-one.yml",
|
||||
}
|
||||
|
||||
t.Run("stack doesn't have additional files", func(t *testing.T) {
|
||||
expected := []string{"/tmp/stack/1/file-one.yml"}
|
||||
assert.ElementsMatch(t, expected, GetStackFilePaths(stack))
|
||||
})
|
||||
|
||||
t.Run("stack has additional files", func(t *testing.T) {
|
||||
stack.AdditionalFiles = []string{"file-two.yml", "file-three.yml"}
|
||||
expected := []string{"/tmp/stack/1/file-one.yml", "/tmp/stack/1/file-two.yml", "/tmp/stack/1/file-three.yml"}
|
||||
assert.ElementsMatch(t, expected, GetStackFilePaths(stack))
|
||||
})
|
||||
}
|
||||
@@ -70,21 +70,6 @@ func NewDatastore(options ...datastoreOption) *datastore {
|
||||
return &d
|
||||
}
|
||||
|
||||
|
||||
type stubSettingsService struct {
|
||||
settings *portainer.Settings
|
||||
}
|
||||
|
||||
func (s *stubSettingsService) Settings() (*portainer.Settings, error) { return s.settings, nil }
|
||||
func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { return nil }
|
||||
|
||||
func WithSettings(settings *portainer.Settings) datastoreOption {
|
||||
return func(d *datastore) {
|
||||
d.settings = &stubSettingsService{settings: settings}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
type stubUserService struct {
|
||||
users []portainer.User
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
type Service struct {
|
||||
secret []byte
|
||||
userSessionTimeout time.Duration
|
||||
dataStore portainer.DataStore
|
||||
}
|
||||
|
||||
type claims struct {
|
||||
@@ -32,7 +31,7 @@ var (
|
||||
)
|
||||
|
||||
// NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens.
|
||||
func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Service, error) {
|
||||
func NewService(userSessionDuration string) (*Service, error) {
|
||||
userSessionTimeout, err := time.ParseDuration(userSessionDuration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -46,28 +45,19 @@ func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Ser
|
||||
service := &Service{
|
||||
secret,
|
||||
userSessionTimeout,
|
||||
dataStore,
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (service *Service) defaultExpireAt() (int64) {
|
||||
return time.Now().Add(service.userSessionTimeout).Unix()
|
||||
}
|
||||
|
||||
// GenerateToken generates a new JWT token.
|
||||
func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) {
|
||||
return service.generateSignedToken(data, service.defaultExpireAt())
|
||||
return service.generateSignedToken(data, nil)
|
||||
}
|
||||
|
||||
// GenerateTokenForOAuth generates a new JWT for OAuth login
|
||||
// token expiry time from the OAuth provider is considered
|
||||
func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
|
||||
expireAt := service.defaultExpireAt()
|
||||
if expiryTime != nil && !expiryTime.IsZero() {
|
||||
expireAt = expiryTime.Unix()
|
||||
}
|
||||
return service.generateSignedToken(data, expireAt)
|
||||
return service.generateSignedToken(data, expiryTime)
|
||||
}
|
||||
|
||||
// ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid.
|
||||
@@ -98,13 +88,17 @@ func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration
|
||||
service.userSessionTimeout = userSessionDuration
|
||||
}
|
||||
|
||||
func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt int64) (string, error) {
|
||||
func (service *Service) generateSignedToken(data *portainer.TokenData, expiryTime *time.Time) (string, error) {
|
||||
expireToken := time.Now().Add(service.userSessionTimeout).Unix()
|
||||
if expiryTime != nil && !expiryTime.IsZero() {
|
||||
expireToken = expiryTime.Unix()
|
||||
}
|
||||
cl := claims{
|
||||
UserID: int(data.ID),
|
||||
Username: data.Username,
|
||||
Role: int(data.Role),
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: expiresAt,
|
||||
ExpiresAt: expireToken,
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GenerateTokenForKubeconfig generates a new JWT token for Kubeconfig
|
||||
func (service *Service) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) {
|
||||
settings, err := service.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
expiryDuration, err := time.ParseDuration(settings.KubeconfigExpiry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
expiryAt := time.Now().Add(expiryDuration).Unix()
|
||||
if expiryDuration == time.Duration(0) {
|
||||
expiryAt = 0
|
||||
}
|
||||
|
||||
return service.generateSignedToken(data, expiryAt)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
i "github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestService_GenerateTokenForKubeconfig(t *testing.T) {
|
||||
type fields struct {
|
||||
userSessionTimeout string
|
||||
dataStore portainer.DataStore
|
||||
}
|
||||
|
||||
type args struct {
|
||||
data *portainer.TokenData
|
||||
}
|
||||
|
||||
mySettings := &portainer.Settings{
|
||||
KubeconfigExpiry: "0",
|
||||
}
|
||||
|
||||
myFields := fields{
|
||||
userSessionTimeout: "24h",
|
||||
dataStore: i.NewDatastore(i.WithSettings(mySettings)),
|
||||
}
|
||||
|
||||
myTokenData := &portainer.TokenData{
|
||||
Username: "Joe",
|
||||
ID: 1,
|
||||
Role: 1,
|
||||
}
|
||||
|
||||
myArgs := args{
|
||||
data: myTokenData,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
wantExpiresAt int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "kubeconfig no expiry",
|
||||
fields: myFields,
|
||||
args: myArgs,
|
||||
wantExpiresAt: 0,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service, err := NewService(tt.fields.userSessionTimeout, tt.fields.dataStore)
|
||||
assert.NoError(t, err, "failed to create a copy of service")
|
||||
|
||||
got, err := service.GenerateTokenForKubeconfig(tt.args.data)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GenerateTokenForKubeconfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
parsedToken, err := jwt.ParseWithClaims(got, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return service.secret, nil
|
||||
})
|
||||
assert.NoError(t, err, "failed to parse generated token")
|
||||
|
||||
tokenClaims, ok := parsedToken.Claims.(*claims)
|
||||
assert.Equal(t, true, ok, "failed to claims out of generated ticket")
|
||||
|
||||
assert.Equal(t, myTokenData.Username, tokenClaims.Username)
|
||||
assert.Equal(t, int(myTokenData.ID), tokenClaims.UserID)
|
||||
assert.Equal(t, int(myTokenData.Role), tokenClaims.Role)
|
||||
assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestGenerateSignedToken(t *testing.T) {
|
||||
svc, err := NewService("24h", nil)
|
||||
svc, err := NewService("24h")
|
||||
assert.NoError(t, err, "failed to create a copy of service")
|
||||
|
||||
token := &portainer.TokenData{
|
||||
@@ -18,9 +18,9 @@ func TestGenerateSignedToken(t *testing.T) {
|
||||
ID: 1,
|
||||
Role: 1,
|
||||
}
|
||||
expiresAt := time.Now().Add(1 * time.Hour).Unix()
|
||||
expirtationTime := time.Now().Add(1 * time.Hour)
|
||||
|
||||
generatedToken, err := svc.generateSignedToken(token, expiresAt)
|
||||
generatedToken, err := svc.generateSignedToken(token, &expirtationTime)
|
||||
assert.NoError(t, err, "failed to generate a signed token")
|
||||
|
||||
parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
@@ -34,5 +34,5 @@ func TestGenerateSignedToken(t *testing.T) {
|
||||
assert.Equal(t, token.Username, tokenClaims.Username)
|
||||
assert.Equal(t, int(token.ID), tokenClaims.UserID)
|
||||
assert.Equal(t, int(token.Role), tokenClaims.Role)
|
||||
assert.Equal(t, expiresAt, tokenClaims.ExpiresAt)
|
||||
assert.Equal(t, expirtationTime.Unix(), tokenClaims.ExpiresAt)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/api/core/v1"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/remotecommand"
|
||||
@@ -15,12 +15,10 @@ import (
|
||||
// using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write
|
||||
// to the stdout parameter.
|
||||
// This function only works against a local endpoint using an in-cluster config with the user's SA token.
|
||||
// This is a blocking operation.
|
||||
func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) {
|
||||
func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error {
|
||||
config, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
if !useAdminToken {
|
||||
@@ -46,8 +44,7 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
|
||||
|
||||
exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
err = exec.Stream(remotecommand.StreamOptions{
|
||||
@@ -57,7 +54,9 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
|
||||
})
|
||||
if err != nil {
|
||||
if _, ok := err.(utilexec.ExitError); !ok {
|
||||
errChan <- errors.New("unable to start exec process")
|
||||
return errors.New("unable to start exec process")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
systemNamespaceLabel = "io.portainer.kubernetes.namespace.system"
|
||||
)
|
||||
|
||||
func defaultSystemNamespaces() map[string]struct{} {
|
||||
return map[string]struct{}{
|
||||
"kube-system": {},
|
||||
"kube-public": {},
|
||||
"kube-node-lease": {},
|
||||
"portainer": {},
|
||||
}
|
||||
}
|
||||
|
||||
func isSystemNamespace(namespace v1.Namespace) bool {
|
||||
systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel]
|
||||
if hasSystemLabel {
|
||||
return systemLabelValue == "true"
|
||||
}
|
||||
|
||||
systemNamespaces := defaultSystemNamespaces()
|
||||
|
||||
_, isSystem := systemNamespaces[namespace.Name]
|
||||
|
||||
return isSystem
|
||||
}
|
||||
|
||||
// ToggleSystemState will set a namespace as a system namespace, or remove this state
|
||||
// if isSystem is true it will set `systemNamespaceLabel` to "true" and false otherwise
|
||||
// this will skip if namespace is "default" or if the required state is already set
|
||||
func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) error {
|
||||
if namespaceName == "default" {
|
||||
return nil
|
||||
}
|
||||
|
||||
nsService := kcl.cli.CoreV1().Namespaces()
|
||||
|
||||
namespace, err := nsService.Get(namespaceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed fetching namespace object")
|
||||
}
|
||||
|
||||
if isSystemNamespace(*namespace) == isSystem {
|
||||
return nil
|
||||
}
|
||||
|
||||
if namespace.Labels == nil {
|
||||
namespace.Labels = map[string]string{}
|
||||
}
|
||||
|
||||
namespace.Labels[systemNamespaceLabel] = strconv.FormatBool(isSystem)
|
||||
|
||||
_, err = nsService.Update(namespace)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed updating namespace object")
|
||||
}
|
||||
|
||||
if isSystem {
|
||||
return kcl.NamespaceAccessPoliciesDeleteNamespace(namespaceName)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
core "k8s.io/api/core/v1"
|
||||
ktypes "k8s.io/api/core/v1"
|
||||
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func Test_ToggleSystemState(t *testing.T) {
|
||||
t.Run("should skip is default (exit without error)", func(t *testing.T) {
|
||||
nsName := "default"
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err := kcl.ToggleSystemState(nsName, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, exists := ns.Labels[systemNamespaceLabel]
|
||||
assert.False(t, exists, "system label should not exists")
|
||||
})
|
||||
|
||||
t.Run("should fail if namespace doesn't exist", func(t *testing.T) {
|
||||
nsName := "not-exist"
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err := kcl.ToggleSystemState(nsName, true)
|
||||
assert.Error(t, err)
|
||||
|
||||
})
|
||||
|
||||
t.Run("if called with the same state, should skip (exit without error)", func(t *testing.T) {
|
||||
nsName := "namespace"
|
||||
tests := []struct {
|
||||
isSystem bool
|
||||
}{
|
||||
{isSystem: true},
|
||||
{isSystem: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(strconv.FormatBool(test.isSystem), func(t *testing.T) {
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{
|
||||
systemNamespaceLabel: strconv.FormatBool(test.isSystem),
|
||||
}}}),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err := kcl.ToggleSystemState(nsName, test.isSystem)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, test.isSystem, isSystemNamespace(*ns))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("for regular namespace if isSystem is true and doesn't have a label, should set the label to true", func(t *testing.T) {
|
||||
nsName := "namespace"
|
||||
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err := kcl.ToggleSystemState(nsName, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
labelValue, exists := ns.Labels[systemNamespaceLabel]
|
||||
assert.True(t, exists, "system label should exists")
|
||||
|
||||
assert.Equal(t, "true", labelValue)
|
||||
})
|
||||
|
||||
t.Run("for default system namespace if isSystem is false and doesn't have a label, should set the label to false", func(t *testing.T) {
|
||||
nsName := "portainer"
|
||||
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err := kcl.ToggleSystemState(nsName, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
labelValue, exists := ns.Labels[systemNamespaceLabel]
|
||||
assert.True(t, exists, "system label should exists")
|
||||
|
||||
assert.Equal(t, "false", labelValue)
|
||||
})
|
||||
|
||||
t.Run("for system namespace (with label), if called with false, should set the label", func(t *testing.T) {
|
||||
nsName := "namespace"
|
||||
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{
|
||||
systemNamespaceLabel: "true",
|
||||
}}}),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err := kcl.ToggleSystemState(nsName, false)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
labelValue, exists := ns.Labels[systemNamespaceLabel]
|
||||
assert.True(t, exists, "system label should exists")
|
||||
assert.Equal(t, "false", labelValue)
|
||||
})
|
||||
|
||||
t.Run("for non system namespace (with label), if called with true, should set the label, and remove accesses", func(t *testing.T) {
|
||||
nsName := "ns1"
|
||||
|
||||
namespace := &core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{
|
||||
systemNamespaceLabel: "false",
|
||||
}}}
|
||||
|
||||
config := &ktypes.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: portainerConfigMapName,
|
||||
Namespace: portainerNamespace,
|
||||
},
|
||||
Data: map[string]string{
|
||||
"NamespaceAccessPolicies": `{"ns1":{"UserAccessPolicies":{"2":{"RoleId":0}}}, "ns2":{"UserAccessPolicies":{"2":{"RoleId":0}}}}`,
|
||||
},
|
||||
}
|
||||
|
||||
kcl := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(namespace, config),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
err := kcl.ToggleSystemState(nsName, true)
|
||||
assert.NoError(t, err)
|
||||
|
||||
ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
labelValue, exists := ns.Labels[systemNamespaceLabel]
|
||||
assert.True(t, exists, "system label should exists")
|
||||
assert.Equal(t, "true", labelValue)
|
||||
|
||||
expectedPolicies := map[string]portainer.K8sNamespaceAccessPolicy{
|
||||
"ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
}
|
||||
actualPolicies, err := kcl.GetNamespaceAccessPolicies()
|
||||
assert.NoError(t, err, "failed to fetch policies")
|
||||
assert.Equal(t, expectedPolicies, actualPolicies)
|
||||
|
||||
})
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// GetNodesLimits gets the CPU and Memory limits(unused resources) of all nodes in the current k8s endpoint connection
|
||||
func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
|
||||
nodesLimits := make(portainer.K8sNodesLimits)
|
||||
|
||||
nodes, err := kcl.cli.CoreV1().Nodes().List(metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pods, err := kcl.cli.CoreV1().Pods("").List(metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, item := range nodes.Items {
|
||||
cpu := item.Status.Allocatable.Cpu().MilliValue()
|
||||
memory := item.Status.Allocatable.Memory().Value()
|
||||
|
||||
nodesLimits[item.ObjectMeta.Name] = &portainer.K8sNodeLimits{
|
||||
CPU: cpu,
|
||||
Memory: memory,
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range pods.Items {
|
||||
if nodeLimits, ok := nodesLimits[item.Spec.NodeName]; ok {
|
||||
for _, container := range item.Spec.Containers {
|
||||
nodeLimits.CPU -= container.Resources.Requests.Cpu().MilliValue()
|
||||
nodeLimits.Memory -= container.Resources.Requests.Memory().Value()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodesLimits, nil
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newNodes() *v1.NodeList {
|
||||
return &v1.NodeList{
|
||||
Items: []v1.Node{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-node-0",
|
||||
},
|
||||
Status: v1.NodeStatus{
|
||||
Allocatable: v1.ResourceList{
|
||||
v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"),
|
||||
v1.ResourceName(v1.ResourceMemory): resource.MustParse("4M"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-node-1",
|
||||
},
|
||||
Status: v1.NodeStatus{
|
||||
Allocatable: v1.ResourceList{
|
||||
v1.ResourceName(v1.ResourceCPU): resource.MustParse("3"),
|
||||
v1.ResourceName(v1.ResourceMemory): resource.MustParse("6M"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newPods() *v1.PodList {
|
||||
return &v1.PodList{
|
||||
Items: []v1.Pod{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-container-0",
|
||||
Namespace: "test-namespace-0",
|
||||
},
|
||||
Spec: v1.PodSpec{
|
||||
NodeName: "test-node-0",
|
||||
Containers: []v1.Container{
|
||||
{
|
||||
Name: "test-container-0",
|
||||
Resources: v1.ResourceRequirements{
|
||||
Requests: v1.ResourceList{
|
||||
v1.ResourceName(v1.ResourceCPU): resource.MustParse("1"),
|
||||
v1.ResourceName(v1.ResourceMemory): resource.MustParse("2M"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-container-1",
|
||||
Namespace: "test-namespace-1",
|
||||
},
|
||||
Spec: v1.PodSpec{
|
||||
NodeName: "test-node-1",
|
||||
Containers: []v1.Container{
|
||||
{
|
||||
Name: "test-container-1",
|
||||
Resources: v1.ResourceRequirements{
|
||||
Requests: v1.ResourceList{
|
||||
v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"),
|
||||
v1.ResourceName(v1.ResourceMemory): resource.MustParse("3M"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestKubeClient_GetNodesLimits(t *testing.T) {
|
||||
type fields struct {
|
||||
cli kubernetes.Interface
|
||||
}
|
||||
|
||||
fieldsInstance := fields{
|
||||
cli: kfake.NewSimpleClientset(newNodes(), newPods()),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
want portainer.K8sNodesLimits
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "2 nodes 2 pods",
|
||||
fields: fieldsInstance,
|
||||
want: portainer.K8sNodesLimits{
|
||||
"test-node-0": &portainer.K8sNodeLimits{
|
||||
CPU: 1000,
|
||||
Memory: 2000000,
|
||||
},
|
||||
"test-node-1": &portainer.K8sNodeLimits{
|
||||
CPU: 1000,
|
||||
Memory: 3000000,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
kcl := &KubeClient{
|
||||
cli: tt.fields.cli,
|
||||
}
|
||||
got, err := kcl.GetNodesLimits()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("GetNodesLimits() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("GetNodesLimits() got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,15 @@ func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule {
|
||||
Resources: []string{"storageclasses"},
|
||||
APIGroups: []string{"storage.k8s.io"},
|
||||
},
|
||||
{
|
||||
Verbs: []string{"list"},
|
||||
Resources: []string{"ingresses"},
|
||||
APIGroups: []string{"networking.k8s.io"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) upsertPortainerK8sClusterRoles() error {
|
||||
func (kcl *KubeClient) createPortainerUserClusterRole() error {
|
||||
clusterRole := &rbacv1.ClusterRole{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: portainerUserCRName,
|
||||
@@ -30,13 +35,8 @@ func (kcl *KubeClient) upsertPortainerK8sClusterRoles() error {
|
||||
}
|
||||
|
||||
_, err := kcl.cli.RbacV1().ClusterRoles().Create(clusterRole)
|
||||
if err != nil {
|
||||
if k8serrors.IsAlreadyExists(err) {
|
||||
_, err = kcl.cli.RbacV1().ClusterRoles().Update(clusterRole)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err != nil && !k8serrors.IsAlreadyExists(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -63,7 +63,7 @@ func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int, restri
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) ensureRequiredResourcesExist() error {
|
||||
return kcl.upsertPortainerK8sClusterRoles()
|
||||
return kcl.createPortainerUserClusterRole()
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type KubeAppLabels struct {
|
||||
StackID int
|
||||
Name string
|
||||
Owner string
|
||||
Kind string
|
||||
}
|
||||
|
||||
// AddAppLabels adds required labels to "Resource"->metadata->labels.
|
||||
// It'll add those labels to all Resource (nodes with a kind property exluding a list) it can find in provided yaml.
|
||||
// Items in the yaml file could either be organised as a list or broken into multi documents.
|
||||
func AddAppLabels(manifestYaml []byte, appLabels KubeAppLabels) ([]byte, error) {
|
||||
if bytes.Equal(manifestYaml, []byte("")) {
|
||||
return manifestYaml, nil
|
||||
}
|
||||
|
||||
docs := make([][]byte, 0)
|
||||
yamlDecoder := yaml.NewDecoder(bytes.NewReader(manifestYaml))
|
||||
|
||||
for {
|
||||
m := make(map[string]interface{})
|
||||
err := yamlDecoder.Decode(&m)
|
||||
|
||||
// if decoded document is empty
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// if there are no more documents in the file
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
|
||||
addResourceLabels(m, appLabels)
|
||||
|
||||
var out bytes.Buffer
|
||||
yamlEncoder := yaml.NewEncoder(&out)
|
||||
yamlEncoder.SetIndent(2)
|
||||
if err := yamlEncoder.Encode(m); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal yaml manifest")
|
||||
}
|
||||
|
||||
docs = append(docs, out.Bytes())
|
||||
}
|
||||
|
||||
return bytes.Join(docs, []byte("---\n")), nil
|
||||
}
|
||||
|
||||
func addResourceLabels(yamlDoc interface{}, appLabels KubeAppLabels) {
|
||||
m, ok := yamlDoc.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
kind, ok := m["kind"]
|
||||
if ok && !strings.EqualFold(kind.(string), "list") {
|
||||
addLabels(m, appLabels)
|
||||
return
|
||||
}
|
||||
|
||||
for _, v := range m {
|
||||
switch v.(type) {
|
||||
case map[string]interface{}:
|
||||
addResourceLabels(v, appLabels)
|
||||
case []interface{}:
|
||||
for _, item := range v.([]interface{}) {
|
||||
addResourceLabels(item, appLabels)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addLabels(obj map[string]interface{}, appLabels KubeAppLabels) {
|
||||
metadata := make(map[string]interface{})
|
||||
if m, ok := obj["metadata"]; ok {
|
||||
metadata = m.(map[string]interface{})
|
||||
}
|
||||
|
||||
labels := make(map[string]string)
|
||||
if l, ok := metadata["labels"]; ok {
|
||||
for k, v := range l.(map[string]interface{}) {
|
||||
labels[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
name := appLabels.Name
|
||||
if appLabels.Name == "" {
|
||||
if n, ok := metadata["name"]; ok {
|
||||
name = n.(string)
|
||||
}
|
||||
}
|
||||
|
||||
labels["io.portainer.kubernetes.application.stackid"] = strconv.Itoa(appLabels.StackID)
|
||||
labels["io.portainer.kubernetes.application.name"] = name
|
||||
labels["io.portainer.kubernetes.application.owner"] = appLabels.Owner
|
||||
labels["io.portainer.kubernetes.application.kind"] = appLabels.Kind
|
||||
|
||||
metadata["labels"] = labels
|
||||
obj["metadata"] = metadata
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_AddAppLabels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantOutput string
|
||||
}{
|
||||
{
|
||||
name: "single deployment without labels",
|
||||
input: `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: busybox
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: busybox
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: busybox
|
||||
spec:
|
||||
containers:
|
||||
- image: busybox
|
||||
name: busybox
|
||||
`,
|
||||
wantOutput: `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: busybox
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: busybox
|
||||
spec:
|
||||
containers:
|
||||
- image: busybox
|
||||
name: busybox
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "single deployment with existing labels",
|
||||
input: `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
foo: bar
|
||||
name: busybox
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: busybox
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: busybox
|
||||
spec:
|
||||
containers:
|
||||
- image: busybox
|
||||
name: busybox
|
||||
`,
|
||||
wantOutput: `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
foo: bar
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: busybox
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: busybox
|
||||
spec:
|
||||
containers:
|
||||
- image: busybox
|
||||
name: busybox
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "complex kompose output",
|
||||
input: `apiVersion: v1
|
||||
items:
|
||||
- apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: web
|
||||
name: web
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
selector:
|
||||
io.kompose.service: web
|
||||
status:
|
||||
loadBalancer: {}
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: redis
|
||||
name: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
io.kompose.service: redis
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: redis
|
||||
status: {}
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
io.kompose.service: web
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: web
|
||||
status: {}
|
||||
kind: List
|
||||
metadata: {}
|
||||
`,
|
||||
wantOutput: `apiVersion: v1
|
||||
items:
|
||||
- apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: web
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
selector:
|
||||
io.kompose.service: web
|
||||
status:
|
||||
loadBalancer: {}
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: redis
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
io.kompose.service: redis
|
||||
strategy: {}
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: redis
|
||||
status: {}
|
||||
- apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
io.kompose.service: web
|
||||
strategy:
|
||||
type: Recreate
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: web
|
||||
status: {}
|
||||
kind: List
|
||||
metadata: {}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "multiple items separated by ---",
|
||||
input: `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: busybox
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: busybox
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: busybox
|
||||
spec:
|
||||
containers:
|
||||
- image: busybox
|
||||
name: busybox
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: web
|
||||
name: web
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
selector:
|
||||
io.kompose.service: web
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
foo: bar
|
||||
name: busybox
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: busybox
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: busybox
|
||||
spec:
|
||||
containers:
|
||||
- image: busybox
|
||||
name: busybox
|
||||
`,
|
||||
wantOutput: `apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: busybox
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: busybox
|
||||
spec:
|
||||
containers:
|
||||
- image: busybox
|
||||
name: busybox
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: web
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
selector:
|
||||
io.kompose.service: web
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
labels:
|
||||
foo: bar
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: busybox
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: busybox
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: busybox
|
||||
spec:
|
||||
containers:
|
||||
- image: busybox
|
||||
name: busybox
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantOutput: "",
|
||||
},
|
||||
{
|
||||
name: "no only deployments",
|
||||
input: `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: web
|
||||
name: web
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
selector:
|
||||
io.kompose.service: web
|
||||
`,
|
||||
wantOutput: `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
labels:
|
||||
io.kompose.service: web
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: best-name
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
selector:
|
||||
io.kompose.service: web
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
labels := KubeAppLabels{
|
||||
StackID: 123,
|
||||
Name: "best-name",
|
||||
Owner: "best-owner",
|
||||
Kind: "git",
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := AddAppLabels([]byte(tt.input), labels)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantOutput, string(result))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_AddAppLabels_PickingName_WhenLabelNameIsEmpty(t *testing.T) {
|
||||
labels := KubeAppLabels{
|
||||
StackID: 123,
|
||||
Owner: "best-owner",
|
||||
Kind: "git",
|
||||
}
|
||||
|
||||
input := `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: web
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
`
|
||||
|
||||
expected := `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: web
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
name: web
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
`
|
||||
|
||||
result, err := AddAppLabels([]byte(input), labels)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(result))
|
||||
}
|
||||
|
||||
func Test_AddAppLabels_PickingName_WhenLabelAndMetadataNameAreEmpty(t *testing.T) {
|
||||
labels := KubeAppLabels{
|
||||
StackID: 123,
|
||||
Owner: "best-owner",
|
||||
Kind: "git",
|
||||
}
|
||||
|
||||
input := `apiVersion: v1
|
||||
kind: Service
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
`
|
||||
|
||||
expected := `apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
io.portainer.kubernetes.application.kind: git
|
||||
io.portainer.kubernetes.application.name: ""
|
||||
io.portainer.kubernetes.application.owner: best-owner
|
||||
io.portainer.kubernetes.application.stackid: "123"
|
||||
spec:
|
||||
ports:
|
||||
- name: "5000"
|
||||
port: 5000
|
||||
targetPort: 5000
|
||||
`
|
||||
|
||||
result, err := AddAppLabels([]byte(input), labels)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, string(result))
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/portainer/libcompose/project"
|
||||
"github.com/portainer/libcompose/project/options"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -87,12 +86,12 @@ func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portain
|
||||
for _, envvar := range stack.Env {
|
||||
env[envvar.Name] = envvar.Value
|
||||
}
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
|
||||
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
proj, err := docker.NewProject(&ctx.Context{
|
||||
ConfigDir: manager.dataPath,
|
||||
Context: project.Context{
|
||||
ComposeFiles: filePaths,
|
||||
ComposeFiles: []string{composeFilePath},
|
||||
EnvironmentLookup: &lookup.ComposableEnvLookup{
|
||||
Lookups: []config.EnvironmentLookup{
|
||||
&lookup.EnvfileLookup{
|
||||
@@ -121,13 +120,10 @@ func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *porta
|
||||
return err
|
||||
}
|
||||
|
||||
var composeFiles []string
|
||||
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
|
||||
composeFiles = append(composeFiles, path.Join(stack.ProjectPath, file))
|
||||
}
|
||||
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
proj, err := docker.NewProject(&ctx.Context{
|
||||
Context: project.Context{
|
||||
ComposeFiles: composeFiles,
|
||||
ComposeFiles: []string{composeFilePath},
|
||||
ProjectName: stack.Name,
|
||||
},
|
||||
ClientFactory: clientFactory,
|
||||
@@ -138,11 +134,3 @@ func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *porta
|
||||
|
||||
return proj.Down(context.Background(), options.Down{RemoveVolume: false, RemoveOrphans: true})
|
||||
}
|
||||
|
||||
func stackFilePaths(stack *portainer.Stack) []string {
|
||||
var filePaths []string
|
||||
for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) {
|
||||
filePaths = append(filePaths, path.Join(stack.ProjectPath, file))
|
||||
}
|
||||
return filePaths
|
||||
}
|
||||
|
||||
@@ -398,13 +398,6 @@ type (
|
||||
// JobType represents a job type
|
||||
JobType int
|
||||
|
||||
K8sNodeLimits struct {
|
||||
CPU int64 `json:"CPU"`
|
||||
Memory int64 `json:"Memory"`
|
||||
}
|
||||
|
||||
K8sNodesLimits map[string]*K8sNodeLimits
|
||||
|
||||
K8sNamespaceAccessPolicy struct {
|
||||
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
|
||||
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
|
||||
@@ -689,8 +682,6 @@ type (
|
||||
EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:""`
|
||||
// The duration of a user session
|
||||
UserSessionTimeout string `json:"UserSessionTimeout" example:"5m"`
|
||||
// The expiry of a Kubeconfig
|
||||
KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"`
|
||||
// Whether telemetry is enabled
|
||||
EnableTelemetry bool `json:"EnableTelemetry" example:"false"`
|
||||
|
||||
@@ -753,24 +744,10 @@ type (
|
||||
UpdateDate int64 `example:"1587399600"`
|
||||
// The username which last updated this stack
|
||||
UpdatedBy string `example:"bob"`
|
||||
// Only applies when deploying stack with multiple files
|
||||
AdditionalFiles []string `json:"AdditionalFiles"`
|
||||
// The auto update settings of a git stack
|
||||
AutoUpdate *StackAutoUpdate `json:"AutoUpdate"`
|
||||
// The git config of this stack
|
||||
GitConfig *gittypes.RepoConfig
|
||||
}
|
||||
|
||||
//StackAutoUpdate represents the git auto sync config for stack deployment
|
||||
StackAutoUpdate struct {
|
||||
// Auto update interval
|
||||
Interval string `example:"1m30s"`
|
||||
// A UUID generated from client
|
||||
Webhook string `example:"05de31a2-79fa-4644-9c12-faa67e5c49f0"`
|
||||
// Autoupdate job id
|
||||
JobID string `example:"15"`
|
||||
}
|
||||
|
||||
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
|
||||
StackID int
|
||||
|
||||
@@ -1210,40 +1187,36 @@ type (
|
||||
// GitService represents a service for managing Git
|
||||
GitService interface {
|
||||
CloneRepository(destination string, repositoryURL, referenceName, username, password string) error
|
||||
LatestCommitID(repositoryURL, referenceName, username, password string) (string, error)
|
||||
}
|
||||
|
||||
// JWTService represents a service for managing JWT tokens
|
||||
JWTService interface {
|
||||
GenerateToken(data *TokenData) (string, error)
|
||||
GenerateTokenForOAuth(data *TokenData, expiryTime *time.Time) (string, error)
|
||||
GenerateTokenForKubeconfig(data *TokenData) (string, error)
|
||||
ParseAndVerifyToken(token string) (*TokenData, error)
|
||||
SetUserSessionDuration(userSessionDuration time.Duration)
|
||||
}
|
||||
|
||||
// KubeClient represents a service used to query a Kubernetes environment
|
||||
KubeClient interface {
|
||||
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
|
||||
GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error)
|
||||
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
|
||||
GetServiceAccountBearerToken(userID int) (string, error)
|
||||
CreateUserShellPod(ctx context.Context, serviceAccountName string) (*KubernetesShellPod, error)
|
||||
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
|
||||
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error
|
||||
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
|
||||
GetNodesLimits() (K8sNodesLimits, error)
|
||||
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
|
||||
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
|
||||
DeleteRegistrySecret(registry *Registry, namespace string) error
|
||||
CreateRegistrySecret(registry *Registry, namespace string) error
|
||||
IsRegistrySecret(namespace, secretName string) (bool, error)
|
||||
GetKubeConfig(ctx context.Context, apiServerURL string, bearerToken string, tokenData *TokenData) (*clientV1.Config, error)
|
||||
ToggleSystemState(namespace string, isSystem bool) error
|
||||
}
|
||||
|
||||
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint
|
||||
KubernetesDeployer interface {
|
||||
Deploy(request *http.Request, endpoint *Endpoint, data string, namespace string) (string, error)
|
||||
ConvertCompose(data []byte) ([]byte, error)
|
||||
ConvertCompose(data string) ([]byte, error)
|
||||
}
|
||||
|
||||
// KubernetesSnapshotter represents a service used to create Kubernetes endpoint snapshots
|
||||
@@ -1329,8 +1302,6 @@ type (
|
||||
UpdateStack(ID StackID, stack *Stack) error
|
||||
DeleteStack(ID StackID) error
|
||||
GetNextIdentifier() int
|
||||
StackByWebhookID(ID string) (*Stack, error)
|
||||
RefreshableStacks() ([]Stack, error)
|
||||
}
|
||||
|
||||
// SnapshotService represents a service for managing endpoint snapshots
|
||||
@@ -1421,7 +1392,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.6.3"
|
||||
APIVersion = "2.6.0"
|
||||
// DBVersion is the version number of the Portainer database
|
||||
DBVersion = 32
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -1455,8 +1426,6 @@ const (
|
||||
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json"
|
||||
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
|
||||
DefaultUserSessionTimeout = "8h"
|
||||
// DefaultUserSessionTimeout represents the default timeout after which the user session is cleared
|
||||
DefaultKubeconfigExpiry = "0"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
crontab *cron.Cron
|
||||
shutdownCtx context.Context
|
||||
}
|
||||
|
||||
func NewScheduler(ctx context.Context) *Scheduler {
|
||||
crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
|
||||
crontab.Start()
|
||||
|
||||
s := &Scheduler{
|
||||
crontab: crontab,
|
||||
}
|
||||
|
||||
if ctx != nil {
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
s.Shutdown()
|
||||
}()
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Shutdown stops the scheduler and waits for it to stop if it is running; otherwise does nothing.
|
||||
func (s *Scheduler) Shutdown() error {
|
||||
if s.crontab == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("[DEBUG] Stopping scheduler")
|
||||
ctx := s.crontab.Stop()
|
||||
<-ctx.Done()
|
||||
|
||||
for _, j := range s.crontab.Entries() {
|
||||
s.crontab.Remove(j.ID)
|
||||
}
|
||||
|
||||
err := ctx.Err()
|
||||
if err == context.Canceled {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// StopJob stops the job from being run in the future
|
||||
func (s *Scheduler) StopJob(jobID string) error {
|
||||
id, err := strconv.Atoi(jobID)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed convert jobID %q to int", jobID)
|
||||
}
|
||||
s.crontab.Remove(cron.EntryID(id))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartJobEvery schedules a new periodic job with a given duration.
|
||||
// Returns job id that could be used to stop the given job
|
||||
func (s *Scheduler) StartJobEvery(duration time.Duration, job func()) string {
|
||||
entryId := s.crontab.Schedule(cron.Every(duration), cron.FuncJob(job))
|
||||
return strconv.Itoa(int(entryId))
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_CanStartAndTerminate(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
s.StartJobEvery(1*time.Minute, func() { fmt.Println("boop") })
|
||||
|
||||
err := s.Shutdown()
|
||||
assert.NoError(t, err, "Shutdown should return no errors")
|
||||
assert.Empty(t, s.crontab.Entries(), "all jobs should have been removed")
|
||||
}
|
||||
|
||||
func Test_CanTerminateByCancellingContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := NewScheduler(ctx)
|
||||
s.StartJobEvery(1*time.Minute, func() { fmt.Println("boop") })
|
||||
|
||||
cancel()
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
if len(s.crontab.Entries()) == 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("all jobs are expected to be cleaned by now; it might be a timing issue, otherwise implementation defect")
|
||||
}
|
||||
|
||||
func Test_StartAndStopJob(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer s.Shutdown()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
|
||||
var jobOne string
|
||||
var workDone bool
|
||||
jobOne = s.StartJobEvery(time.Second, func() {
|
||||
assert.Equal(t, 1, len(s.crontab.Entries()), "scheduler should have one active job")
|
||||
workDone = true
|
||||
|
||||
s.StopJob(jobOne)
|
||||
cancel()
|
||||
})
|
||||
|
||||
<-ctx.Done()
|
||||
assert.True(t, workDone, "value should been set in the job")
|
||||
assert.Equal(t, 0, len(s.crontab.Entries()), "scheduler should have no active jobs")
|
||||
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) error {
|
||||
stack, err := datastore.Stack().Stack(stackID)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
|
||||
}
|
||||
|
||||
if stack.GitConfig == nil {
|
||||
return nil // do nothing if it isn't a git-based stack
|
||||
}
|
||||
|
||||
username, password := "", ""
|
||||
if stack.GitConfig.Authentication != nil {
|
||||
username, password = stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password
|
||||
}
|
||||
|
||||
newHash, err := gitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, username, password)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID)
|
||||
}
|
||||
|
||||
if strings.EqualFold(newHash, string(stack.GitConfig.ConfigHash)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloneParams := &cloneRepositoryParameters{
|
||||
url: stack.GitConfig.URL,
|
||||
ref: stack.GitConfig.ReferenceName,
|
||||
toDir: stack.ProjectPath,
|
||||
}
|
||||
if stack.GitConfig.Authentication != nil {
|
||||
cloneParams.auth = &gitAuth{
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
if err := cloneGitRepository(gitService, cloneParams); err != nil {
|
||||
return errors.WithMessagef(err, "failed to do a fresh clone of the stack %v", stack.ID)
|
||||
}
|
||||
|
||||
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to find the endpoint %v associated to the stack %v", stack.EndpointID, stack.ID)
|
||||
}
|
||||
|
||||
author := stack.UpdatedBy
|
||||
if author == "" {
|
||||
author = stack.CreatedBy
|
||||
}
|
||||
|
||||
registries, err := getUserRegistries(datastore, author, endpoint.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch stack.Type {
|
||||
case portainer.DockerComposeStack:
|
||||
err := deployer.DeployComposeStack(stack, endpoint, registries)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
|
||||
}
|
||||
case portainer.DockerSwarmStack:
|
||||
err := deployer.DeploySwarmStack(stack, endpoint, registries, true)
|
||||
if err != nil {
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
|
||||
}
|
||||
default:
|
||||
return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type)
|
||||
}
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.GitConfig.ConfigHash = newHash
|
||||
if err := datastore.Stack().UpdateStack(stack.ID, stack); err != nil {
|
||||
return errors.WithMessagef(err, "failed to update the stack %v", stack.ID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getUserRegistries(datastore portainer.DataStore, authorUsername string, endpointID portainer.EndpointID) ([]portainer.Registry, error) {
|
||||
registries, err := datastore.Registry().Registries()
|
||||
if err != nil {
|
||||
return nil, errors.WithMessage(err, "unable to retrieve registries from the database")
|
||||
}
|
||||
|
||||
user, err := datastore.User().UserByUsername(authorUsername)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessagef(err, "failed to fetch a stack's author [%s]", authorUsername)
|
||||
}
|
||||
|
||||
if user.Role == portainer.AdministratorRole {
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
userMemberships, err := datastore.TeamMembership().TeamMembershipsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return nil, errors.WithMessagef(err, "failed to fetch memberships of the stack author [%s]", authorUsername)
|
||||
}
|
||||
|
||||
filteredRegistries := make([]portainer.Registry, 0, len(registries))
|
||||
for _, registry := range registries {
|
||||
if security.AuthorizedRegistryAccess(®istry, user, userMemberships, endpointID) {
|
||||
filteredRegistries = append(filteredRegistries, registry)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredRegistries, nil
|
||||
}
|
||||
|
||||
type cloneRepositoryParameters struct {
|
||||
url string
|
||||
ref string
|
||||
toDir string
|
||||
auth *gitAuth
|
||||
}
|
||||
|
||||
type gitAuth struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error {
|
||||
if cloneParams.auth != nil {
|
||||
return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, cloneParams.auth.username, cloneParams.auth.password)
|
||||
}
|
||||
return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, "", "")
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolt "github.com/portainer/portainer/api/bolt/bolttest"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type gitService struct {
|
||||
cloneErr error
|
||||
id string
|
||||
}
|
||||
|
||||
func (g *gitService) CloneRepository(destination, repositoryURL, referenceName, username, password string) error {
|
||||
return g.cloneErr
|
||||
}
|
||||
|
||||
func (g *gitService) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) {
|
||||
return g.id, nil
|
||||
}
|
||||
|
||||
type noopDeployer struct{}
|
||||
|
||||
func (s *noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) {
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
err := RedeployWhenChanged(1, nil, store, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Truef(t, strings.HasPrefix(err.Error(), "failed to get the stack"), "it isn't an error we expected: %v", err.Error())
|
||||
}
|
||||
|
||||
func Test_redeployWhenChanged_DoesNothingWhenNotAGitBasedStack(t *testing.T) {
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
err := store.Stack().CreateStack(&portainer.Stack{ID: 1})
|
||||
assert.NoError(t, err, "failed to create a test stack")
|
||||
|
||||
err = RedeployWhenChanged(1, nil, store, &gitService{nil, ""})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) {
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
tmpDir, _ := ioutil.TempDir("", "stack")
|
||||
|
||||
err := store.Stack().CreateStack(&portainer.Stack{
|
||||
ID: 1,
|
||||
ProjectPath: tmpDir,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "url",
|
||||
ReferenceName: "ref",
|
||||
ConfigHash: "oldHash",
|
||||
}})
|
||||
assert.NoError(t, err, "failed to create a test stack")
|
||||
|
||||
err = RedeployWhenChanged(1, nil, store, &gitService{nil, "oldHash"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) {
|
||||
cloneErr := errors.New("failed to clone")
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
err := store.Stack().CreateStack(&portainer.Stack{
|
||||
ID: 1,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "url",
|
||||
ReferenceName: "ref",
|
||||
ConfigHash: "oldHash",
|
||||
}})
|
||||
assert.NoError(t, err, "failed to create a test stack")
|
||||
|
||||
err = RedeployWhenChanged(1, nil, store, &gitService{cloneErr, "newHash"})
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, cloneErr, "should failed to clone but didn't, check test setup")
|
||||
}
|
||||
|
||||
func Test_redeployWhenChanged(t *testing.T) {
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
tmpDir, _ := ioutil.TempDir("", "stack")
|
||||
|
||||
err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1})
|
||||
assert.NoError(t, err, "error creating endpoint")
|
||||
|
||||
username := "user"
|
||||
err = store.User().CreateUser(&portainer.User{Username: username, Role: portainer.AdministratorRole})
|
||||
assert.NoError(t, err, "error creating a user")
|
||||
|
||||
stack := portainer.Stack{
|
||||
ID: 1,
|
||||
EndpointID: 1,
|
||||
ProjectPath: tmpDir,
|
||||
UpdatedBy: username,
|
||||
GitConfig: &gittypes.RepoConfig{
|
||||
URL: "url",
|
||||
ReferenceName: "ref",
|
||||
ConfigHash: "oldHash",
|
||||
}}
|
||||
err = store.Stack().CreateStack(&stack)
|
||||
assert.NoError(t, err, "failed to create a test stack")
|
||||
|
||||
t.Run("can deploy docker compose stack", func(t *testing.T) {
|
||||
stack.Type = portainer.DockerComposeStack
|
||||
store.Stack().UpdateStack(stack.ID, &stack)
|
||||
|
||||
err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("can deploy docker swarm stack", func(t *testing.T) {
|
||||
stack.Type = portainer.DockerSwarmStack
|
||||
store.Stack().UpdateStack(stack.ID, &stack)
|
||||
|
||||
err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"})
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("can NOT deploy kube stack", func(t *testing.T) {
|
||||
stack.Type = portainer.KubernetesStack
|
||||
store.Stack().UpdateStack(stack.ID, &stack)
|
||||
|
||||
err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"})
|
||||
assert.EqualError(t, err, "cannot update stack, type 3 is unsupported")
|
||||
})
|
||||
}
|
||||
|
||||
func Test_getUserRegistries(t *testing.T) {
|
||||
store, teardown := bolt.MustNewTestStore(true)
|
||||
defer teardown()
|
||||
|
||||
endpointID := 123
|
||||
|
||||
admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
||||
err := store.User().CreateUser(&admin)
|
||||
assert.NoError(t, err, "error creating an admin")
|
||||
|
||||
user := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole}
|
||||
err = store.User().CreateUser(&user)
|
||||
assert.NoError(t, err, "error creating a user")
|
||||
|
||||
team := portainer.Team{ID: 1, Name: "team"}
|
||||
|
||||
store.TeamMembership().CreateTeamMembership(&portainer.TeamMembership{
|
||||
ID: 1,
|
||||
UserID: user.ID,
|
||||
TeamID: team.ID,
|
||||
Role: portainer.TeamMember,
|
||||
})
|
||||
|
||||
registryReachableByUser := portainer.Registry{
|
||||
ID: 1,
|
||||
RegistryAccesses: portainer.RegistryAccesses{
|
||||
portainer.EndpointID(endpointID): {
|
||||
UserAccessPolicies: map[portainer.UserID]portainer.AccessPolicy{
|
||||
user.ID: {RoleID: portainer.RoleID(portainer.StandardUserRole)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err = store.Registry().CreateRegistry(®istryReachableByUser)
|
||||
assert.NoError(t, err, "couldn't create a registry")
|
||||
|
||||
registryReachableByTeam := portainer.Registry{
|
||||
ID: 2,
|
||||
RegistryAccesses: portainer.RegistryAccesses{
|
||||
portainer.EndpointID(endpointID): {
|
||||
TeamAccessPolicies: map[portainer.TeamID]portainer.AccessPolicy{
|
||||
team.ID: {RoleID: portainer.RoleID(portainer.StandardUserRole)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err = store.Registry().CreateRegistry(®istryReachableByTeam)
|
||||
assert.NoError(t, err, "couldn't create a registry")
|
||||
|
||||
registryRestricted := portainer.Registry{
|
||||
ID: 3,
|
||||
RegistryAccesses: portainer.RegistryAccesses{
|
||||
portainer.EndpointID(endpointID): {
|
||||
UserAccessPolicies: map[portainer.UserID]portainer.AccessPolicy{
|
||||
user.ID + 100: {RoleID: portainer.RoleID(portainer.StandardUserRole)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err = store.Registry().CreateRegistry(®istryRestricted)
|
||||
assert.NoError(t, err, "couldn't create a registry")
|
||||
|
||||
t.Run("admin should has access to all registries", func(t *testing.T) {
|
||||
registries, err := getUserRegistries(store, admin.Username, portainer.EndpointID(endpointID))
|
||||
assert.NoError(t, err)
|
||||
assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam, registryRestricted}, registries)
|
||||
})
|
||||
|
||||
t.Run("regular user has access to registries allowed to him and/or his team", func(t *testing.T) {
|
||||
registries, err := getUserRegistries(store, user.Username, portainer.EndpointID(endpointID))
|
||||
assert.NoError(t, err)
|
||||
assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam}, registries)
|
||||
})
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type StackDeployer interface {
|
||||
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error
|
||||
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error
|
||||
}
|
||||
|
||||
type stackDeployer struct {
|
||||
lock *sync.Mutex
|
||||
swarmStackManager portainer.SwarmStackManager
|
||||
composeStackManager portainer.ComposeStackManager
|
||||
}
|
||||
|
||||
func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager) *stackDeployer {
|
||||
return &stackDeployer{
|
||||
lock: &sync.Mutex{},
|
||||
swarmStackManager: swarmStackManager,
|
||||
composeStackManager: composeStackManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.swarmStackManager.Login(registries, endpoint)
|
||||
defer d.swarmStackManager.Logout(endpoint)
|
||||
|
||||
return d.swarmStackManager.Deploy(stack, prune, endpoint)
|
||||
}
|
||||
|
||||
func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.swarmStackManager.Login(registries, endpoint)
|
||||
defer d.swarmStackManager.Logout(endpoint)
|
||||
|
||||
return d.composeStackManager.Up(stack, endpoint)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user