Compare commits
26 Commits
528
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114073ae59 | ||
|
|
f1e2bb14a9 | ||
|
|
ed2c65c1e6 | ||
|
|
51ef2c2aa9 | ||
|
|
5652bac004 | ||
|
|
ce31de5e9e | ||
|
|
cee7ac26e9 | ||
|
|
c943ac498f | ||
|
|
49f25e9c4c | ||
|
|
7d6b1edd48 | ||
|
|
c26af1449c | ||
|
|
09c5bada3e | ||
|
|
fe07815fc7 | ||
|
|
c56c236e3a | ||
|
|
68453482af | ||
|
|
7b2269fbba | ||
|
|
bd47bb8cdc | ||
|
|
f9ffb1a712 | ||
|
|
592f7024e1 | ||
|
|
00fc629c1c | ||
|
|
6a9b386df8 | ||
|
|
8aa3bfc59c | ||
|
|
308f828446 | ||
|
|
89756b2e01 | ||
|
|
db16299aab | ||
|
|
72117693fb |
4
.vscode.example/settings.json
Normal file
4
.vscode.example/settings.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintFlags": ["--fast", "-E", "exportloopref"]
|
||||
}
|
||||
40
README.md
40
README.md
@@ -1,16 +1,12 @@
|
||||
<p align="center">
|
||||
<img title="portainer" src='https://github.com/portainer/portainer/blob/develop/app/assets/images/logo_alt.png?raw=true' />
|
||||
<img title="portainer" src='https://github.com/portainer/portainer/blob/develop/app/assets/images/portainer-github-banner.png?raw=true' />
|
||||
</p>
|
||||
|
||||
[](https://hub.docker.com/r/portainer/portainer/)
|
||||
[](http://microbadger.com/images/portainer/portainer 'Image size')
|
||||
[](https://portainer.visualstudio.com/Portainer%20CI/_build/latest?definitionId=3&branchName=develop)
|
||||
[](https://codeclimate.com/github/portainer/portainer)
|
||||
[](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6)
|
||||
**Portainer CE** is a lightweight ‘universal’ management GUI that can be used to **easily** manage Docker, Swarm, Kubernetes and ACI environments. It is designed to be as **simple** to deploy as it is to use.
|
||||
|
||||
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
|
||||
**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container, supports other platforms too).
|
||||
**_Portainer_** allows you to manage all your Docker resources (containers, images, volumes, networks and more!) It is compatible with the _standalone Docker_ engine and with _Docker Swarm mode_.
|
||||
Portainer consists of a single container that can run on any cluster. It can be deployed as a Linux container or a Windows native container.
|
||||
|
||||
**Portainer** allows you to manage all your orchestrator resources (containers, images, volumes, networks and more) through a super-simple graphical interface.
|
||||
|
||||
## Demo
|
||||
|
||||
@@ -18,30 +14,38 @@ You can try out the public demo instance: http://demo.portainer.io/ (login with
|
||||
|
||||
Please note that the public demo cluster is **reset every 15min**.
|
||||
|
||||
Alternatively, you can deploy a copy of the demo stack inside a [play-with-docker (PWD)](https://labs.play-with-docker.com) playground:
|
||||
## Latest Version
|
||||
|
||||
- Browse [PWD/?stack=portainer-demo/play-with-docker/docker-stack.yml](http://play-with-docker.com/?stack=https://raw.githubusercontent.com/portainer/portainer-demo/master/play-with-docker/docker-stack.yml)
|
||||
- Sign in with your [Docker ID](https://docs.docker.com/docker-id)
|
||||
- Follow [these](https://github.com/portainer/portainer-demo/blob/master/play-with-docker/docker-stack.yml#L5-L8) steps.
|
||||
Portainer CE is updated regularly. We aim to do an update release every couple of months.
|
||||
|
||||
Unlike the public demo, the playground sessions are deleted after 4 hours. Apart from that, all the settings are the same, including default credentials.
|
||||
**The latest version of Portainer is 2.6.x** And you can find the release notes [here.](https://www.portainer.io/blog/new-portainer-ce-2.6.0-release)
|
||||
Portainer is on version 2, the second number denotes the month of release.
|
||||
|
||||
## Getting started
|
||||
|
||||
- [Deploy Portainer](https://documentation.portainer.io/quickstart/)
|
||||
- [Documentation](https://documentation.portainer.io)
|
||||
- [Building Portainer](https://documentation.portainer.io/contributing/instructions/)
|
||||
- [Contribute to the project](https://documentation.portainer.io/contributing/instructions/)
|
||||
|
||||
## Features & Functions
|
||||
|
||||
View [this](https://www.portainer.io/products) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
||||
|
||||
- [Portainer CE for Docker / Docker Swarm](https://www.portainer.io/solutions/docker)
|
||||
- [Portainer CE for Kubernetes](https://www.portainer.io/solutions/kubernetes-ui)
|
||||
- [Portainer CE for Azure ACI](https://www.portainer.io/solutions/serverless-containers)
|
||||
|
||||
## Getting help
|
||||
|
||||
For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products/portainer-business
|
||||
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
|
||||
|
||||
For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/products/community-edition/customer-success
|
||||
Learn more about Portainers community support channels [here.](https://www.portainer.io/help_about)
|
||||
|
||||
- Issues: https://github.com/portainer/portainer/issues
|
||||
- FAQ: https://documentation.portainer.io
|
||||
- Slack (chat): https://portainer.io/slack/
|
||||
|
||||
You can join the Portainer Community by visiting community.portainer.io. This will give you advance notice of events, content and other related Portainer content.
|
||||
|
||||
## Reporting bugs and contributing
|
||||
|
||||
- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package migrator
|
||||
|
||||
func (m *Migrator) migrateDBVersionTo30() error {
|
||||
if err := m.migrateSettings(); err != nil {
|
||||
func (m *Migrator) migrateDBVersionToDB30() error {
|
||||
if err := m.migrateSettingsToDB30(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateSettings() error {
|
||||
func (m *Migrator) migrateSettingsToDB30() error {
|
||||
legacySettings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -76,7 +76,7 @@ func TestMigrateSettings(t *testing.T) {
|
||||
db: dbConn,
|
||||
settingsService: settingsService,
|
||||
}
|
||||
if err := m.migrateSettings(); err != nil {
|
||||
if err := m.migrateSettingsToDB30(); err != nil {
|
||||
t.Errorf("failed to update settings: %v", err)
|
||||
}
|
||||
updatedSettings, err := m.settingsService.Settings()
|
||||
@@ -1,11 +1,15 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
endpointutils "github.com/portainer/portainer/api/internal/endpoint"
|
||||
snapshotutils "github.com/portainer/portainer/api/internal/snapshot"
|
||||
)
|
||||
|
||||
func (m *Migrator) migrateDBVersionTo32() error {
|
||||
func (m *Migrator) migrateDBVersionToDB32() error {
|
||||
err := m.updateRegistriesToDB32()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -16,6 +20,10 @@ func (m *Migrator) migrateDBVersionTo32() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.updateVolumeResourceControlToDB32(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,3 +130,84 @@ func (m *Migrator) updateDockerhubToDB32() error {
|
||||
|
||||
return m.registryService.CreateRegistry(registry)
|
||||
}
|
||||
|
||||
func (m *Migrator) updateVolumeResourceControlToDB32() error {
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching endpoints: %w", err)
|
||||
}
|
||||
|
||||
resourceControls, err := m.resourceControlService.ResourceControls()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching resource controls: %w", err)
|
||||
}
|
||||
|
||||
toUpdate := map[portainer.ResourceControlID]string{}
|
||||
volumeResourceControls := map[string]*portainer.ResourceControl{}
|
||||
|
||||
for i := range resourceControls {
|
||||
resourceControl := resourceControls[i]
|
||||
if resourceControl.Type == portainer.VolumeResourceControl {
|
||||
volumeResourceControls[resourceControl.ResourceID] = &resourceControl
|
||||
}
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if !endpointutils.IsDockerEndpoint(&endpoint) {
|
||||
continue
|
||||
}
|
||||
|
||||
totalSnapshots := len(endpoint.Snapshots)
|
||||
if totalSnapshots == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshot := endpoint.Snapshots[totalSnapshots-1]
|
||||
|
||||
endpointDockerID, err := snapshotutils.FetchDockerID(snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching endpoint docker id: %w", err)
|
||||
}
|
||||
|
||||
if volumesData, done := snapshot.SnapshotRaw.Volumes.(map[string]interface{}); done {
|
||||
if volumesData["Volumes"] == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
findResourcesToUpdateForDB32(endpointDockerID, volumesData, toUpdate, volumeResourceControls)
|
||||
}
|
||||
}
|
||||
|
||||
for _, resourceControl := range volumeResourceControls {
|
||||
if newResourceID, ok := toUpdate[resourceControl.ID]; ok {
|
||||
resourceControl.ResourceID = newResourceID
|
||||
err := m.resourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed updating resource control %d: %w", resourceControl.ID, err)
|
||||
}
|
||||
|
||||
} else {
|
||||
err := m.resourceControlService.DeleteResourceControl(resourceControl.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed deleting resource control %d: %w", resourceControl.ID, err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interface{}, toUpdate map[portainer.ResourceControlID]string, volumeResourceControls map[string]*portainer.ResourceControl) {
|
||||
volumes := volumesData["Volumes"].([]interface{})
|
||||
for _, volumeMeta := range volumes {
|
||||
volume := volumeMeta.(map[string]interface{})
|
||||
volumeName := volume["Name"].(string)
|
||||
oldResourceID := fmt.Sprintf("%s%s", volumeName, volume["CreatedAt"].(string))
|
||||
resourceControl, ok := volumeResourceControls[oldResourceID]
|
||||
|
||||
if ok {
|
||||
toUpdate[resourceControl.ID] = fmt.Sprintf("%s_%s", volumeName, dockerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,7 @@ func (m *Migrator) Migrate() error {
|
||||
|
||||
// Portainer 2.6.0
|
||||
if m.currentDBVersion < 30 {
|
||||
err := m.migrateDBVersionTo30()
|
||||
err := m.migrateDBVersionToDB30()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -375,7 +375,7 @@ func (m *Migrator) Migrate() error {
|
||||
|
||||
// Portainer 2.9.0
|
||||
if m.currentDBVersion < 32 {
|
||||
err := m.migrateDBVersionTo32()
|
||||
err := m.migrateDBVersionToDB32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
19
api/cmd/portainer/log.go
Normal file
19
api/cmd/portainer/log.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func configureLogger() {
|
||||
logger := logrus.New() // logger is to implicitly substitute stdlib's log
|
||||
log.SetOutput(logger.Writer())
|
||||
|
||||
formatter := &logrus.TextFormatter{DisableTimestamp: true, DisableLevelTruncation: true}
|
||||
logger.SetFormatter(formatter)
|
||||
logrus.SetFormatter(formatter)
|
||||
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -77,20 +78,25 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port
|
||||
}
|
||||
|
||||
func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper := exec.NewComposeWrapper(assetsPath, dataStorePath, proxyManager)
|
||||
if composeWrapper != nil {
|
||||
return composeWrapper
|
||||
composeWrapper, err := exec.NewComposeStackManager(assetsPath, dataStorePath, proxyManager)
|
||||
if err != nil {
|
||||
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 libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
|
||||
return composeWrapper
|
||||
}
|
||||
|
||||
func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) {
|
||||
return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService)
|
||||
}
|
||||
|
||||
func initKubernetesDeployer(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(dataStore, reverseTunnelService, signatureService, assetsPath)
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, assetsPath)
|
||||
}
|
||||
|
||||
func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
|
||||
@@ -402,7 +408,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
|
||||
|
||||
if dataStore.IsNew() {
|
||||
err = updateSettingsFromFlags(dataStore, flags)
|
||||
@@ -497,6 +503,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
func main() {
|
||||
flags := initCLI()
|
||||
|
||||
configureLogger()
|
||||
|
||||
for {
|
||||
server := buildServer(flags)
|
||||
log.Printf("Starting Portainer %s on %s\n", portainer.APIVersion, *flags.Addr)
|
||||
|
||||
122
api/exec/compose_stack.go
Normal file
122
api/exec/compose_stack.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// ComposeStackManager is a wrapper for docker-compose binary
|
||||
type ComposeStackManager struct {
|
||||
wrapper *wrapper.ComposeWrapper
|
||||
configPath string
|
||||
proxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
|
||||
func NewComposeStackManager(binaryPath string, configPath string, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
|
||||
wrap, err := wrapper.NewComposeWrapper(binaryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ComposeStackManager{
|
||||
wrapper: wrap,
|
||||
proxyManager: proxyManager,
|
||||
configPath: configPath,
|
||||
}, 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
|
||||
}
|
||||
|
||||
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
|
||||
func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := w.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
envFilePath, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := w.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
filePath := stackFilePath(stack)
|
||||
|
||||
_, err = w.wrapper.Down([]string{filePath}, url, stack.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func stackFilePath(stack *portainer.Stack) string {
|
||||
return path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
}
|
||||
|
||||
func (w *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
|
||||
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", proxy.Port), proxy, nil
|
||||
}
|
||||
|
||||
func createEnvFile(stack *portainer.Stack) (string, error) {
|
||||
if stack.Env == nil || len(stack.Env) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
envFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
|
||||
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
return envFilePath, nil
|
||||
}
|
||||
@@ -33,7 +33,9 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
|
||||
Name: "project-name",
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{}
|
||||
endpoint := &portainer.Endpoint{
|
||||
URL: "unix://",
|
||||
}
|
||||
|
||||
return stack, endpoint
|
||||
}
|
||||
@@ -42,14 +44,17 @@ func Test_UpAndDown(t *testing.T) {
|
||||
|
||||
stack, endpoint := setup(t)
|
||||
|
||||
w := NewComposeWrapper("", "", nil)
|
||||
w, err := NewComposeStackManager("", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed creating manager: %s", err)
|
||||
}
|
||||
|
||||
err := w.Up(stack, endpoint)
|
||||
err = w.Up(stack, endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("Error calling docker-compose up: %s", err)
|
||||
}
|
||||
|
||||
if containerExists(composedContainerName) == false {
|
||||
if !containerExists(composedContainerName) {
|
||||
t.Fatal("container should exist")
|
||||
}
|
||||
|
||||
@@ -63,13 +68,13 @@ func Test_UpAndDown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func containerExists(contaierName string) bool {
|
||||
cmd := exec.Command(osProgram("docker"), "ps", "-a", "-f", fmt.Sprintf("name=%s", contaierName))
|
||||
func containerExists(containerName string) bool {
|
||||
cmd := exec.Command("docker", "ps", "-a", "-f", fmt.Sprintf("name=%s", containerName))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to list containers: %s", err)
|
||||
}
|
||||
|
||||
return strings.Contains(string(out), contaierName)
|
||||
return strings.Contains(string(out), containerName)
|
||||
}
|
||||
112
api/exec/compose_stack_test.go
Normal file
112
api/exec/compose_stack_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"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()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
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{
|
||||
ProjectPath: dir,
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack's env variables are empty",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "should add env file option if stack has env variables",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{
|
||||
{Name: "var1", Value: "value1"},
|
||||
{Name: "var2", Value: "value2"},
|
||||
},
|
||||
},
|
||||
expected: "var1=value1\nvar2=value2\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, _ := createEnvFile(tt.stack)
|
||||
|
||||
if tt.expected != "" {
|
||||
assert.Equal(t, path.Join(tt.stack.ProjectPath, "stack.env"), result)
|
||||
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
assert.Equal(t, tt.expected, string(content))
|
||||
} else {
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
)
|
||||
|
||||
// ComposeWrapper is a wrapper for docker-compose binary
|
||||
type ComposeWrapper struct {
|
||||
binaryPath string
|
||||
dataPath string
|
||||
proxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewComposeWrapper returns a docker-compose wrapper if corresponding binary present, otherwise nil
|
||||
func NewComposeWrapper(binaryPath, dataPath string, proxyManager *proxy.Manager) *ComposeWrapper {
|
||||
if !IsBinaryPresent(programPath(binaryPath, "docker-compose")) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ComposeWrapper{
|
||||
binaryPath: binaryPath,
|
||||
dataPath: dataPath,
|
||||
proxyManager: proxyManager,
|
||||
}
|
||||
}
|
||||
|
||||
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
|
||||
func (w *ComposeWrapper) ComposeSyntaxMaxVersion() string {
|
||||
return portainer.ComposeSyntaxMaxVersion
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (w *ComposeWrapper) NormalizeStackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
|
||||
func (w *ComposeWrapper) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
_, err := w.command([]string{"up", "-d"}, stack, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command
|
||||
func (w *ComposeWrapper) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
_, err := w.command([]string{"down", "--remove-orphans"}, stack, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *ComposeWrapper) command(command []string, stack *portainer.Stack, endpoint *portainer.Endpoint) ([]byte, error) {
|
||||
if endpoint == nil {
|
||||
return nil, errors.New("cannot call a compose command on an empty endpoint")
|
||||
}
|
||||
|
||||
program := programPath(w.binaryPath, "docker-compose")
|
||||
|
||||
options := setComposeFile(stack)
|
||||
|
||||
options = addProjectNameOption(options, stack)
|
||||
options, err := addEnvFileOption(options, stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !(endpoint.URL == "" || strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://")) {
|
||||
|
||||
proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer proxy.Close()
|
||||
|
||||
options = append(options, "-H", fmt.Sprintf("http://127.0.0.1:%d", proxy.Port))
|
||||
}
|
||||
|
||||
args := append(options, command...)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(program, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("DOCKER_CONFIG=%s", w.dataPath))
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return out, errors.New(stderr.String())
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func setComposeFile(stack *portainer.Stack) []string {
|
||||
options := make([]string, 0)
|
||||
|
||||
if stack == nil || stack.EntryPoint == "" {
|
||||
return options
|
||||
}
|
||||
|
||||
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
options = append(options, "-f", composeFilePath)
|
||||
return options
|
||||
}
|
||||
|
||||
func addProjectNameOption(options []string, stack *portainer.Stack) []string {
|
||||
if stack == nil || stack.Name == "" {
|
||||
return options
|
||||
}
|
||||
|
||||
options = append(options, "-p", stack.Name)
|
||||
return options
|
||||
}
|
||||
|
||||
func addEnvFileOption(options []string, stack *portainer.Stack) ([]string, error) {
|
||||
if stack == nil || stack.Env == nil || len(stack.Env) == 0 {
|
||||
return options, nil
|
||||
}
|
||||
|
||||
envFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
|
||||
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
options = append(options, "--env-file", envFilePath)
|
||||
return options, nil
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_setComposeFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "should return empty result if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should return empty result if stack don't have entrypoint",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should allow file name and dir",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: "dir",
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: []string{"-f", path.Join("dir", "file")},
|
||||
},
|
||||
{
|
||||
name: "should allow file name only",
|
||||
stack: &portainer.Stack{
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: []string{"-f", "file"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := setComposeFile(tt.stack)
|
||||
assert.ElementsMatch(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_addProjectNameOption(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "should not add project option if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add project option if stack doesn't have name",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should add project name option if stack has a name",
|
||||
stack: &portainer.Stack{
|
||||
Name: "project-name",
|
||||
},
|
||||
expected: []string{"-p", "project-name"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := []string{"-a", "b"}
|
||||
result := addProjectNameOption(options, tt.stack)
|
||||
assert.ElementsMatch(t, append(options, tt.expected...), result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_addEnvFileOption(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
expectedContent string
|
||||
}{
|
||||
{
|
||||
name: "should not add env file option if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack doesn't have env variables",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack's env variables are empty",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{},
|
||||
},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should add env file option if stack has env variables",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{
|
||||
{Name: "var1", Value: "value1"},
|
||||
{Name: "var2", Value: "value2"},
|
||||
},
|
||||
},
|
||||
expected: []string{"--env-file", path.Join(dir, "stack.env")},
|
||||
expectedContent: "var1=value1\nvar2=value2\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := []string{"-a", "b"}
|
||||
result, _ := addEnvFileOption(options, tt.stack)
|
||||
assert.ElementsMatch(t, append(options, tt.expected...), result)
|
||||
|
||||
if tt.expectedContent != "" {
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
assert.Equal(t, tt.expectedContent, string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -20,27 +23,64 @@ import (
|
||||
|
||||
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment.
|
||||
type KubernetesDeployer struct {
|
||||
binaryPath string
|
||||
dataStore portainer.DataStore
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
signatureService portainer.DigitalSignatureService
|
||||
binaryPath string
|
||||
dataStore portainer.DataStore
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
signatureService portainer.DigitalSignatureService
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
}
|
||||
|
||||
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
|
||||
func NewKubernetesDeployer(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
|
||||
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
|
||||
return &KubernetesDeployer{
|
||||
binaryPath: binaryPath,
|
||||
dataStore: datastore,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
signatureService: signatureService,
|
||||
binaryPath: binaryPath,
|
||||
dataStore: datastore,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
signatureService: signatureService,
|
||||
kubernetesClientFactory: kubernetesClientFactory,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
kubecli, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tokenCache := deployer.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID))
|
||||
|
||||
tokenManager, err := kubernetes.NewTokenManager(kubecli, deployer.dataStore, tokenCache, setLocalAdminToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return tokenManager.GetAdminServiceAccountToken(), nil
|
||||
}
|
||||
|
||||
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("can not get a valid user service account token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint.
|
||||
// Otherwise it will use kubectl to deploy the manifest.
|
||||
func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
|
||||
func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment {
|
||||
token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
token, err := deployer.getToken(request, endpoint, true);
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -53,7 +93,7 @@ func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackCo
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--server", endpoint.URL)
|
||||
args = append(args, "--insecure-skip-tls-verify")
|
||||
args = append(args, "--token", string(token))
|
||||
args = append(args, "--token", token)
|
||||
args = append(args, "--namespace", namespace)
|
||||
args = append(args, "apply", "-f", "-")
|
||||
|
||||
@@ -139,8 +179,14 @@ func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackCo
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := deployer.getToken(request, endpoint, false);
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey())
|
||||
req.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
req.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
resp, err := httpCli.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
@@ -184,3 +186,8 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (manager *SwarmStackManager) NormalizeStackName(name string) string {
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func osProgram(program string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
program += ".exe"
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
func programPath(rootPath, program string) string {
|
||||
return filepath.Join(rootPath, osProgram(program))
|
||||
}
|
||||
|
||||
// IsBinaryPresent returns true if corresponding program exists on PATH
|
||||
func IsBinaryPresent(program string) bool {
|
||||
_, err := exec.LookPath(program)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_isBinaryPresent(t *testing.T) {
|
||||
|
||||
if !IsBinaryPresent("docker") {
|
||||
t.Error("expect docker binary to exist on the path")
|
||||
}
|
||||
|
||||
if IsBinaryPresent("executable-with-this-name-should-not-exist") {
|
||||
t.Error("expect binary with a random name to be missing on the path")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package gittypes
|
||||
|
||||
type RepoConfig struct {
|
||||
URL string
|
||||
ReferenceName string
|
||||
ConfigFilePath string
|
||||
// The repo url
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -27,10 +27,12 @@ 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-20210527221011-0a1418224b92
|
||||
github.com/portainer/libcompose v0.5.3
|
||||
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
||||
github.com/stretchr/testify v1.6.1
|
||||
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
|
||||
|
||||
17
api/go.sum
17
api/go.sum
@@ -80,6 +80,7 @@ github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkg
|
||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
|
||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible h1:fUDGZCv/7iAN7u0puUVhvKCcsR6vRfwrJatElLBEf0I=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
@@ -153,6 +154,7 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
|
||||
@@ -184,7 +186,6 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c h1:N7A4JCA2G+j5fuFxCsJqjFU/sZe0mj8H0sSoSwbaikw=
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -219,8 +220,10 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420 h1:Yu3681ykYHDfLoI6XVjL4JWmkE+3TX9yfIWwRCh1kFM=
|
||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
|
||||
@@ -238,6 +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-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-20190723020515-23ebe86ab2c2 h1:0PfgGLys9yHr4rtnirg0W0Cjvv6/DzxBIZk5sV59208=
|
||||
@@ -261,8 +266,9 @@ github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDa
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
@@ -273,8 +279,8 @@ github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRci
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
|
||||
github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ=
|
||||
@@ -366,9 +372,11 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
@@ -395,6 +403,7 @@ k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUc
|
||||
k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
|
||||
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
|
||||
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a h1:UcxjrRMyNx/i/y8G7kPvLyy7rfbeuf1PYyBf973pgyU=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f h1:GiPwtSzdP43eI1hpPCbROQCCIgCuiMMNF8YUVLF3vJo=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
|
||||
|
||||
56
api/http/handler/endpoints/endpoint_association_delete.go
Normal file
56
api/http/handler/endpoints/endpoint_association_delete.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// @id EndpointAssociationDelete
|
||||
// @summary De-association an edge endpoint
|
||||
// @description De-association an edge endpoint.
|
||||
// @description **Access policy**: administrator
|
||||
// @security jwt
|
||||
// @tags endpoints
|
||||
// @produce json
|
||||
// @param id path int true "Endpoint identifier"
|
||||
// @success 200 {object} portainer.Endpoint "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 404 "Endpoint not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /api/endpoints/:id/association [put]
|
||||
func (handler *Handler) endpointAssociationDelete(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}
|
||||
}
|
||||
|
||||
if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint type", errors.New("Invalid endpoint type")}
|
||||
}
|
||||
|
||||
endpoint.EdgeID = ""
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting endpoint in database", err}
|
||||
}
|
||||
|
||||
handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID)
|
||||
|
||||
return response.JSON(w, endpoint)
|
||||
}
|
||||
@@ -132,8 +132,18 @@ func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhub
|
||||
return nil, errors.New("failed fetching dockerhub limits")
|
||||
}
|
||||
|
||||
// An error with rateLimit-Limit or RateLimit-Remaining is likely for dockerhub pro accounts where there is no rate limit.
|
||||
// In that specific case the headers will not be present. Don't bubble up the error as its normal
|
||||
// See: https://docs.docker.com/docker-hub/download-rate-limit/
|
||||
rateLimit, err := parseRateLimitHeader(resp.Header, "RateLimit-Limit")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rateLimitRemaining, err := parseRateLimitHeader(resp.Header, "RateLimit-Remaining")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &dockerhubStatusResponse{
|
||||
Limit: rateLimit,
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// @param search query string false "Search query"
|
||||
// @param groupId query int false "List endpoints of this group"
|
||||
// @param limit query int false "Limit results to this value"
|
||||
// @param type query int false "List endpoints of this type"
|
||||
// @param types query []int false "List endpoints of this type"
|
||||
// @param tagIds query []int false "search endpoints with these tags (depends on tagsPartialMatch)"
|
||||
// @param tagsPartialMatch query bool false "If true, will return endpoint which has one of tagIds, if false (or missing) will return only endpoints that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these endpoints"
|
||||
@@ -46,7 +46,9 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
endpointType, _ := request.RetrieveNumericQueryParameter(r, "type", true)
|
||||
|
||||
var endpointTypes []int
|
||||
request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true)
|
||||
|
||||
var tagIDs []portainer.TagID
|
||||
request.RetrieveJSONQueryParameter(r, "tagIds", &tagIDs, true)
|
||||
@@ -98,8 +100,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, endpointGroups, tagsMap, search)
|
||||
}
|
||||
|
||||
if endpointType != 0 {
|
||||
filteredEndpoints = filterEndpointsByType(filteredEndpoints, portainer.EndpointType(endpointType))
|
||||
if endpointTypes != nil {
|
||||
filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, endpointTypes)
|
||||
}
|
||||
|
||||
if tagIDs != nil {
|
||||
@@ -212,11 +214,16 @@ func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGrou
|
||||
return false
|
||||
}
|
||||
|
||||
func filterEndpointsByType(endpoints []portainer.Endpoint, endpointType portainer.EndpointType) []portainer.Endpoint {
|
||||
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
typeSet := map[portainer.EndpointType]bool{}
|
||||
for _, endpointType := range endpointTypes {
|
||||
typeSet[portainer.EndpointType(endpointType)] = true
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Type == endpointType {
|
||||
if typeSet[endpoint.Type] {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,11 +151,17 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
updateAuthorizations := false
|
||||
|
||||
if payload.Kubernetes != nil {
|
||||
if payload.Kubernetes.Configuration.RestrictDefaultNamespace !=
|
||||
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
|
||||
updateAuthorizations = true
|
||||
}
|
||||
|
||||
endpoint.Kubernetes = *payload.Kubernetes
|
||||
}
|
||||
|
||||
updateAuthorizations := false
|
||||
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) {
|
||||
updateAuthorizations = true
|
||||
endpoint.UserAccessPolicies = payload.UserAccessPolicies
|
||||
|
||||
@@ -45,6 +45,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints/{id}/settings",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSettingsUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/endpoints/{id}/association",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointAssociationDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/endpoints/snapshot",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints",
|
||||
|
||||
@@ -95,7 +95,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
output, err := handler.deployKubernetesStack(endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
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}
|
||||
}
|
||||
@@ -139,7 +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(endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
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}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (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()
|
||||
|
||||
@@ -167,7 +167,7 @@ func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stac
|
||||
stackConfig = string(convertedConfig)
|
||||
}
|
||||
|
||||
return handler.KubernetesDeployer.Deploy(endpoint, stackConfig, namespace)
|
||||
return handler.KubernetesDeployer.Deploy(request, endpoint, stackConfig, namespace)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
|
||||
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{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
@@ -148,6 +150,8 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||
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{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
@@ -244,6 +248,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
|
||||
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{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id} [delete]
|
||||
func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/start [post]
|
||||
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/stop [post]
|
||||
func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
@@ -61,7 +61,7 @@ func (payload *updateSwarmStackPayload) Validate(r *http.Request) error {
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id} [put]
|
||||
func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
@@ -160,6 +160,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
err = handler.deployComposeStack(config)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,7 +33,23 @@ func (payload *updateStackGitPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PUT request on /api/stacks/:id/git?endpointId=<endpointId>
|
||||
// @id StackUpdateGit
|
||||
// @summary Redeploy a stack
|
||||
// @description Pull and redeploy a stack via Git
|
||||
// @description **Access policy**: restricted
|
||||
// @tags stacks
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Stack identifier"
|
||||
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this optional parameter to set the endpoint identifier used by the stack."
|
||||
// @param body body updateStackGitPayload true "Git configs for pull and redeploy a stack"
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/git [put]
|
||||
func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
@@ -12,20 +13,22 @@ import (
|
||||
// Handler is the HTTP handler used to handle websocket operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore portainer.DataStore
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
requestBouncer *security.RequestBouncer
|
||||
connectionUpgrader websocket.Upgrader
|
||||
DataStore portainer.DataStore
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
requestBouncer *security.RequestBouncer
|
||||
connectionUpgrader websocket.Upgrader
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage websocket operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
func NewHandler(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
connectionUpgrader: websocket.Upgrader{},
|
||||
requestBouncer: bouncer,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
}
|
||||
h.PathPrefix("/websocket/exec").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec)))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
)
|
||||
|
||||
// @summary Execute a websocket on pod
|
||||
@@ -70,8 +73,14 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
token, useAdminToken, err := handler.getToken(r, endpoint, false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user service account token", err}
|
||||
}
|
||||
|
||||
params := &webSocketRequestParams{
|
||||
endpoint: endpoint,
|
||||
token: token,
|
||||
}
|
||||
|
||||
r.Header.Del("Origin")
|
||||
@@ -112,7 +121,7 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
|
||||
}
|
||||
|
||||
err = cli.StartExecProcess(namespace, podName, containerName, commandArray, stdinReader, stdoutWriter)
|
||||
err = cli.StartExecProcess(token, useAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err}
|
||||
}
|
||||
@@ -124,3 +133,37 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
kubecli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
tokenCache := handler.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID))
|
||||
|
||||
tokenManager, err := kubernetes.NewTokenManager(kubecli, handler.DataStore, tokenCache, setLocalAdminToken)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return tokenManager.GetAdminServiceAccountToken(), true, nil
|
||||
}
|
||||
|
||||
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", false, fmt.Errorf("can not get a valid user service account token")
|
||||
}
|
||||
|
||||
return token, false, nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r
|
||||
|
||||
proxy.Director = func(incoming *http.Request, out http.Header) {
|
||||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
|
||||
}
|
||||
|
||||
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
|
||||
@@ -64,6 +65,7 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
|
||||
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
|
||||
out.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
|
||||
@@ -8,4 +8,5 @@ type webSocketRequestParams struct {
|
||||
ID string
|
||||
nodeName string
|
||||
endpoint *portainer.Endpoint
|
||||
token string
|
||||
}
|
||||
|
||||
@@ -161,9 +161,7 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response,
|
||||
|
||||
volumeName := volumeIDParameter[0]
|
||||
|
||||
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
|
||||
resourceID, err := transport.getVolumeResourceID(agentTargetHeader, volumeName)
|
||||
resourceID, err := transport.getVolumeResourceID(volumeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -300,7 +298,7 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re
|
||||
func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/volumes/create":
|
||||
return transport.decorateVolumeResourceCreationOperation(request, volumeObjectIdentifier, portainer.VolumeResourceControl)
|
||||
return transport.decorateVolumeResourceCreationOperation(request, portainer.VolumeResourceControl)
|
||||
|
||||
case "/volumes/prune":
|
||||
return transport.administratorOperation(request)
|
||||
|
||||
@@ -3,6 +3,7 @@ package docker
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
@@ -12,10 +13,11 @@ import (
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
)
|
||||
|
||||
const (
|
||||
volumeObjectIdentifier = "ID"
|
||||
volumeObjectIdentifier = "ResourceID"
|
||||
)
|
||||
|
||||
func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, endpointID portainer.EndpointID, volumeID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
||||
@@ -48,10 +50,12 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
|
||||
for _, volumeObject := range volumeData {
|
||||
volume := volumeObject.(map[string]interface{})
|
||||
if volume["Name"] == nil || volume["CreatedAt"] == nil {
|
||||
return errors.New("missing identifier in Docker resource list response")
|
||||
|
||||
err = transport.decorateVolumeResponseWithResourceID(volume)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed decorating volume response: %w", err)
|
||||
}
|
||||
volume[volumeObjectIdentifier] = volume["Name"].(string) + volume["CreatedAt"].(string)
|
||||
|
||||
}
|
||||
|
||||
resourceOperationParameters := &resourceOperationParameters{
|
||||
@@ -81,10 +85,10 @@ func (transport *Transport) volumeInspectOperation(response *http.Response, exec
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject["Name"] == nil || responseObject["CreatedAt"] == nil {
|
||||
return errors.New("missing identifier in Docker resource detail response")
|
||||
err = transport.decorateVolumeResponseWithResourceID(responseObject)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed decorating volume response: %w", err)
|
||||
}
|
||||
responseObject[volumeObjectIdentifier] = responseObject["Name"].(string) + responseObject["CreatedAt"].(string)
|
||||
|
||||
resourceOperationParameters := &resourceOperationParameters{
|
||||
resourceIdentifierAttribute: volumeObjectIdentifier,
|
||||
@@ -95,6 +99,21 @@ func (transport *Transport) volumeInspectOperation(response *http.Response, exec
|
||||
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateVolumeResponseWithResourceID(responseObject map[string]interface{}) error {
|
||||
if responseObject["Name"] == nil {
|
||||
return errors.New("missing identifier in Docker resource detail response")
|
||||
}
|
||||
|
||||
resourceID, err := transport.getVolumeResourceID(responseObject["Name"].(string))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching resource id: %w", err)
|
||||
}
|
||||
|
||||
responseObject[volumeObjectIdentifier] = resourceID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectorVolumeLabels retrieve the labels object associated to the volume object.
|
||||
// Labels are available under the "Labels" property.
|
||||
// API schema references:
|
||||
@@ -104,7 +123,7 @@ func selectorVolumeLabels(responseObject map[string]interface{}) map[string]inte
|
||||
return utils.GetJSONObject(responseObject, "Labels")
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -136,27 +155,33 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusCreated {
|
||||
err = transport.decorateVolumeCreationResponse(response, resourceIdentifierAttribute, resourceType, tokenData.ID)
|
||||
err = transport.decorateVolumeCreationResponse(response, resourceType, tokenData.ID)
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
|
||||
func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject["Name"] == nil || responseObject["CreatedAt"] == nil {
|
||||
if responseObject["Name"] == nil {
|
||||
return errors.New("missing identifier in Docker resource creation response")
|
||||
}
|
||||
resourceID := responseObject["Name"].(string) + responseObject["CreatedAt"].(string)
|
||||
|
||||
resourceID, err := transport.getVolumeResourceID(responseObject["Name"].(string))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching resource id: %w", err)
|
||||
}
|
||||
|
||||
resourceControl, err := transport.createPrivateResourceControl(resourceID, resourceType, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
responseObject[volumeObjectIdentifier] = resourceID
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
@@ -169,9 +194,8 @@ func (transport *Transport) restrictedVolumeOperation(requestPath string, reques
|
||||
}
|
||||
|
||||
volumeName := path.Base(requestPath)
|
||||
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
|
||||
resourceID, err := transport.getVolumeResourceID(agentTargetHeader, volumeName)
|
||||
resourceID, err := transport.getVolumeResourceID(volumeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -182,17 +206,34 @@ func (transport *Transport) restrictedVolumeOperation(requestPath string, reques
|
||||
return transport.restrictedResourceOperation(request, resourceID, volumeName, portainer.VolumeResourceControl, false)
|
||||
}
|
||||
|
||||
func (transport *Transport) getVolumeResourceID(nodename, volumeID string) (string, error) {
|
||||
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodename)
|
||||
func (transport *Transport) getVolumeResourceID(volumeName string) (string, error) {
|
||||
dockerID, err := transport.getDockerID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed fetching docker id: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%s_%s", volumeName, dockerID), nil
|
||||
}
|
||||
|
||||
func (transport *Transport) getDockerID() (string, error) {
|
||||
if len(transport.endpoint.Snapshots) > 0 {
|
||||
dockerID, err := snapshot.FetchDockerID(transport.endpoint.Snapshots[0])
|
||||
// ignore err - in case of error, just generate not from snapshot
|
||||
if err == nil {
|
||||
return dockerID, nil
|
||||
}
|
||||
}
|
||||
|
||||
cli := transport.dockerClient
|
||||
defer cli.Close()
|
||||
|
||||
volume, err := cli.VolumeInspect(context.Background(), volumeID)
|
||||
info, err := cli.Info(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return volume.Name + volume.CreatedAt, nil
|
||||
if info.Swarm.Cluster != nil {
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
}
|
||||
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
@@ -49,13 +49,18 @@ func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endp
|
||||
proxy.Transport = dockercompose.NewAgentTransport(factory.signatureService, httpTransport)
|
||||
|
||||
proxyServer := &ProxyServer{
|
||||
&http.Server{
|
||||
server: &http.Server{
|
||||
Handler: proxy,
|
||||
},
|
||||
0,
|
||||
Port: 0,
|
||||
}
|
||||
|
||||
return proxyServer, proxyServer.start()
|
||||
err = proxyServer.start()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proxyServer, err
|
||||
}
|
||||
|
||||
func (proxy *ProxyServer) start() error {
|
||||
@@ -72,7 +77,7 @@ func (proxy *ProxyServer) start() error {
|
||||
err := proxy.server.Serve(listener)
|
||||
log.Printf("Exiting Proxy server %s\n", proxyHost)
|
||||
|
||||
if err != http.ErrServerClosed {
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("Proxy server %s exited with an error: %s\n", proxyHost, err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -34,7 +34,7 @@ func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsCo
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager)
|
||||
token, err := transport.getRoundTripToken(request, transport.tokenManager)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpo
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager)
|
||||
token, err := transport.getRoundTripToken(request, transport.tokenManager)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ package kubernetes
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func (transport *baseTransport) proxyNamespaceDeleteOperation(request *http.Request, namespace string) (*http.Response, error) {
|
||||
if err := transport.tokenManager.kubecli.NamespaceAccessPoliciesDeleteNamespace(namespace); err != nil {
|
||||
return nil, errors.WithMessagef(err, "failed to delete a namespace [%s] from portainer config", namespace)
|
||||
}
|
||||
|
||||
registries, err := transport.dataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
@@ -13,7 +11,6 @@ type tokenManager struct {
|
||||
tokenCache *tokenCache
|
||||
kubecli portainer.KubeClient
|
||||
dataStore portainer.DataStore
|
||||
mutex sync.Mutex
|
||||
adminToken string
|
||||
}
|
||||
|
||||
@@ -25,7 +22,6 @@ func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore
|
||||
tokenCache: cache,
|
||||
kubecli: kubecli,
|
||||
dataStore: dataStore,
|
||||
mutex: sync.Mutex{},
|
||||
adminToken: "",
|
||||
}
|
||||
|
||||
@@ -41,13 +37,13 @@ func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore
|
||||
return tokenManager, nil
|
||||
}
|
||||
|
||||
func (manager *tokenManager) getAdminServiceAccountToken() string {
|
||||
func (manager *tokenManager) GetAdminServiceAccountToken() string {
|
||||
return manager.adminToken
|
||||
}
|
||||
|
||||
func (manager *tokenManager) getUserServiceAccountToken(userID int) (string, error) {
|
||||
manager.mutex.Lock()
|
||||
defer manager.mutex.Unlock()
|
||||
func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) {
|
||||
manager.tokenCache.mutex.Lock()
|
||||
defer manager.tokenCache.mutex.Unlock()
|
||||
|
||||
token, ok := manager.tokenCache.getToken(userID)
|
||||
if !ok {
|
||||
@@ -61,7 +57,13 @@ func (manager *tokenManager) getUserServiceAccountToken(userID int) (string, err
|
||||
teamIds = append(teamIds, int(membership.TeamID))
|
||||
}
|
||||
|
||||
err = manager.kubecli.SetupUserServiceAccount(userID, teamIds)
|
||||
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
|
||||
err = manager.kubecli.SetupUserServiceAccount(userID, teamIds, restrictDefaultNamespace)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/orcaman/concurrent-map"
|
||||
)
|
||||
@@ -14,6 +15,7 @@ type (
|
||||
|
||||
tokenCache struct {
|
||||
userTokenCache cmap.ConcurrentMap
|
||||
mutex sync.Mutex
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,6 +37,18 @@ func (manager *TokenCacheManager) CreateTokenCache(endpointID int) *tokenCache {
|
||||
return tokenCache
|
||||
}
|
||||
|
||||
// GetOrCreateTokenCache will get the tokenCache from the manager map of caches if it exists,
|
||||
// otherwise it will create a new tokenCache object, associate it to the manager map of caches
|
||||
// and return a pointer to that tokenCache instance.
|
||||
func (manager *TokenCacheManager) GetOrCreateTokenCache(endpointID int) *tokenCache {
|
||||
key := strconv.Itoa(endpointID)
|
||||
if epCache, ok := manager.tokenCaches.Get(key); ok {
|
||||
return epCache.(*tokenCache)
|
||||
}
|
||||
|
||||
return manager.CreateTokenCache(endpointID)
|
||||
}
|
||||
|
||||
// RemoveUserFromCache will ensure that the specific userID is removed from all registered caches.
|
||||
func (manager *TokenCacheManager) RemoveUserFromCache(userID int) {
|
||||
for cache := range manager.tokenCaches.IterBuffered() {
|
||||
@@ -45,6 +59,7 @@ func (manager *TokenCacheManager) RemoveUserFromCache(userID int) {
|
||||
func newTokenCache() *tokenCache {
|
||||
return &tokenCache{
|
||||
userTokenCache: cmap.New(),
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ func (transport *baseTransport) executeKubernetesRequest(request *http.Request)
|
||||
// #region ROUND TRIP
|
||||
|
||||
func (transport *baseTransport) prepareRoundTrip(request *http.Request) (string, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager)
|
||||
token, err := transport.getRoundTripToken(request, transport.tokenManager)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response
|
||||
return transport.proxyKubernetesRequest(request)
|
||||
}
|
||||
|
||||
func getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) {
|
||||
func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -110,9 +110,9 @@ func getRoundTripToken(request *http.Request, tokenManager *tokenManager) (strin
|
||||
|
||||
var token string
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
token = tokenManager.getAdminServiceAccountToken()
|
||||
token = tokenManager.GetAdminServiceAccountToken()
|
||||
} else {
|
||||
token, err = tokenManager.getUserServiceAccountToken(int(tokenData.ID))
|
||||
token, err = tokenManager.GetUserServiceAccountToken(int(tokenData.ID), transport.endpoint.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed retrieving service account token: %v", err)
|
||||
return "", err
|
||||
|
||||
@@ -202,7 +202,7 @@ func (server *Server) Start() error {
|
||||
userHandler.DataStore = server.DataStore
|
||||
userHandler.CryptoService = server.CryptoService
|
||||
|
||||
var websocketHandler = websocket.NewHandler(requestBouncer)
|
||||
var websocketHandler = websocket.NewHandler(server.KubernetesTokenCacheManager, requestBouncer)
|
||||
websocketHandler.DataStore = server.DataStore
|
||||
websocketHandler.SignatureService = server.SignatureService
|
||||
websocketHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
|
||||
@@ -2,6 +2,7 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
@@ -187,3 +188,27 @@ func (service *Service) snapshotEndpoints() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchDockerID fetches info.Swarm.Cluster.ID if endpoint is swarm and info.ID otherwise
|
||||
func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
|
||||
info, done := snapshot.SnapshotRaw.Info.(map[string]interface{})
|
||||
if !done {
|
||||
return "", errors.New("failed getting snapshot info")
|
||||
}
|
||||
|
||||
if !snapshot.Swarm {
|
||||
return info["ID"].(string), nil
|
||||
}
|
||||
|
||||
if info["Swarm"] == nil {
|
||||
return "", errors.New("swarm endpoint is missing swarm info snapshot")
|
||||
}
|
||||
|
||||
swarmInfo := info["Swarm"].(map[string]interface{})
|
||||
if swarmInfo["Cluster"] == nil {
|
||||
return "", errors.New("swarm endpoint is missing cluster info snapshot")
|
||||
}
|
||||
|
||||
clusterInfo := swarmInfo["Cluster"].(map[string]interface{})
|
||||
return clusterInfo["ID"].(string), nil
|
||||
}
|
||||
|
||||
@@ -3,20 +3,30 @@ package cli
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type (
|
||||
namespaceAccessPolicies map[string]portainer.K8sNamespaceAccessPolicy
|
||||
)
|
||||
// NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace
|
||||
func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error {
|
||||
kcl.lock.Lock()
|
||||
defer kcl.lock.Unlock()
|
||||
|
||||
policies, err := kcl.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "failed to fetch access policies")
|
||||
}
|
||||
|
||||
delete(policies, ns)
|
||||
|
||||
return kcl.UpdateNamespaceAccessPolicies(policies)
|
||||
}
|
||||
|
||||
// GetNamespaceAccessPolicies gets the namespace access policies
|
||||
// from config maps in the portainer namespace
|
||||
func (kcl *KubeClient) GetNamespaceAccessPolicies() (
|
||||
map[string]portainer.K8sNamespaceAccessPolicy, error,
|
||||
) {
|
||||
func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) {
|
||||
configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{})
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return nil, nil
|
||||
@@ -34,18 +44,8 @@ func (kcl *KubeClient) GetNamespaceAccessPolicies() (
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string) error {
|
||||
configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{})
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accessData := configMap.Data[portainerConfigMapAccessPoliciesKey]
|
||||
|
||||
var accessPolicies namespaceAccessPolicies
|
||||
err = json.Unmarshal([]byte(accessData), &accessPolicies)
|
||||
func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string, restrictDefaultNamespace bool) error {
|
||||
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -56,20 +56,16 @@ func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, service
|
||||
}
|
||||
|
||||
for _, namespace := range namespaces.Items {
|
||||
if namespace.Name == defaultNamespace {
|
||||
continue
|
||||
}
|
||||
|
||||
policies, ok := accessPolicies[namespace.Name]
|
||||
if !ok {
|
||||
err = kcl.removeNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name)
|
||||
if namespace.Name == defaultNamespace && !restrictDefaultNamespace {
|
||||
err = kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, defaultNamespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasUserAccessToNamespace(userID, teamIDs, policies) {
|
||||
policies, ok := accessPolicies[namespace.Name]
|
||||
if !ok || !hasUserAccessToNamespace(userID, teamIDs, policies) {
|
||||
err = kcl.removeNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
68
api/kubernetes/cli/access_test.go
Normal file
68
api/kubernetes/cli/access_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
ktypes "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConfigExists(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
namespaceToDelete string
|
||||
expectedConfig map[string]portainer.K8sNamespaceAccessPolicy
|
||||
}{
|
||||
{
|
||||
name: "doesn't change config, when designated namespace absent",
|
||||
namespaceToDelete: "missing-namespace",
|
||||
expectedConfig: map[string]portainer.K8sNamespaceAccessPolicy{
|
||||
"ns1": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
"ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "removes designated namespace from config, when namespace is present",
|
||||
namespaceToDelete: "ns2",
|
||||
expectedConfig: map[string]portainer.K8sNamespaceAccessPolicy{
|
||||
"ns1": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
k := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
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}}}}`,
|
||||
},
|
||||
}
|
||||
_, err := k.cli.CoreV1().ConfigMaps(portainerNamespace).Create(config)
|
||||
assert.NoError(t, err, "failed to create a portainer config")
|
||||
defer func() {
|
||||
k.cli.CoreV1().ConfigMaps(portainerNamespace).Delete(portainerConfigMapName, nil)
|
||||
}()
|
||||
|
||||
err = k.NamespaceAccessPoliciesDeleteNamespace(test.namespaceToDelete)
|
||||
assert.NoError(t, err, "failed to delete namespace")
|
||||
|
||||
policies, err := k.GetNamespaceAccessPolicies()
|
||||
assert.NoError(t, err, "failed to fetch policies")
|
||||
assert.Equal(t, test.expectedConfig, policies)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cmap "github.com/orcaman/concurrent-map"
|
||||
@@ -27,8 +28,9 @@ type (
|
||||
|
||||
// KubeClient represent a service used to execute Kubernetes operations
|
||||
KubeClient struct {
|
||||
cli *kubernetes.Clientset
|
||||
cli kubernetes.Interface
|
||||
instanceID string
|
||||
lock *sync.Mutex
|
||||
}
|
||||
)
|
||||
|
||||
@@ -75,6 +77,7 @@ func (factory *ClientFactory) createKubeClient(endpoint *portainer.Endpoint) (po
|
||||
kubecli := &KubeClient{
|
||||
cli: cli,
|
||||
instanceID: factory.instanceID,
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
return kubecli, nil
|
||||
|
||||
@@ -14,13 +14,18 @@ import (
|
||||
// StartExecProcess will start an exec process inside a container located inside a pod inside a specific namespace
|
||||
// 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.
|
||||
func (kcl *KubeClient) StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error {
|
||||
// This function only works against a local endpoint using an in-cluster config with the user's SA token.
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
if !useAdminToken {
|
||||
config.BearerToken = token
|
||||
config.BearerTokenFile = ""
|
||||
}
|
||||
|
||||
req := kcl.cli.CoreV1().RESTClient().
|
||||
Post().
|
||||
Resource("pods").
|
||||
|
||||
@@ -17,7 +17,7 @@ func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error)
|
||||
// SetupUserServiceAccount will make sure that all the required resources are created inside the Kubernetes
|
||||
// cluster before creating a ServiceAccount and a ServiceAccountToken for the specified Portainer user.
|
||||
//It will also create required default RoleBinding and ClusterRoleBinding rules.
|
||||
func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int) error {
|
||||
func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error {
|
||||
serviceAccountName := userServiceAccountName(userID, kcl.instanceID)
|
||||
|
||||
err := kcl.ensureRequiredResourcesExist()
|
||||
@@ -25,20 +25,7 @@ func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int) error
|
||||
return err
|
||||
}
|
||||
|
||||
err = kcl.ensureServiceAccountForUserExists(serviceAccountName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return kcl.setupNamespaceAccesses(userID, teamIDs, serviceAccountName)
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) ensureRequiredResourcesExist() error {
|
||||
return kcl.createPortainerUserClusterRole()
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) ensureServiceAccountForUserExists(serviceAccountName string) error {
|
||||
err := kcl.createUserServiceAccount(portainerNamespace, serviceAccountName)
|
||||
err = kcl.createUserServiceAccount(portainerNamespace, serviceAccountName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -53,7 +40,11 @@ func (kcl *KubeClient) ensureServiceAccountForUserExists(serviceAccountName stri
|
||||
return err
|
||||
}
|
||||
|
||||
return kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, defaultNamespace)
|
||||
return kcl.setupNamespaceAccesses(userID, teamIDs, serviceAccountName, restrictDefaultNamespace)
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) ensureRequiredResourcesExist() error {
|
||||
return kcl.createPortainerUserClusterRole()
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error {
|
||||
|
||||
@@ -2,6 +2,7 @@ package portainer
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
@@ -414,10 +415,11 @@ type (
|
||||
|
||||
// KubernetesConfiguration represents the configuration of a Kubernetes endpoint
|
||||
KubernetesConfiguration struct {
|
||||
UseLoadBalancer bool `json:"UseLoadBalancer"`
|
||||
UseServerMetrics bool `json:"UseServerMetrics"`
|
||||
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
|
||||
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
|
||||
UseLoadBalancer bool `json:"UseLoadBalancer"`
|
||||
UseServerMetrics bool `json:"UseServerMetrics"`
|
||||
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
|
||||
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
|
||||
RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"`
|
||||
}
|
||||
|
||||
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration
|
||||
@@ -1170,9 +1172,10 @@ type (
|
||||
|
||||
// KubeClient represents a service used to query a Kubernetes environment
|
||||
KubeClient interface {
|
||||
SetupUserServiceAccount(userID int, teamIDs []int) error
|
||||
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
|
||||
GetServiceAccountBearerToken(userID int) (string, error)
|
||||
StartExecProcess(namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error
|
||||
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error
|
||||
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
|
||||
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
|
||||
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
|
||||
DeleteRegistrySecret(registry *Registry, namespace string) error
|
||||
@@ -1182,7 +1185,7 @@ type (
|
||||
|
||||
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint
|
||||
KubernetesDeployer interface {
|
||||
Deploy(endpoint *Endpoint, data string, namespace string) (string, error)
|
||||
Deploy(request *http.Request, endpoint *Endpoint, data string, namespace string) (string, error)
|
||||
ConvertCompose(data string) ([]byte, error)
|
||||
}
|
||||
|
||||
@@ -1279,6 +1282,7 @@ type (
|
||||
Logout(endpoint *Endpoint) error
|
||||
Deploy(stack *Stack, prune bool, endpoint *Endpoint) error
|
||||
Remove(stack *Stack, endpoint *Endpoint) error
|
||||
NormalizeStackName(name string) string
|
||||
}
|
||||
|
||||
// TagService represents a service for managing tag data
|
||||
|
||||
@@ -1003,6 +1003,8 @@ definitions:
|
||||
type: boolean
|
||||
UseServerMetrics:
|
||||
type: boolean
|
||||
RestrictDefaultNamespace:
|
||||
type: boolean
|
||||
type: object
|
||||
portainer.KubernetesData:
|
||||
properties:
|
||||
|
||||
BIN
app/assets/images/portainer-github-banner.png
Normal file
BIN
app/assets/images/portainer-github-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 230 KiB |
@@ -262,7 +262,7 @@
|
||||
<td ng-show="$ctrl.columnVisibility.columns.ports.display">
|
||||
<a
|
||||
ng-if="item.Ports.length > 0"
|
||||
ng-repeat="p in item.Ports"
|
||||
ng-repeat="p in item.Ports | unique: 'public'"
|
||||
class="image-tag"
|
||||
ng-href="http://{{ $ctrl.state.publicURL || p.host }}:{{ p.public }}"
|
||||
target="_blank"
|
||||
|
||||
@@ -32,7 +32,7 @@ export default class porImageRegistryContainerController {
|
||||
|
||||
try {
|
||||
this.pullRateLimits = await this.DockerHubService.checkRateLimits(this.endpoint, this.registryId || 0);
|
||||
this.setValidity(this.pullRateLimits.remaining >= 0);
|
||||
this.setValidity(!this.pullRateLimits.limit || (this.pullRateLimits.limit && this.pullRateLimits.remaining >= 0));
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed loading DockerHub pull rate limits', e);
|
||||
|
||||
@@ -322,4 +322,7 @@ angular
|
||||
}
|
||||
return fullName.substring(0, versionIdx);
|
||||
};
|
||||
})
|
||||
.filter('unique', function () {
|
||||
return _.uniqBy;
|
||||
});
|
||||
|
||||
@@ -53,33 +53,43 @@ function ImageHelperFactory() {
|
||||
*/
|
||||
export function buildImageFullURI(imageModel) {
|
||||
if (!imageModel.UseRegistry) {
|
||||
return imageModel.Image;
|
||||
return ensureTag(imageModel.Image);
|
||||
}
|
||||
|
||||
let fullImageName = '';
|
||||
const imageName = buildImageFullURIWithRegistry(imageModel);
|
||||
|
||||
return ensureTag(imageName);
|
||||
|
||||
function ensureTag(image, defaultTag = 'latest') {
|
||||
return image.includes(':') ? image : `${image}:${defaultTag}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildImageFullURIWithRegistry(imageModel) {
|
||||
switch (imageModel.Registry.Type) {
|
||||
case RegistryTypes.GITLAB:
|
||||
fullImageName = imageModel.Registry.URL + '/' + imageModel.Registry.Gitlab.ProjectPath + (imageModel.Image.startsWith(':') ? '' : '/') + imageModel.Image;
|
||||
break;
|
||||
case RegistryTypes.ANONYMOUS:
|
||||
fullImageName = imageModel.Image;
|
||||
break;
|
||||
return buildImageURIForGitLab(imageModel);
|
||||
case RegistryTypes.QUAY:
|
||||
fullImageName =
|
||||
(imageModel.Registry.URL ? imageModel.Registry.URL + '/' : '') +
|
||||
(imageModel.Registry.Quay.UseOrganisation ? imageModel.Registry.Quay.OrganisationName : imageModel.Registry.Username) +
|
||||
'/' +
|
||||
imageModel.Image;
|
||||
break;
|
||||
return buildImageURIForQuay(imageModel);
|
||||
case RegistryTypes.ANONYMOUS:
|
||||
return imageModel.Image;
|
||||
default:
|
||||
fullImageName = imageModel.Registry.URL + '/' + imageModel.Image;
|
||||
break;
|
||||
return buildImageURIForOtherRegistry(imageModel);
|
||||
}
|
||||
|
||||
if (!imageModel.Image.includes(':')) {
|
||||
fullImageName += ':latest';
|
||||
function buildImageURIForGitLab(imageModel) {
|
||||
const slash = imageModel.Image.startsWith(':') ? '' : '/';
|
||||
return `${imageModel.Registry.URL}/${imageModel.Registry.Gitlab.ProjectPath}${slash}${imageModel.Image}`;
|
||||
}
|
||||
|
||||
return fullImageName;
|
||||
function buildImageURIForQuay(imageModel) {
|
||||
const name = imageModel.Registry.Quay.UseOrganisation ? imageModel.Registry.Quay.OrganisationName : imageModel.Registry.Username;
|
||||
const url = imageModel.Registry.URL ? imageModel.Registry.URL + '/' : '';
|
||||
return `${url}${name}/${imageModel.Image}`;
|
||||
}
|
||||
|
||||
function buildImageURIForOtherRegistry(imageModel) {
|
||||
const url = imageModel.Registry.URL ? imageModel.Registry.URL + '/' : '';
|
||||
return url + imageModel.Image;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export function VolumeViewModel(data) {
|
||||
}
|
||||
this.Mountpoint = data.Mountpoint;
|
||||
|
||||
this.ResourceId = data.ResourceID;
|
||||
|
||||
if (data.Portainer) {
|
||||
if (data.Portainer.ResourceControl) {
|
||||
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
||||
|
||||
@@ -36,7 +36,11 @@ angular.module('portainer.docker').controller('ImagesController', [
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
ImageService.pullImage(registryModel, false)
|
||||
.then(function success() {
|
||||
.then(function success(data) {
|
||||
var err = data[data.length - 1].errorDetail;
|
||||
if (err) {
|
||||
return Notifications.error('Failure', err, 'Unable to pull image');
|
||||
}
|
||||
Notifications.success('Image successfully pulled', registryModel.Image);
|
||||
$state.reload();
|
||||
})
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel ng-if="volume" resource-id="volume.Id + volume.CreatedAt" resource-control="volume.ResourceControl" resource-type="'volume'"> </por-access-control-panel>
|
||||
<por-access-control-panel ng-if="volume" resource-id="volume.ResourceId" resource-control="volume.ResourceControl" resource-type="'volume'"> </por-access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
<div class="row" ng-if="!(volume.Options | emptyobject)">
|
||||
|
||||
@@ -49,7 +49,7 @@ export class EdgeGroupFormController {
|
||||
async getDynamicEndpointsAsync() {
|
||||
const { pageNumber, limit, search } = this.endpoints.state;
|
||||
const start = (pageNumber - 1) * limit + 1;
|
||||
const query = { search, type: 4, tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch };
|
||||
const query = { search, types: [4], tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch };
|
||||
|
||||
const response = await this.EndpointService.endpoints(start, limit, query);
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export class EditEdgeStackViewController {
|
||||
|
||||
async getPaginatedEndpointsAsync(lastId, limit, search) {
|
||||
try {
|
||||
const query = { search, type: 4, endpointIds: this.stackEndpointIds };
|
||||
const query = { search, types: [4], endpointIds: this.stackEndpointIds };
|
||||
const { value, totalCount } = await this.EndpointService.endpoints(lastId, limit, query);
|
||||
const endpoints = _.map(value, (endpoint) => {
|
||||
const status = this.stack.Status[endpoint.Id];
|
||||
|
||||
@@ -10,5 +10,6 @@ angular.module('portainer.kubernetes').component('kubernetesResourcePoolsDatatab
|
||||
reverseOrder: '<',
|
||||
removeAction: '<',
|
||||
refreshCallback: '<',
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,7 +18,11 @@ angular.module('portainer.docker').controller('KubernetesResourcePoolsDatatableC
|
||||
};
|
||||
|
||||
this.canManageAccess = function (item) {
|
||||
return item.Namespace.Name !== 'default' && !this.isSystemNamespace(item);
|
||||
if (!this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace) {
|
||||
return item.Namespace.Name !== 'default' && !this.isSystemNamespace(item);
|
||||
} else {
|
||||
return !this.isSystemNamespace(item);
|
||||
}
|
||||
};
|
||||
|
||||
this.disableRemove = function (item) {
|
||||
|
||||
@@ -10,22 +10,42 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.memoryLimit !== 0">
|
||||
<label for="memory-usage" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
<label for="memory-reservation" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Memory reservation
|
||||
</label>
|
||||
<div class="col-sm-9" style="margin-top: 4px;">
|
||||
<uib-progressbar animate="false" value="$ctrl.memoryUsage" type="{{ $ctrl.memoryUsage | kubernetesUsageLevelInfo }}">
|
||||
<b style="white-space: nowrap;"> {{ $ctrl.memory }} / {{ $ctrl.memoryLimit }} MB - {{ $ctrl.memoryUsage }}% </b>
|
||||
<uib-progressbar animate="false" value="$ctrl.memoryReservationPercent" type="{{ $ctrl.memoryReservationPercent | kubernetesUsageLevelInfo }}">
|
||||
<b style="white-space: nowrap;"> {{ $ctrl.memoryReservation }} / {{ $ctrl.memoryLimit }} MB - {{ $ctrl.memoryReservationPercent }}% </b>
|
||||
</uib-progressbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.displayUsage && $ctrl.memoryLimit !== 0">
|
||||
<label for="memory-usage" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Memory used
|
||||
</label>
|
||||
<div class="col-sm-9" style="margin-top: 4px;">
|
||||
<uib-progressbar animate="false" value="$ctrl.memoryUsagePercent" type="{{ $ctrl.memoryUsagePercent | kubernetesUsageLevelInfo }}">
|
||||
<b style="white-space: nowrap;"> {{ $ctrl.memoryUsage }} / {{ $ctrl.memoryLimit }} MB - {{ $ctrl.memoryUsagePercent }}% </b>
|
||||
</uib-progressbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.cpuLimit !== 0">
|
||||
<label for="cpu-usage" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
<label for="cpu-reservation" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
CPU reservation
|
||||
</label>
|
||||
<div class="col-sm-9" style="margin-top: 4px;">
|
||||
<uib-progressbar animate="false" value="$ctrl.cpuUsage" type="{{ $ctrl.cpuUsage | kubernetesUsageLevelInfo }}">
|
||||
<b style="white-space: nowrap;"> {{ $ctrl.cpu | kubernetesApplicationCPUValue }} / {{ $ctrl.cpuLimit }} - {{ $ctrl.cpuUsage }}% </b>
|
||||
<uib-progressbar animate="false" value="$ctrl.cpuReservationPercent" type="{{ $ctrl.cpuReservationPercent | kubernetesUsageLevelInfo }}">
|
||||
<b style="white-space: nowrap;"> {{ $ctrl.cpuReservation | kubernetesApplicationCPUValue }} / {{ $ctrl.cpuLimit }} - {{ $ctrl.cpuReservationPercent }}% </b>
|
||||
</uib-progressbar>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-if="$ctrl.displayUsage && $ctrl.cpuLimit !== 0">
|
||||
<label for="cpu-usage" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
CPU used
|
||||
</label>
|
||||
<div class="col-sm-9" style="margin-top: 4px;">
|
||||
<uib-progressbar animate="false" value="$ctrl.cpuUsagePercent" type="{{ $ctrl.cpuUsagePercent | kubernetesUsageLevelInfo }}">
|
||||
<b style="white-space: nowrap;"> {{ $ctrl.cpuUsage | kubernetesApplicationCPUValue }} / {{ $ctrl.cpuLimit }} - {{ $ctrl.cpuUsagePercent }}% </b>
|
||||
</uib-progressbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,12 @@ angular.module('portainer.kubernetes').component('kubernetesResourceReservation'
|
||||
controller: 'KubernetesResourceReservationController',
|
||||
bindings: {
|
||||
description: '@',
|
||||
cpu: '<',
|
||||
cpuReservation: '<',
|
||||
cpuUsage: '<',
|
||||
cpuLimit: '<',
|
||||
memory: '<',
|
||||
memoryReservation: '<',
|
||||
memoryUsage: '<',
|
||||
memoryLimit: '<',
|
||||
displayUsage: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,10 +3,15 @@ import angular from 'angular';
|
||||
class KubernetesResourceReservationController {
|
||||
usageValues() {
|
||||
if (this.cpuLimit) {
|
||||
this.cpuUsage = Math.round((this.cpu / this.cpuLimit) * 100);
|
||||
this.cpuReservationPercent = Math.round((this.cpuReservation / this.cpuLimit) * 100);
|
||||
}
|
||||
if (this.memoryLimit) {
|
||||
this.memoryUsage = Math.round((this.memory / this.memoryLimit) * 100);
|
||||
this.memoryReservationPercent = Math.round((this.memoryReservation / this.memoryLimit) * 100);
|
||||
}
|
||||
|
||||
if (this.displayUsage && this.cpuLimit && this.memoryLimit) {
|
||||
this.cpuUsagePercent = Math.round((this.cpuUsage / this.cpuLimit) * 100);
|
||||
this.memoryUsagePercent = Math.round((this.memoryUsage / this.memoryLimit) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,9 @@ export class KubernetesIngressConverter {
|
||||
rule.IngressName = ingress.Name;
|
||||
rule.ServiceName = serviceName;
|
||||
rule.Port = p.ContainerPort;
|
||||
rule.Path = _.startsWith(p.IngressRoute, '/') ? p.IngressRoute : '/' + p.IngressRoute;
|
||||
if (p.IngressRoute) {
|
||||
rule.Path = _.startsWith(p.IngressRoute, '/') ? p.IngressRoute : '/' + p.IngressRoute;
|
||||
}
|
||||
rule.Host = p.IngressHost;
|
||||
ingress.Paths.push(rule);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ class KubernetesMetricsService {
|
||||
this.KubernetesMetrics = KubernetesMetrics;
|
||||
|
||||
this.capabilitiesAsync = this.capabilitiesAsync.bind(this);
|
||||
|
||||
this.getPodAsync = this.getPodAsync.bind(this);
|
||||
this.getNodeAsync = this.getNodeAsync.bind(this);
|
||||
|
||||
this.getPodsAsync = this.getPodsAsync.bind(this);
|
||||
this.getNodesAsync = this.getNodesAsync.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +72,42 @@ class KubernetesMetricsService {
|
||||
getPod(namespace, podName) {
|
||||
return this.$async(this.getPodAsync, namespace, podName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats of Nodes in cluster
|
||||
*
|
||||
* @param {string} endpointID
|
||||
*/
|
||||
async getNodesAsync(endpointID) {
|
||||
try {
|
||||
const data = await this.KubernetesMetrics().getNodes({ endpointId: endpointID }).$promise;
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to retrieve nodes stats', err);
|
||||
}
|
||||
}
|
||||
|
||||
getNodes(endpointID) {
|
||||
return this.$async(this.getNodesAsync, endpointID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats of Pods in a namespace
|
||||
*
|
||||
* @param {string} namespace
|
||||
*/
|
||||
async getPodsAsync(namespace) {
|
||||
try {
|
||||
const data = await this.KubernetesMetrics(namespace).getPods().$promise;
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to retrieve pod stats', err);
|
||||
}
|
||||
}
|
||||
|
||||
getPods(namespace) {
|
||||
return this.$async(this.getPodsAsync, namespace);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesMetricsService;
|
||||
|
||||
@@ -24,6 +24,14 @@ angular.module('portainer.kubernetes').factory('KubernetesMetrics', [
|
||||
method: 'GET',
|
||||
url: `${url}/nodes/:id`,
|
||||
},
|
||||
getPods: {
|
||||
method: 'GET',
|
||||
url: `${url}/namespaces/:namespace/pods`,
|
||||
},
|
||||
getNodes: {
|
||||
method: 'GET',
|
||||
url: `${url}/nodes`,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,30 +5,28 @@ import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymen
|
||||
* KubernetesApplicationFormValues Model
|
||||
*/
|
||||
export function KubernetesApplicationFormValues() {
|
||||
return {
|
||||
ApplicationType: undefined, // will only exist for formValues generated from Application (app edit situation)
|
||||
ResourcePool: {},
|
||||
Name: '',
|
||||
StackName: '',
|
||||
ApplicationOwner: '',
|
||||
ImageModel: new PorImageRegistryModel(),
|
||||
Note: '',
|
||||
MemoryLimit: 0,
|
||||
CpuLimit: 0,
|
||||
DeploymentType: KubernetesApplicationDeploymentTypes.REPLICATED,
|
||||
ReplicaCount: 1,
|
||||
AutoScaler: {},
|
||||
Containers: [],
|
||||
EnvironmentVariables: [], // KubernetesApplicationEnvironmentVariableFormValue list
|
||||
DataAccessPolicy: KubernetesApplicationDataAccessPolicies.SHARED,
|
||||
PersistedFolders: [], // KubernetesApplicationPersistedFolderFormValue list
|
||||
Configurations: [], // KubernetesApplicationConfigurationFormValue list
|
||||
PublishingType: KubernetesApplicationPublishingTypes.INTERNAL,
|
||||
PublishedPorts: [], // KubernetesApplicationPublishedPortFormValue list
|
||||
PlacementType: KubernetesApplicationPlacementTypes.PREFERRED,
|
||||
Placements: [], // KubernetesApplicationPlacementFormValue list
|
||||
OriginalIngresses: undefined,
|
||||
};
|
||||
this.ApplicationType = undefined; // will only exist for formValues generated from Application (app edit situation;
|
||||
this.ResourcePool = {};
|
||||
this.Name = '';
|
||||
this.StackName = '';
|
||||
this.ApplicationOwner = '';
|
||||
this.ImageModel = new PorImageRegistryModel();
|
||||
this.Note = '';
|
||||
this.MemoryLimit = 0;
|
||||
this.CpuLimit = 0;
|
||||
this.DeploymentType = KubernetesApplicationDeploymentTypes.REPLICATED;
|
||||
this.ReplicaCount = 1;
|
||||
this.AutoScaler = {};
|
||||
this.Containers = [];
|
||||
this.EnvironmentVariables = []; // KubernetesApplicationEnvironmentVariableFormValue lis;
|
||||
this.DataAccessPolicy = KubernetesApplicationDataAccessPolicies.SHARED;
|
||||
this.PersistedFolders = []; // KubernetesApplicationPersistedFolderFormValue lis;
|
||||
this.Configurations = []; // KubernetesApplicationConfigurationFormValue lis;
|
||||
this.PublishingType = KubernetesApplicationPublishingTypes.INTERNAL;
|
||||
this.PublishedPorts = []; // KubernetesApplicationPublishedPortFormValue lis;
|
||||
this.PlacementType = KubernetesApplicationPlacementTypes.PREFERRED;
|
||||
this.Placements = []; // KubernetesApplicationPlacementFormValue lis;
|
||||
this.OriginalIngresses = undefined;
|
||||
}
|
||||
|
||||
export const KubernetesApplicationConfigurationFormValueOverridenKeyTypes = Object.freeze({
|
||||
|
||||
@@ -12,11 +12,14 @@
|
||||
<!-- resource-reservation -->
|
||||
<form class="form-horizontal" ng-if="ctrl.resourceReservation">
|
||||
<kubernetes-resource-reservation
|
||||
cpu="ctrl.resourceReservation.CPU"
|
||||
cpu-limit="ctrl.CPULimit"
|
||||
memory="ctrl.resourceReservation.Memory"
|
||||
memory-limit="ctrl.MemoryLimit"
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
|
||||
cpu-reservation="ctrl.resourceReservation.CPU"
|
||||
cpu-limit="ctrl.CPULimit"
|
||||
memory-reservation="ctrl.resourceReservation.Memory"
|
||||
memory-limit="ctrl.MemoryLimit"
|
||||
display-usage="ctrl.isAdmin"
|
||||
cpu-usage="ctrl.resourceUsage.CPU"
|
||||
memory-usage="ctrl.resourceUsage.Memory"
|
||||
>
|
||||
</kubernetes-resource-reservation>
|
||||
</form>
|
||||
|
||||
@@ -2,4 +2,7 @@ angular.module('portainer.kubernetes').component('kubernetesClusterView', {
|
||||
templateUrl: './cluster.html',
|
||||
controller: 'KubernetesClusterController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,10 +13,10 @@ class KubernetesClusterController {
|
||||
Notifications,
|
||||
LocalStorage,
|
||||
KubernetesNodeService,
|
||||
KubernetesMetricsService,
|
||||
KubernetesApplicationService,
|
||||
KubernetesComponentStatusService,
|
||||
KubernetesEndpointService,
|
||||
EndpointProvider
|
||||
KubernetesEndpointService
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
@@ -24,10 +24,10 @@ class KubernetesClusterController {
|
||||
this.Notifications = Notifications;
|
||||
this.LocalStorage = LocalStorage;
|
||||
this.KubernetesNodeService = KubernetesNodeService;
|
||||
this.KubernetesMetricsService = KubernetesMetricsService;
|
||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
||||
this.KubernetesComponentStatusService = KubernetesComponentStatusService;
|
||||
this.KubernetesEndpointService = KubernetesEndpointService;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getNodes = this.getNodes.bind(this);
|
||||
@@ -106,6 +106,10 @@ class KubernetesClusterController {
|
||||
new KubernetesResourceReservation()
|
||||
);
|
||||
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(this.resourceReservation.Memory);
|
||||
|
||||
if (this.isAdmin) {
|
||||
await this.getResourceUsage(this.endpoint.Id);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', 'Unable to retrieve applications', err);
|
||||
} finally {
|
||||
@@ -117,14 +121,31 @@ class KubernetesClusterController {
|
||||
return this.$async(this.getApplicationsAsync);
|
||||
}
|
||||
|
||||
async getResourceUsage(endpointId) {
|
||||
try {
|
||||
const nodeMetrics = await this.KubernetesMetricsService.getNodes(endpointId);
|
||||
const resourceUsageList = nodeMetrics.items.map((i) => i.usage);
|
||||
const clusterResourceUsage = resourceUsageList.reduce((total, u) => {
|
||||
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
|
||||
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
|
||||
return total;
|
||||
}, new KubernetesResourceReservation());
|
||||
this.resourceUsage = clusterResourceUsage;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', 'Unable to retrieve cluster resource usage', err);
|
||||
}
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
applicationsLoading: true,
|
||||
viewReady: false,
|
||||
hasUnhealthyComponentStatus: false,
|
||||
useServerMetrics: false,
|
||||
};
|
||||
|
||||
this.isAdmin = this.Authentication.isAdmin();
|
||||
this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
||||
|
||||
await this.getNodes();
|
||||
if (this.isAdmin) {
|
||||
@@ -134,7 +155,6 @@ class KubernetesClusterController {
|
||||
}
|
||||
|
||||
this.state.viewReady = true;
|
||||
this.state.useServerMetrics = this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.UseServerMetrics;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
|
||||
@@ -78,11 +78,14 @@
|
||||
<div style="padding: 8px;">
|
||||
<kubernetes-resource-reservation
|
||||
ng-if="ctrl.resourceReservation"
|
||||
cpu="ctrl.resourceReservation.CPU"
|
||||
cpu-reservation="ctrl.resourceReservation.CPU"
|
||||
cpu-usage="ctrl.resourceUsage.CPU"
|
||||
cpu-limit="ctrl.node.CPU"
|
||||
memory="ctrl.resourceReservation.Memory"
|
||||
memory-reservation="ctrl.resourceReservation.Memory"
|
||||
memory-usage="ctrl.resourceUsage.Memory"
|
||||
memory-limit="ctrl.memoryLimit"
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications running on this node."
|
||||
display-usage="ctrl.state.isAdmin"
|
||||
>
|
||||
</kubernetes-resource-reservation>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ angular.module('portainer.kubernetes').component('kubernetesNodeView', {
|
||||
controller: 'KubernetesNodeController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
$transition$: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,7 +21,9 @@ class KubernetesNodeController {
|
||||
KubernetesEventService,
|
||||
KubernetesPodService,
|
||||
KubernetesApplicationService,
|
||||
KubernetesEndpointService
|
||||
KubernetesEndpointService,
|
||||
KubernetesMetricsService,
|
||||
Authentication
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
@@ -33,6 +35,8 @@ class KubernetesNodeController {
|
||||
this.KubernetesPodService = KubernetesPodService;
|
||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
||||
this.KubernetesEndpointService = KubernetesEndpointService;
|
||||
this.KubernetesMetricsService = KubernetesMetricsService;
|
||||
this.Authentication = Authentication;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getNodesAsync = this.getNodesAsync.bind(this);
|
||||
@@ -42,6 +46,7 @@ class KubernetesNodeController {
|
||||
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
||||
this.updateNodeAsync = this.updateNodeAsync.bind(this);
|
||||
this.drainNodeAsync = this.drainNodeAsync.bind(this);
|
||||
this.getNodeUsageAsync = this.getNodeUsageAsync.bind(this);
|
||||
}
|
||||
|
||||
selectTab(index) {
|
||||
@@ -327,6 +332,22 @@ class KubernetesNodeController {
|
||||
return this.$async(this.getNodesAsync);
|
||||
}
|
||||
|
||||
async getNodeUsageAsync() {
|
||||
try {
|
||||
const nodeName = this.$transition$.params().name;
|
||||
const node = await this.KubernetesMetricsService.getNode(nodeName);
|
||||
this.resourceUsage = new KubernetesResourceReservation();
|
||||
this.resourceUsage.CPU = KubernetesResourceReservationHelper.parseCPU(node.usage.cpu);
|
||||
this.resourceUsage.Memory = KubernetesResourceReservationHelper.megaBytesValue(node.usage.memory);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', 'Unable to retrieve node resource usage', err);
|
||||
}
|
||||
}
|
||||
|
||||
getNodeUsage() {
|
||||
return this.$async(this.getNodeUsageAsync);
|
||||
}
|
||||
|
||||
hasEventWarnings() {
|
||||
return this.state.eventWarningCount;
|
||||
}
|
||||
@@ -334,8 +355,8 @@ class KubernetesNodeController {
|
||||
async getEventsAsync() {
|
||||
try {
|
||||
this.state.eventsLoading = true;
|
||||
this.events = await this.KubernetesEventService.get();
|
||||
this.events = _.filter(this.events.items, (item) => item.involvedObject.kind === 'Node');
|
||||
const events = await this.KubernetesEventService.get();
|
||||
this.events = events.filter((item) => item.Involved.kind === 'Node');
|
||||
this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve node events');
|
||||
@@ -375,6 +396,10 @@ class KubernetesNodeController {
|
||||
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(this.resourceReservation.Memory);
|
||||
this.memoryLimit = KubernetesResourceReservationHelper.megaBytesValue(this.node.Memory);
|
||||
this.state.isContainPortainer = _.find(this.applications, { ApplicationName: 'portainer' });
|
||||
|
||||
if (this.state.isAdmin) {
|
||||
await this.getNodeUsage();
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
||||
} finally {
|
||||
@@ -388,6 +413,7 @@ class KubernetesNodeController {
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
isAdmin: this.Authentication.isAdmin(),
|
||||
activeTab: 0,
|
||||
currentName: this.$state.$current.name,
|
||||
dataLoading: true,
|
||||
@@ -402,10 +428,13 @@ class KubernetesNodeController {
|
||||
hasDuplicateLabelKeys: false,
|
||||
isDrainOperation: false,
|
||||
isContainPortainer: false,
|
||||
useServerMetrics: false,
|
||||
};
|
||||
|
||||
this.availabilities = KubernetesNodeAvailabilities;
|
||||
|
||||
this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('node');
|
||||
|
||||
await this.getNodes();
|
||||
|
||||
@@ -145,11 +145,7 @@
|
||||
<label class="control-label text-left">
|
||||
Restrict access to the default namespace
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" disabled /><i></i> </label>
|
||||
<span class="text-muted small" style="margin-left: 15px;">
|
||||
<i class="fa fa-user" aria-hidden="true"></i>
|
||||
This feature is available in <a href="https://www.portainer.io/business-upsell?from=k8s-setup-default" target="_blank"> Portainer Business Edition</a>.
|
||||
</span>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.RestrictDefaultNamespace" /><i></i> </label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ class KubernetesConfigureController {
|
||||
endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
|
||||
endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses;
|
||||
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace;
|
||||
}
|
||||
|
||||
transformFormValues() {
|
||||
@@ -259,6 +260,7 @@ class KubernetesConfigureController {
|
||||
UseLoadBalancer: false,
|
||||
UseServerMetrics: false,
|
||||
IngressClasses: [],
|
||||
RestrictDefaultNamespace: false,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -281,6 +283,7 @@ class KubernetesConfigureController {
|
||||
|
||||
this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
|
||||
this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
||||
this.formValues.RestrictDefaultNamespace = this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace;
|
||||
this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => {
|
||||
ic.IsNew = false;
|
||||
ic.NeedsDeletion = false;
|
||||
|
||||
@@ -40,10 +40,13 @@
|
||||
<kubernetes-resource-reservation
|
||||
ng-if="ctrl.pool.Quota"
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
|
||||
cpu="ctrl.state.cpuUsed"
|
||||
memory="ctrl.state.memoryUsed"
|
||||
cpu-reservation="ctrl.state.resourceReservation.CPU"
|
||||
memory-reservation="ctrl.state.resourceReservation.Memory"
|
||||
cpu-limit="ctrl.formValues.CpuLimit"
|
||||
memory-limit="ctrl.formValues.MemoryLimit"
|
||||
display-usage="ctrl.state.useServerMetrics"
|
||||
cpu-usage="ctrl.state.resourceUsage.CPU"
|
||||
memory-usage="ctrl.state.resourceUsage.Memory"
|
||||
>
|
||||
</kubernetes-resource-reservation>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
|
||||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
|
||||
import {
|
||||
KubernetesResourcePoolFormValues,
|
||||
@@ -27,6 +28,7 @@ class KubernetesResourcePoolController {
|
||||
EndpointService,
|
||||
ModalService,
|
||||
KubernetesNodeService,
|
||||
KubernetesMetricsService,
|
||||
KubernetesResourceQuotaService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesEventService,
|
||||
@@ -45,6 +47,7 @@ class KubernetesResourcePoolController {
|
||||
EndpointService,
|
||||
ModalService,
|
||||
KubernetesNodeService,
|
||||
KubernetesMetricsService,
|
||||
KubernetesResourceQuotaService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesEventService,
|
||||
@@ -240,6 +243,8 @@ class KubernetesResourcePoolController {
|
||||
app.Memory = resourceReservation.Memory;
|
||||
return app;
|
||||
});
|
||||
|
||||
await this.getResourceUsage(this.pool.Namespace.Name);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications.');
|
||||
} finally {
|
||||
@@ -300,11 +305,26 @@ class KubernetesResourcePoolController {
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
async getResourceUsage(namespace) {
|
||||
try {
|
||||
const namespaceMetrics = await this.KubernetesMetricsService.getPods(namespace);
|
||||
// extract resource usage of all containers within each pod of the namespace
|
||||
const containerResourceUsageList = namespaceMetrics.items.flatMap((i) => i.containers.map((c) => c.usage));
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce((total, u) => {
|
||||
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
|
||||
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
|
||||
return total;
|
||||
}, new KubernetesResourceReservation());
|
||||
this.state.resourceUsage = namespaceResourceUsage;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', 'Unable to retrieve namespace resource usage', err);
|
||||
}
|
||||
}
|
||||
|
||||
/* #region ON INIT */
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const endpoint = this.endpoint;
|
||||
this.isAdmin = this.Authentication.isAdmin();
|
||||
|
||||
this.state = {
|
||||
@@ -312,9 +332,8 @@ class KubernetesResourcePoolController {
|
||||
sliderMaxMemory: 0,
|
||||
sliderMaxCpu: 0,
|
||||
cpuUsage: 0,
|
||||
cpuUsed: 0,
|
||||
memoryUsage: 0,
|
||||
memoryUsed: 0,
|
||||
resourceReservation: { CPU: 0, Memory: 0 },
|
||||
activeTab: 0,
|
||||
currentName: this.$state.$current.name,
|
||||
showEditorTab: false,
|
||||
@@ -323,7 +342,8 @@ class KubernetesResourcePoolController {
|
||||
ingressesLoading: true,
|
||||
viewReady: false,
|
||||
eventWarningCount: 0,
|
||||
canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length,
|
||||
canUseIngress: this.endpoint.Kubernetes.Configuration.IngressClasses.length,
|
||||
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
|
||||
duplicates: {
|
||||
ingressHosts: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
@@ -352,8 +372,8 @@ class KubernetesResourcePoolController {
|
||||
this.formValues = KubernetesResourceQuotaConverter.quotaToResourcePoolFormValues(quota);
|
||||
this.formValues.EndpointId = this.endpoint.Id;
|
||||
|
||||
this.state.cpuUsed = quota.CpuLimitUsed;
|
||||
this.state.memoryUsed = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed);
|
||||
this.state.resourceReservation.CPU = quota.CpuLimitUsed;
|
||||
this.state.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed);
|
||||
}
|
||||
|
||||
this.isEditable = !this.KubernetesNamespaceHelper.isSystemNamespace(this.pool.Namespace.Name);
|
||||
@@ -366,7 +386,7 @@ class KubernetesResourcePoolController {
|
||||
|
||||
if (this.state.canUseIngress) {
|
||||
await this.getIngresses();
|
||||
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
|
||||
const ingressClasses = this.endpoint.Kubernetes.Configuration.IngressClasses;
|
||||
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses, this.ingresses);
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
if (ic.Hosts.length === 0) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
order-by="Namespace.Name"
|
||||
remove-action="ctrl.removeAction"
|
||||
refresh-callback="ctrl.getResourcePools"
|
||||
endpoint="ctrl.endpoint"
|
||||
></kubernetes-resource-pools-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,4 +2,7 @@ angular.module('portainer.kubernetes').component('kubernetesResourcePoolsView',
|
||||
templateUrl: './resourcePools.html',
|
||||
controller: 'KubernetesResourcePoolsController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ class AssoicatedEndpointsSelectorController {
|
||||
|
||||
async getEndpointsAsync() {
|
||||
const { start, search, limit } = this.getPaginationData('available');
|
||||
const query = { search, type: 4 };
|
||||
const query = { search, types: [4] };
|
||||
|
||||
const response = await this.EndpointService.endpoints(start, limit, query);
|
||||
|
||||
@@ -73,7 +73,7 @@ class AssoicatedEndpointsSelectorController {
|
||||
let response = { value: [], totalCount: 0 };
|
||||
if (this.endpointIds.length > 0) {
|
||||
const { start, search, limit } = this.getPaginationData('associated');
|
||||
const query = { search, type: 4, endpointIds: this.endpointIds };
|
||||
const query = { search, types: [4], endpointIds: this.endpointIds };
|
||||
|
||||
response = await this.EndpointService.endpoints(start, limit, query);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ angular.module('portainer.app').controller('StackDuplicationFormController', [
|
||||
ctrl.duplicateStack = duplicateStack;
|
||||
ctrl.migrateStack = migrateStack;
|
||||
ctrl.isMigrationButtonDisabled = isMigrationButtonDisabled;
|
||||
ctrl.isEndpointSelected = isEndpointSelected;
|
||||
|
||||
function isFormValidForMigration() {
|
||||
return ctrl.formValues.endpoint && ctrl.formValues.endpoint.Id;
|
||||
@@ -62,5 +63,9 @@ angular.module('portainer.app').controller('StackDuplicationFormController', [
|
||||
function isTargetEndpointAndCurrentEquals() {
|
||||
return ctrl.formValues.endpoint && ctrl.formValues.endpoint.Id === ctrl.currentEndpointId;
|
||||
}
|
||||
|
||||
function isEndpointSelected() {
|
||||
return ctrl.formValues.endpoint && ctrl.formValues.endpoint.Id;
|
||||
}
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<span ng-hide="$ctrl.state.duplicationInProgress"> <i class="fa fa-clone space-right" aria-hidden="true"></i> Duplicate </span>
|
||||
<span ng-show="$ctrl.state.duplicationInProgress">Duplication in progress...</span>
|
||||
</button>
|
||||
<div ng-if="$ctrl.yamlError"
|
||||
<div ng-if="$ctrl.yamlError && $ctrl.isEndpointSelected()"
|
||||
><span class="text-danger small">{{ $ctrl.yamlError }}</span></div
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ angular.module('portainer.app').factory('Endpoints', [
|
||||
},
|
||||
get: { method: 'GET', params: { id: '@id' } },
|
||||
update: { method: 'PUT', params: { id: '@id' } },
|
||||
deassociate: { method: 'DELETE', params: { id: '@id', action: 'association' } },
|
||||
updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } },
|
||||
remove: { method: 'DELETE', params: { id: '@id' } },
|
||||
snapshots: { method: 'POST', params: { action: 'snapshot' } },
|
||||
|
||||
@@ -17,11 +17,12 @@ angular.module('portainer.app').factory('EndpointService', [
|
||||
return Endpoints.get({ id: endpointID }).$promise;
|
||||
};
|
||||
|
||||
service.endpoints = function (start, limit, { search, type, tagIds, endpointIds, tagsPartialMatch } = {}) {
|
||||
service.endpoints = function (start, limit, { search, types, tagIds, endpointIds, tagsPartialMatch } = {}) {
|
||||
if (tagIds && !tagIds.length) {
|
||||
return Promise.resolve({ value: [], totalCount: 0 });
|
||||
}
|
||||
return Endpoints.query({ start, limit, search, type, tagIds: JSON.stringify(tagIds), endpointIds: JSON.stringify(endpointIds), tagsPartialMatch }).$promise;
|
||||
return Endpoints.query({ start, limit, search, types: JSON.stringify(types), tagIds: JSON.stringify(tagIds), endpointIds: JSON.stringify(endpointIds), tagsPartialMatch })
|
||||
.$promise;
|
||||
};
|
||||
|
||||
service.snapshotEndpoints = function () {
|
||||
@@ -40,6 +41,10 @@ angular.module('portainer.app').factory('EndpointService', [
|
||||
return Endpoints.updateAccess({ id: id }, { UserAccessPolicies: userAccessPolicies, TeamAccessPolicies: teamAccessPolicies }).$promise;
|
||||
};
|
||||
|
||||
service.deassociateEndpoint = function (endpointID) {
|
||||
return Endpoints.deassociate({ id: endpointID }).$promise;
|
||||
};
|
||||
|
||||
service.updateEndpoint = function (id, payload) {
|
||||
var deferred = $q.defer();
|
||||
FileUploadService.uploadTLSFilesForEndpoint(id, payload.TLSCACert, payload.TLSCert, payload.TLSKey)
|
||||
|
||||
@@ -26,6 +26,11 @@ angular.module('portainer.app').factory('ChartService', [
|
||||
},
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
padding: {
|
||||
left: 15,
|
||||
},
|
||||
},
|
||||
hover: { animationDuration: 0 },
|
||||
scales: {
|
||||
yAxes: [
|
||||
|
||||
@@ -152,6 +152,24 @@ angular.module('portainer.app').factory('ModalService', [
|
||||
});
|
||||
};
|
||||
|
||||
service.confirmDeassociate = function (callback) {
|
||||
const message =
|
||||
'<p>De-associating this Edge endpoint will mark it as non associated and will clear the registered Edge ID.</p>' +
|
||||
'<p>Any agent started with the Edge key associated to this endpoint will be able to re-associate with this endpoint.</p>' +
|
||||
'<p>You can re-use the Edge ID and Edge key that you used to deploy the existing Edge agent to associate a new Edge device to this endpoint.</p>';
|
||||
service.confirm({
|
||||
title: 'About de-associating',
|
||||
message: $sanitize(message),
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'De-associate',
|
||||
className: 'btn-primary',
|
||||
},
|
||||
},
|
||||
callback: callback,
|
||||
});
|
||||
};
|
||||
|
||||
service.confirmUpdate = function (message, callback) {
|
||||
message = $sanitize(message);
|
||||
service.confirm({
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
<p>
|
||||
Edge identifier: <code>{{ endpoint.EdgeID }}</code>
|
||||
</p>
|
||||
<p>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress" ng-click="onDeassociateEndpoint()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">De-associate</span>
|
||||
</button>
|
||||
</p>
|
||||
</span>
|
||||
</information-panel>
|
||||
<information-panel ng-if="state.edgeEndpoint && !endpoint.EdgeID" title-text="Deploy an agent">
|
||||
|
||||
@@ -19,7 +19,8 @@ angular
|
||||
EndpointProvider,
|
||||
Notifications,
|
||||
Authentication,
|
||||
SettingsService
|
||||
SettingsService,
|
||||
ModalService
|
||||
) {
|
||||
$scope.state = {
|
||||
uploadInProgress: false,
|
||||
@@ -113,6 +114,29 @@ angular
|
||||
}
|
||||
}
|
||||
|
||||
$scope.onDeassociateEndpoint = async function () {
|
||||
ModalService.confirmDeassociate((confirmed) => {
|
||||
if (confirmed) {
|
||||
deassociateEndpoint();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
async function deassociateEndpoint() {
|
||||
var endpoint = $scope.endpoint;
|
||||
|
||||
try {
|
||||
$scope.state.actionInProgress = true;
|
||||
await EndpointService.deassociateEndpoint(endpoint.Id);
|
||||
Notifications.success('Endpoint de-associated', $scope.endpoint.Name);
|
||||
$state.reload();
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to de-associate endpoint');
|
||||
} finally {
|
||||
$scope.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.updateEndpoint = function () {
|
||||
var endpoint = $scope.endpoint;
|
||||
var securityData = $scope.formValues.SecurityFormData;
|
||||
|
||||
@@ -232,8 +232,8 @@ angular
|
||||
}
|
||||
|
||||
try {
|
||||
$scope.containers = await ContainerService.containers();
|
||||
$scope.containerNames = ContainerHelper.getContainerNames($scope.containers);
|
||||
const containers = await ContainerService.containers(true);
|
||||
$scope.containerNames = ContainerHelper.getContainerNames(containers);
|
||||
} catch (err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve Containers');
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- environment-variables -->
|
||||
<div ng-if="stack && stack.Type === 1">
|
||||
<div ng-if="stack">
|
||||
<environment-variables-panel
|
||||
ng-model="formValues.Env"
|
||||
explanation="These values will be used as substitutions in the stack file"
|
||||
@@ -185,7 +185,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-disabled="state.actionInProgress || !stackUpdateForm.$valid || stack.Status === 2 || !stackFileContent || orphaned"
|
||||
ng-disabled="state.actionInProgress || !stackUpdateForm.$valid || !stackFileContent || orphaned"
|
||||
ng-click="deployStack()"
|
||||
button-spinner="state.actionInProgress"
|
||||
>
|
||||
|
||||
@@ -291,7 +291,7 @@ angular.module('portainer.app').controller('StackController', [
|
||||
$q.all({
|
||||
stack: StackService.stack(id),
|
||||
groups: GroupService.groups(),
|
||||
containers: ContainerService.containers(),
|
||||
containers: ContainerService.containers(true),
|
||||
})
|
||||
.then(function success(data) {
|
||||
var stack = data.stack;
|
||||
|
||||
@@ -32,9 +32,12 @@
|
||||
"dev:client:prod": "grunt clean:client && webpack-dev-server --config=./webpack/webpack.production.js",
|
||||
"dev:nodl": "grunt clean:server && grunt clean:client && grunt build:server && grunt copy:assets && grunt start:client",
|
||||
"start:toolkit": "grunt start:toolkit",
|
||||
"build:server:offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s' && mv -f portainer ../../../dist/portainer",
|
||||
"build:server:offline": "cd ./api/cmd/portainer && CGO_ENABLED=0 go build --installsuffix cgo --ldflags '-s' && mv -f portainer ../../../dist/portainer",
|
||||
"clean:all": "grunt clean:all",
|
||||
"format": "prettier --loglevel warn --write \"**/*.{js,css,html}\""
|
||||
"format": "prettier --loglevel warn --write \"**/*.{js,css,html}\"",
|
||||
"lint": "yarn lint:client; yarn lint:server",
|
||||
"lint:server": "cd api && golangci-lint run -E exportloopref",
|
||||
"lint:client": "eslint --cache --fix ."
|
||||
},
|
||||
"scriptsComments": {
|
||||
"build": "Build the entire app (backend/frontend) in development mode",
|
||||
@@ -171,4 +174,4 @@
|
||||
"*.js": "eslint --cache --fix",
|
||||
"*.{js,css,md,html}": "prettier --write"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user