Compare commits

...

31 Commits

Author SHA1 Message Date
Simon Meng
58125a5a0a fix(k8s) trigger port validation while changing protocol (ce#394) 2021-02-02 14:01:51 +13:00
Yi Chen
81de55fedd * fix missing kubectl download (#4802) 2021-02-02 11:12:40 +13:00
Steven Kang
84827b8782 feat(build): introducing buildx for Windows (#4792)
* feat(build): introducing buildx for Windows

* feat(build): re-ordered USER

* feat(build): Fixed Typo

* feat(build): fixed typo
2021-01-31 17:32:30 +13:00
Dmitry Salakhov
a71e71f481 feat(compose): add docker-compose wrapper (#4713)
* feat(compose): add docker-compose wrapper

ce-187

* fix(compose): pick compose implementation upon startup

* Add static compose build for linux

* Fix wget

* Fix platofrm specific docker-compose download

* Keep amd64 architecture as download parameter

* Add tmp folder for docker-compose

* fix: line endings

* add proxy server

* logs

* Proxy

* Add lite transport for compose

* Fix local deployment

* refactor: pass proxyManager by ref

* fix: string conversion

* refactor: compose wrapper remove unused code

* fix: tests

* Add edge

* Fix merge issue

* refactor: remove unused code

* Move server to proxy implementation

* Cleanup wrapper and manager

* feat: pass max supported compose syntax version with each endpoint

* fix: pick compose syntax version

* fix: store wrapper version in portainer

* Get and show composeSyntaxMaxVersion at stack creation screen

* Get and show composeSyntaxMaxVersion at stack editor screen

* refactor: proxy server

* Fix used tmp

* Bump docker-compose to 1.28.0

* remove message for docker compose limitation

* fix: markup typo

* Rollback docker compose to 1.27.4

* * attempt to fix the windows build issue

* * attempt to debug grunt issue

* * use console log in grunt file

* fix: try to fix windows build by removing indirect deps from go.mod

* Remove tmp folder

* Remove builder stage

* feat(build/windows): add git for Docker Compose

* feat(build/windows): add git for Docker Compose

* feat(build/windows): add git for Docker Compose

* feat(build/windows): add git for Docker Compose

* feat(build/windows): add git for Docker Compose

* feat(build/windows): add git for Docker Compose - fixed verbose output

* refactor: renames

* fix(stack): get endpoint by EndpointProvider

* fix(stack): use margin to add space between line instead of using br tag

Co-authored-by: Stéphane Busso <stephane.busso@gmail.com>
Co-authored-by: Simon Meng <simon.meng@portainer.io>
Co-authored-by: yi-portainer <yi.chen@portainer.io>
Co-authored-by: Steven Kang <skan070@gmail.com>
2021-01-26 08:16:53 +13:00
LP B
83f4c5ec0b fix(k8s/app): remove advanced deployment panel from app details view (#4730) 2021-01-25 14:43:54 +13:00
Maxime Bajeux
41308d570d feat(configurations): Review UI/UX configurations (#4691)
* feat(configurations): Review UI/UX configurations

* feat(configurations): fix binary secret value

* fix(frontend): populate data between simple and advanced modes (#4503)

* fix(configuration): parseYaml before create configuration

* fix(configurations): change c to C in ConfigurationOwner

* fix(application): change configuration index to configuration key in the view

* fix(configuration): resolve problem in application create with configuration not overriden.

* fix(configuration): fix bad import in helper

Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-01-25 14:14:35 +13:00
Chaim Lev-Ari
46ff8a01bc fix(kubernetes/pods): save note (#4675)
* feat(kubernetes/pods): introduce patch api

* feat(k8s/pods): pod converter

* feat(kubernetes/pods): introduce patch api

* feat(k8s/pod): add annotations only if needed

* fix(k8s/pod): replace class with factory function
2021-01-22 14:08:08 +13:00
yi-portainer
2b257d2785 Squashed commit of the following 2.0.1 release fixes:
commit f90d6b55d6
Author: Chaim Lev-Ari <chiptus@users.noreply.github.com>
Date:   Wed Jan 13 00:56:19 2021 +0200

    feat(service): clear source volume when change type (#4627)

    * feat(service): clear source volume when change type

    * feat(service): init volume source to the correct value

commit 1b82b450d7
Author: Yi Chen <69284638+yi-portainer@users.noreply.github.com>
Date:   Thu Jan 7 14:47:32 2021 +1300

    * bump the APIVersion to 2.0.1 (#4688)

commit b78d804881
Author: Yi Chen <69284638+yi-portainer@users.noreply.github.com>
Date:   Wed Dec 30 23:03:43 2020 +1300

    Revert "chore(build): bump Kompose version (#4475)" (#4676)

    This reverts commit 380f106571.

    Co-authored-by: Stéphane Busso <sbusso@users.noreply.github.com>

commit 51b72c12f9
Author: Anthony Lapenna <anthony.lapenna@portainer.io>
Date:   Wed Dec 23 14:45:32 2020 +1300

    fix(docker/stack-details): do not display editor tab for external stack (#4650)

commit 58c04bdbe3
Author: Yi Chen <69284638+yi-portainer@users.noreply.github.com>
Date:   Tue Dec 22 13:47:11 2020 +1300

    + silently continue when downloading artifacts in windows (#4637)

commit a6320d5222
Author: cong meng <mcpacino@gmail.com>
Date:   Tue Dec 22 13:38:54 2020 +1300

    fix(frontend) unable to retrieve config map error when trying to manage newly created resource pool (ce#180) (#4618)

    * fix(frontend) unable to retrieve config map error when trying to manage newly created resource pool (ce#180)

    * fix(frontend) rephrase comments (#4629)

    Co-authored-by: Stéphane Busso <sbusso@users.noreply.github.com>

    Co-authored-by: Simon Meng <simon.meng@portainer.io>
    Co-authored-by: Stéphane Busso <sbusso@users.noreply.github.com>
2021-01-21 00:04:15 +13:00
cong meng
da41dbb79a fix(stack): stacks created via API are incorrectly marked as private with no owner (#3721) (#4725)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-01-20 15:19:35 +13:00
Maxime Bajeux
68d42617f2 feat(placement): Add a warning notification under the placement tab when an application cannot be scheduled on any node in the cluster (#4525)
* feat(placement): Add a warning notification under the placement tab when an application cannot be scheduled on any node in the cluster

* fix(applications): if there is at least one node the application can schedule on, then do not show the warning
2021-01-20 13:02:18 +13:00
Anthony McMahon
8323e22309 Update issue templates
Adding auto labelling to Bug Report (kind/bug, bug/unconfirmed) and Question (kind/question)
2021-01-20 12:06:25 +13:00
Chaim Lev-Ari
20d4341170 fix(state): check validity of state (#4609) 2021-01-19 11:10:08 +13:00
Chaim Lev-Ari
832cafc933 fix(registries): update password only when not empty (#4669) 2021-01-18 13:59:57 +13:00
cong meng
f3c537ac2c chore(build): bump Kompose version (#4473) (#4724)
Co-authored-by: Simon Meng <simon.meng@portainer.io>
2021-01-18 13:02:16 +13:00
Anthony McMahon
958baf6283 Update README.md 2021-01-18 09:30:17 +13:00
Chaim Lev-Ari
08e392378e chore(app): fail on angular components missing nginject (#4224) 2021-01-17 20:28:09 +13:00
Alice Groux
a2d9734b8b fix(k8s/datatables): reduce size of collapse/expand column for stacks datatable and storage datatable (#4511)
* fix(k8s/datatables): reduce size of collapse/expand column for stacks datatable and storage datatable

* fix(k8s/datatables): reduce size of expand/collapse column
2021-01-17 16:50:22 +13:00
DarkAEther
15aed9fc6f feat(area/kubernetes): show shared access policy in volume details (#4707) 2021-01-17 13:53:32 +13:00
Alice Groux
121d33538d fix(k8s/application): validate load balancer ports inputs (#4426)
* fix(k8s/application): validate load balancer ports inputs

* fix(k8s/application): allow user to only change the protocol on the first port mapping
2021-01-15 14:51:36 +13:00
Olli Janatuinen
7a03351df8 dep(api): Support Docker Stack 3.8 (#4333)
- Linux: Update Docker binary to version 19.03.13
- Windows: Update Docker binary to version 19.03.12
2021-01-15 10:05:33 +13:00
Alice Groux
0c2987893d feat(app/images): in advanced mode, remove tooltip and add an information message (#4528) 2021-01-14 15:04:44 +13:00
Alice Groux
d1eddaa188 feat(app/network): rename restrict external acces to the network label and add a tooltip (#4514) 2021-01-14 12:24:56 +13:00
Anthony Lapenna
d336ada3c2 feat(k8s/application): review application creation warning style (#4613) 2021-01-13 16:13:27 +13:00
Avadhut Tanugade
839198fbff #4424 style(stack-details): shift button position in stack details (#4439) 2021-01-13 12:19:18 +13:00
Chaim Lev-Ari
486ffa5bbd chore(webpack): add source maps (#4471)
* chore(webpack): add source maps

* feat(build): fetch source maps for 3rd party libs
2021-01-13 10:40:09 +13:00
Maxime Bajeux
4cd468ce21 Can't create kubernetes resources with a username longer than 63 characters (#4672)
* fix(kubernetes): truncate username when we create resource

* fix(k8s): remove forbidden characters in owner label
2021-01-12 14:35:59 +13:00
Chaim Lev-Ari
cbd7fdc62e feat(docker/stacks): introduce date info for stacks (#4660)
* feat(docker/stacks): add creation and update dates

* feat(docker/stacks): put ownership column as the last column

* feat(docker/stacks): fix the no stacks message

* refactor(docker/stacks): make external stacks helpers more readable

* feat(docker/stacks): add updated and created by

* feat(docker/stacks): toggle updated column

* refactor(datatable): create column visibility component

Co-authored-by: alice groux <alice.grx@gmail.com>
2021-01-12 12:38:49 +13:00
DarkAEther
b9fe8009dd feat(image-details): Show labels in images datatable (#4287)
* feat(images): show labels in images datatable

* move labels to image details view
2021-01-11 15:35:19 +13:00
Stéphane Busso
6a504e7134 fix(settings): Use default setting if UserSessionTimeout not set (#4521)
* fix(settings): Use default settings if UserSessionTimeout not set

* Update UserSessionTimeout settings in database if set to empty string
2021-01-11 14:44:15 +13:00
Alice Groux
51ba0876a5 feat(k8s/configuration): rename add ingress controller button and changed information text (#4540) 2021-01-11 12:51:46 +13:00
Alice Groux
769e6a4c6c feat(k8s/configuration): add extra information panel when creating a sensitive configuration (#4541) 2021-01-11 11:30:31 +13:00
106 changed files with 1657 additions and 542 deletions

View File

@@ -1,6 +1,10 @@
---
name: Bug report
about: Create a bug report
title: ''
labels: bug/need-confirmation, kind/bug
assignees: ''
---
<!--

View File

@@ -1,6 +1,10 @@
---
name: Question
about: Ask us a question about Portainer usage or deployment
title: ''
labels: ''
assignees: ''
---
<!--

View File

@@ -1,6 +1,10 @@
---
name: Feature request
about: Suggest a feature/enhancement that should be added in Portainer
title: ''
labels: ''
assignees: ''
---
<!--

View File

@@ -34,9 +34,9 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart
## Getting help
For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products-services/portainer-business-support/
For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products/portainer-business
For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/2019/04/portainer-support-policy/
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
- Issues: https://github.com/portainer/portainer/issues
- FAQ: https://documentation.portainer.io

View File

@@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api/cli"
@@ -17,6 +17,8 @@ import (
"github.com/portainer/portainer/api/git"
"github.com/portainer/portainer/api/http"
"github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/api/http/proxy"
kubeproxy "github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/internal/snapshot"
"github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/kubernetes"
@@ -71,7 +73,12 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port
return store
}
func initComposeStackManager(dataStorePath string, reverseTunnelService portainer.ReverseTunnelService) portainer.ComposeStackManager {
func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper := exec.NewComposeWrapper(assetsPath, proxyManager)
if composeWrapper != nil {
return composeWrapper
}
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
}
@@ -89,6 +96,10 @@ func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error)
return nil, err
}
if settings.UserSessionTimeout == "" {
settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout
dataStore.Settings().UpdateSettings(settings)
}
jwtService, err := jwt.NewService(settings.UserSessionTimeout)
if err != nil {
return nil, err
@@ -380,8 +391,10 @@ func main() {
if err != nil {
log.Fatal(err)
}
kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager()
proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager)
composeStackManager := initComposeStackManager(*flags.Data, reverseTunnelService)
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
kubernetesDeployer := initKubernetesDeployer(*flags.Assets)
@@ -448,27 +461,29 @@ func main() {
}
var server portainer.Server = &http.Server{
ReverseTunnelService: reverseTunnelService,
Status: applicationStatus,
BindAddress: *flags.Addr,
AssetsPath: *flags.Assets,
DataStore: dataStore,
SwarmStackManager: swarmStackManager,
ComposeStackManager: composeStackManager,
KubernetesDeployer: kubernetesDeployer,
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
SignatureService: digitalSignatureService,
SnapshotService: snapshotService,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,
DockerClientFactory: dockerClientFactory,
KubernetesClientFactory: kubernetesClientFactory,
ReverseTunnelService: reverseTunnelService,
Status: applicationStatus,
BindAddress: *flags.Addr,
AssetsPath: *flags.Assets,
DataStore: dataStore,
SwarmStackManager: swarmStackManager,
ComposeStackManager: composeStackManager,
KubernetesDeployer: kubernetesDeployer,
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
LDAPService: ldapService,
OAuthService: oauthService,
GitService: gitService,
ProxyManager: proxyManager,
KubernetesTokenCacheManager: kubernetesTokenCacheManager,
SignatureService: digitalSignatureService,
SnapshotService: snapshotService,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,
DockerClientFactory: dockerClientFactory,
KubernetesClientFactory: kubernetesClientFactory,
}
log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr)

132
api/exec/compose_wrapper.go Normal file
View File

@@ -0,0 +1,132 @@
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
proxyManager *proxy.Manager
}
// NewComposeWrapper returns a docker-compose wrapper if corresponding binary present, otherwise nil
func NewComposeWrapper(binaryPath string, proxyManager *proxy.Manager) *ComposeWrapper {
if !IsBinaryPresent(programPath(binaryPath, "docker-compose")) {
return nil
}
return &ComposeWrapper{
binaryPath: binaryPath,
proxyManager: proxyManager,
}
}
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
func (w *ComposeWrapper) ComposeSyntaxMaxVersion() string {
return portainer.ComposeSyntaxMaxVersion
}
// 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.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
}

View File

@@ -0,0 +1,75 @@
// +build integration
package exec
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
portainer "github.com/portainer/portainer/api"
)
const composeFile = `version: "3.9"
services:
busybox:
image: "alpine:latest"
container_name: "compose_wrapper_test"`
const composedContainerName = "compose_wrapper_test"
func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
dir := t.TempDir()
composeFileName := "compose_wrapper_test.yml"
f, _ := os.Create(filepath.Join(dir, composeFileName))
f.WriteString(composeFile)
stack := &portainer.Stack{
ProjectPath: dir,
EntryPoint: composeFileName,
Name: "project-name",
}
endpoint := &portainer.Endpoint{}
return stack, endpoint
}
func Test_UpAndDown(t *testing.T) {
stack, endpoint := setup(t)
w := NewComposeWrapper("", nil)
err := w.Up(stack, endpoint)
if err != nil {
t.Fatalf("Error calling docker-compose up: %s", err)
}
if containerExists(composedContainerName) == false {
t.Fatal("container should exist")
}
err = w.Down(stack, endpoint)
if err != nil {
t.Fatalf("Error calling docker-compose down: %s", err)
}
if containerExists(composedContainerName) {
t.Fatal("container should be removed")
}
}
func containerExists(contaierName string) bool {
cmd := exec.Command(osProgram("docker"), "ps", "-a", "-f", fmt.Sprintf("name=%s", contaierName))
out, err := cmd.Output()
if err != nil {
log.Fatalf("failed to list containers: %s", err)
}
return strings.Contains(string(out), contaierName)
}

View File

@@ -0,0 +1,143 @@
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))
}
})
}
}

View File

@@ -134,6 +134,8 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPa
if !endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
} else {
args = append(args, "--tlscacert", "''")
}
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {

24
api/exec/utils.go Normal file
View File

@@ -0,0 +1,24 @@
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
}

16
api/exec/utils_test.go Normal file
View File

@@ -0,0 +1,16 @@
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")
}
}

View File

@@ -28,6 +28,7 @@ require (
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 // indirect
golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 // indirect
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45

View File

@@ -262,12 +262,15 @@ github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4=
github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/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=
@@ -392,6 +395,8 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -6,7 +6,7 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/errors"
)
@@ -30,6 +30,7 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
}
hideFields(endpoint)
endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
return response.JSON(w, endpoint)
}

View File

@@ -5,12 +5,11 @@ import (
"strconv"
"strings"
"github.com/portainer/portainer/api"
"github.com/portainer/libhttp/request"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
)
@@ -89,6 +88,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
for idx := range paginatedEndpoints {
hideFields(&paginatedEndpoints[idx])
paginatedEndpoints[idx].ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
}
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))

View File

@@ -27,6 +27,7 @@ type Handler struct {
ProxyManager *proxy.Manager
ReverseTunnelService portainer.ReverseTunnelService
SnapshotService portainer.SnapshotService
ComposeStackManager portainer.ComposeStackManager
}
// NewHandler creates a handler to manage endpoint operations.

View File

@@ -7,7 +7,7 @@ import (
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
)
@@ -71,7 +71,7 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
registry.Username = *payload.Username
}
if payload.Password != nil {
if payload.Password != nil && *payload.Password != "" {
registry.Password = *payload.Password
}

View File

@@ -7,11 +7,12 @@ import (
"regexp"
"strconv"
"strings"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
)
@@ -60,13 +61,14 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerComposeStack,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: payload.Env,
Status: portainer.StackStatusActive,
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerComposeStack,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
stackFolder := strconv.Itoa(int(stack.ID))
@@ -89,6 +91,8 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter,
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.CreatedBy = config.user.Username
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
@@ -146,13 +150,14 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerComposeStack,
EndpointID: endpoint.ID,
EntryPoint: payload.ComposeFilePathInRepository,
Env: payload.Env,
Status: portainer.StackStatusActive,
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerComposeStack,
EndpointID: endpoint.ID,
EntryPoint: payload.ComposeFilePathInRepository,
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
@@ -185,6 +190,8 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.CreatedBy = config.user.Username
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
@@ -242,13 +249,14 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerComposeStack,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: payload.Env,
Status: portainer.StackStatusActive,
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerComposeStack,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
stackFolder := strconv.Itoa(int(stack.ID))
@@ -271,6 +279,8 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.CreatedBy = config.user.Username
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
@@ -347,7 +357,6 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
!isAdminOrEndpointAdmin {
composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint)
stackContent, err := handler.FileService.GetFileContent(composeFilePath)
if err != nil {
return err

View File

@@ -6,11 +6,12 @@ import (
"path"
"strconv"
"strings"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/http/security"
)
@@ -55,14 +56,15 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerSwarmStack,
SwarmID: payload.SwarmID,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: payload.Env,
Status: portainer.StackStatusActive,
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerSwarmStack,
SwarmID: payload.SwarmID,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
stackFolder := strconv.Itoa(int(stack.ID))
@@ -85,6 +87,8 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.CreatedBy = config.user.Username
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
@@ -145,14 +149,15 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerSwarmStack,
SwarmID: payload.SwarmID,
EndpointID: endpoint.ID,
EntryPoint: payload.ComposeFilePathInRepository,
Env: payload.Env,
Status: portainer.StackStatusActive,
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerSwarmStack,
SwarmID: payload.SwarmID,
EndpointID: endpoint.ID,
EntryPoint: payload.ComposeFilePathInRepository,
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
@@ -185,6 +190,8 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.CreatedBy = config.user.Username
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}
@@ -249,14 +256,15 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerSwarmStack,
SwarmID: payload.SwarmID,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: payload.Env,
Status: portainer.StackStatusActive,
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerSwarmStack,
SwarmID: payload.SwarmID,
EndpointID: endpoint.ID,
EntryPoint: filesystem.ComposeFileDefaultName,
Env: payload.Env,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
}
stackFolder := strconv.Itoa(int(stack.ID))
@@ -279,6 +287,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
}
stack.CreatedBy = config.user.Username
err = handler.DataStore.Stack().CreateStack(stack)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err}

View File

@@ -7,7 +7,7 @@ import (
"github.com/gorilla/mux"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
@@ -78,6 +78,17 @@ func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedR
return handler.userIsAdminOrEndpointAdmin(user, endpointID)
}
func (handler *Handler) userIsAdmin(userID portainer.UserID) (bool, error) {
user, err := handler.DataStore.User().User(userID)
if err != nil {
return false, err
}
isAdmin := user.Role == portainer.AdministratorRole
return isAdmin, nil
}
func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) {
isAdmin := user.Role == portainer.AdministratorRole

View File

@@ -183,9 +183,20 @@ func (handler *Handler) isValidStackFile(stackFileContent []byte, settings *port
}
func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *portainer.Stack, userID portainer.UserID) *httperror.HandlerError {
resourceControl := authorization.NewPrivateResourceControl(stack.Name, portainer.StackResourceControl, userID)
var resourceControl *portainer.ResourceControl
err := handler.DataStore.ResourceControl().CreateResourceControl(resourceControl)
isAdmin, err := handler.userIsAdmin(userID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
}
if isAdmin {
resourceControl = authorization.NewAdministratorsOnlyResourceControl(stack.Name, portainer.StackResourceControl)
} else {
resourceControl = authorization.NewPrivateResourceControl(stack.Name, portainer.StackResourceControl, userID)
}
err = handler.DataStore.ResourceControl().CreateResourceControl(resourceControl)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist resource control inside the database", err}
}

View File

@@ -155,5 +155,6 @@ func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer.
if stack.Type == portainer.DockerSwarmStack {
return handler.SwarmStackManager.Remove(stack, endpoint)
}
return handler.ComposeStackManager.Down(stack, endpoint)
}

View File

@@ -4,13 +4,13 @@ import (
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
)

View File

@@ -4,15 +4,14 @@ import (
"errors"
"net/http"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
)
// POST request on /api/stacks/:id/stop

View File

@@ -4,12 +4,13 @@ import (
"errors"
"net/http"
"strconv"
"time"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
@@ -135,6 +136,9 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
return configErr
}
stack.UpdateDate = time.Now().Unix()
stack.UpdatedBy = config.user.Username
err = handler.deployComposeStack(config)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}
@@ -163,6 +167,9 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
return configErr
}
stack.UpdateDate = time.Now().Unix()
stack.UpdatedBy = config.user.Username
err = handler.deploySwarmStack(config)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}

View File

@@ -0,0 +1,88 @@
package factory
import (
"fmt"
"log"
"net"
"net/http"
"net/url"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/proxy/factory/dockercompose"
)
// ProxyServer provide an extedned proxy with a local server to forward requests
type ProxyServer struct {
server *http.Server
Port int
}
func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endpoint) (*ProxyServer, error) {
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
return &ProxyServer{
Port: factory.reverseTunnelService.GetTunnelDetails(endpoint.ID).Port,
}, nil
}
endpointURL, err := url.Parse(endpoint.URL)
if err != nil {
return nil, err
}
endpointURL.Scheme = "http"
httpTransport := &http.Transport{}
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
config, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
httpTransport.TLSClientConfig = config
endpointURL.Scheme = "https"
}
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
proxy.Transport = dockercompose.NewAgentTransport(factory.signatureService, httpTransport)
proxyServer := &ProxyServer{
&http.Server{
Handler: proxy,
},
0,
}
return proxyServer, proxyServer.start()
}
func (proxy *ProxyServer) start() error {
listener, err := net.Listen("tcp", ":0")
if err != nil {
return err
}
proxy.Port = listener.Addr().(*net.TCPAddr).Port
go func() {
proxyHost := fmt.Sprintf("127.0.0.1:%d", proxy.Port)
log.Printf("Starting Proxy server on %s...\n", proxyHost)
err := proxy.server.Serve(listener)
log.Printf("Exiting Proxy server %s\n", proxyHost)
if err != http.ErrServerClosed {
log.Printf("Proxy server %s exited with an error: %s\n", proxyHost, err)
}
}()
return nil
}
// Close shuts down the server
func (proxy *ProxyServer) Close() {
if proxy.server != nil {
proxy.server.Close()
}
}

View File

@@ -0,0 +1,40 @@
package dockercompose
import (
"net/http"
portainer "github.com/portainer/portainer/api"
)
type (
// AgentTransport is an http.Transport wrapper that adds custom http headers to communicate to an Agent
AgentTransport struct {
httpTransport *http.Transport
signatureService portainer.DigitalSignatureService
endpointIdentifier portainer.EndpointID
}
)
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
func NewAgentTransport(signatureService portainer.DigitalSignatureService, httpTransport *http.Transport) *AgentTransport {
transport := &AgentTransport{
httpTransport: httpTransport,
signatureService: signatureService,
}
return transport
}
// RoundTrip is the implementation of the the http.RoundTripper interface
func (transport *AgentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
return transport.httpTransport.RoundTrip(request)
}

View File

@@ -1,6 +1,7 @@
package proxy
import (
"fmt"
"net/http"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
@@ -43,13 +44,19 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo
return nil, err
}
manager.endpointProxies.Set(string(endpoint.ID), proxy)
manager.endpointProxies.Set(fmt.Sprint(endpoint.ID), proxy)
return proxy, nil
}
// CreateComposeProxyServer creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies.
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
func (manager *Manager) CreateComposeProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) {
return manager.proxyFactory.NewDockerComposeAgentProxy(endpoint)
}
// GetEndpointProxy returns the proxy associated to a key
func (manager *Manager) GetEndpointProxy(endpoint *portainer.Endpoint) http.Handler {
proxy, ok := manager.endpointProxies.Get(string(endpoint.ID))
proxy, ok := manager.endpointProxies.Get(fmt.Sprint(endpoint.ID))
if !ok {
return nil
}
@@ -61,7 +68,7 @@ func (manager *Manager) GetEndpointProxy(endpoint *portainer.Endpoint) http.Hand
// and cleans the k8s endpoint client cache. DeleteEndpointProxy
// is currently only called for edge connection clean up.
func (manager *Manager) DeleteEndpointProxy(endpoint *portainer.Endpoint) {
manager.endpointProxies.Remove(string(endpoint.ID))
manager.endpointProxies.Remove(fmt.Sprint(endpoint.ID))
manager.k8sClientFactory.RemoveKubeClient(endpoint)
}

View File

@@ -39,39 +39,41 @@ import (
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/kubernetes/cli"
)
// Server implements the portainer.Server interface
type Server struct {
BindAddress string
AssetsPath string
Status *portainer.Status
ReverseTunnelService portainer.ReverseTunnelService
ComposeStackManager portainer.ComposeStackManager
CryptoService portainer.CryptoService
SignatureService portainer.DigitalSignatureService
SnapshotService portainer.SnapshotService
FileService portainer.FileService
DataStore portainer.DataStore
GitService portainer.GitService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
SwarmStackManager portainer.SwarmStackManager
Handler *handler.Handler
SSL bool
SSLCert string
SSLKey string
DockerClientFactory *docker.ClientFactory
KubernetesClientFactory *cli.ClientFactory
KubernetesDeployer portainer.KubernetesDeployer
BindAddress string
AssetsPath string
Status *portainer.Status
ReverseTunnelService portainer.ReverseTunnelService
ComposeStackManager portainer.ComposeStackManager
CryptoService portainer.CryptoService
SignatureService portainer.DigitalSignatureService
SnapshotService portainer.SnapshotService
FileService portainer.FileService
DataStore portainer.DataStore
GitService portainer.GitService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
OAuthService portainer.OAuthService
SwarmStackManager portainer.SwarmStackManager
ProxyManager *proxy.Manager
KubernetesTokenCacheManager *kubernetes.TokenCacheManager
Handler *handler.Handler
SSL bool
SSLCert string
SSLKey string
DockerClientFactory *docker.ClientFactory
KubernetesClientFactory *cli.ClientFactory
KubernetesDeployer portainer.KubernetesDeployer
}
// Start starts the HTTP server
func (server *Server) Start() error {
kubernetesTokenCacheManager := kubernetes.NewTokenCacheManager()
proxyManager := proxy.NewManager(server.DataStore, server.SignatureService, server.ReverseTunnelService, server.DockerClientFactory, server.KubernetesClientFactory, kubernetesTokenCacheManager)
kubernetesTokenCacheManager := server.KubernetesTokenCacheManager
requestBouncer := security.NewRequestBouncer(server.DataStore, server.JWTService)
@@ -82,7 +84,7 @@ func (server *Server) Start() error {
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
authHandler.LDAPService = server.LDAPService
authHandler.ProxyManager = proxyManager
authHandler.ProxyManager = server.ProxyManager
authHandler.KubernetesTokenCacheManager = kubernetesTokenCacheManager
authHandler.OAuthService = server.OAuthService
@@ -116,10 +118,10 @@ func (server *Server) Start() error {
var endpointHandler = endpoints.NewHandler(requestBouncer)
endpointHandler.DataStore = server.DataStore
endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = proxyManager
endpointHandler.ProxyManager = server.ProxyManager
endpointHandler.SnapshotService = server.SnapshotService
endpointHandler.ProxyManager = proxyManager
endpointHandler.ReverseTunnelService = server.ReverseTunnelService
endpointHandler.ComposeStackManager = server.ComposeStackManager
var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer)
endpointEdgeHandler.DataStore = server.DataStore
@@ -131,7 +133,7 @@ func (server *Server) Start() error {
var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer)
endpointProxyHandler.DataStore = server.DataStore
endpointProxyHandler.ProxyManager = proxyManager
endpointProxyHandler.ProxyManager = server.ProxyManager
endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"))
@@ -141,7 +143,7 @@ func (server *Server) Start() error {
var registryHandler = registries.NewHandler(requestBouncer)
registryHandler.DataStore = server.DataStore
registryHandler.FileService = server.FileService
registryHandler.ProxyManager = proxyManager
registryHandler.ProxyManager = server.ProxyManager
var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer)
resourceControlHandler.DataStore = server.DataStore

View File

@@ -6,6 +6,21 @@ import (
"github.com/portainer/portainer/api"
)
// NewAdministratorsOnlyResourceControl will create a new administrators only resource control associated to the resource specified by the
// identifier and type parameters.
func NewAdministratorsOnlyResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType) *portainer.ResourceControl {
return &portainer.ResourceControl{
Type: resourceType,
ResourceID: resourceIdentifier,
SubResourceIDs: []string{},
UserAccesses: []portainer.UserResourceAccess{},
TeamAccesses: []portainer.TeamResourceAccess{},
AdministratorsOnly: true,
Public: false,
System: false,
}
}
// NewPrivateResourceControl will create a new private resource control associated to the resource specified by the
// identifier and type parameters. It automatically assigns it to the user specified by the userID parameter.
func NewPrivateResourceControl(resourceIdentifier string, resourceType portainer.ResourceControlType, userID portainer.UserID) *portainer.ResourceControl {

View File

@@ -13,11 +13,12 @@ import (
"github.com/portainer/libcompose/lookup"
"github.com/portainer/libcompose/project"
"github.com/portainer/libcompose/project/options"
"github.com/portainer/portainer/api"
portainer "github.com/portainer/portainer/api"
)
const (
dockerClientVersion = "1.24"
dockerClientVersion = "1.24"
composeSyntaxMaxVersion = "2"
)
// ComposeStackManager represents a service for managing compose stacks.
@@ -58,6 +59,11 @@ func (manager *ComposeStackManager) createClient(endpoint *portainer.Endpoint) (
return client.NewDefaultFactory(clientOpts)
}
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string {
return composeSyntaxMaxVersion
}
// Up will deploy a compose stack (equivalent of docker-compose up)
func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {

View File

@@ -190,24 +190,25 @@ type (
// Endpoint represents a Docker endpoint with all the info required
// to connect to it
Endpoint struct {
ID EndpointID `json:"Id"`
Name string `json:"Name"`
Type EndpointType `json:"Type"`
URL string `json:"URL"`
GroupID EndpointGroupID `json:"GroupId"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
Extensions []EndpointExtension `json:"Extensions"`
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
TagIDs []TagID `json:"TagIds"`
Status EndpointStatus `json:"Status"`
Snapshots []DockerSnapshot `json:"Snapshots"`
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
EdgeID string `json:"EdgeID,omitempty"`
EdgeKey string `json:"EdgeKey"`
EdgeCheckinInterval int `json:"EdgeCheckinInterval"`
Kubernetes KubernetesData `json:"Kubernetes"`
ID EndpointID `json:"Id"`
Name string `json:"Name"`
Type EndpointType `json:"Type"`
URL string `json:"URL"`
GroupID EndpointGroupID `json:"GroupId"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
Extensions []EndpointExtension `json:"Extensions"`
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
TagIDs []TagID `json:"TagIds"`
Status EndpointStatus `json:"Status"`
Snapshots []DockerSnapshot `json:"Snapshots"`
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
EdgeID string `json:"EdgeID,omitempty"`
EdgeKey string `json:"EdgeKey"`
EdgeCheckinInterval int `json:"EdgeCheckinInterval"`
Kubernetes KubernetesData `json:"Kubernetes"`
ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion"`
// Deprecated fields
// Deprecated in DBVersion == 4
@@ -554,6 +555,10 @@ type (
Env []Pair `json:"Env"`
ResourceControl *ResourceControl `json:"ResourceControl"`
Status StackStatus `json:"Status"`
CreationDate int64
CreatedBy string
UpdateDate int64
UpdatedBy string
ProjectPath string
}
@@ -774,6 +779,7 @@ type (
// ComposeStackManager represents a service to manage Compose stacks
ComposeStackManager interface {
ComposeSyntaxMaxVersion() string
Up(stack *Stack, endpoint *Endpoint) error
Down(stack *Stack, endpoint *Endpoint) error
}
@@ -1122,6 +1128,8 @@ const (
APIVersion = "2.0.1"
// DBVersion is the version number of the Portainer database
DBVersion = 25
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
ComposeSyntaxMaxVersion = "3.9"
// AssetsServerURL represents the URL of the Portainer asset server
AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com"
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved

View File

@@ -4,100 +4,8 @@
<div class="toolBar">
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
<div class="settings">
<span
class="setting"
ng-class="{ 'setting-active': $ctrl.columnVisibility.state.open }"
uib-dropdown
dropdown-append-to-body
auto-close="disabled"
is-open="$ctrl.columnVisibility.state.open"
>
<span uib-dropdown-toggle><i class="fa fa-columns space-right" aria-hidden="true"></i>Columns</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Show / Hide Columns
</div>
<div class="menuContent">
<div class="md-checkbox">
<input
id="col_vis_state"
ng-change="$ctrl.onColumnVisibilityChange($ctrl.columnVisibility)"
type="checkbox"
ng-model="$ctrl.columnVisibility.columns.state.display"
/>
<label for="col_vis_state" ng-bind="$ctrl.columnVisibility.columns.state.label"></label>
</div>
<div class="md-checkbox">
<input
id="col_vis_actions"
ng-change="$ctrl.onColumnVisibilityChange($ctrl.columnVisibility)"
type="checkbox"
ng-model="$ctrl.columnVisibility.columns.actions.display"
/>
<label for="col_vis_actions" ng-bind="$ctrl.columnVisibility.columns.actions.label"></label>
</div>
<div class="md-checkbox">
<input
id="col_vis_stack"
ng-change="$ctrl.onColumnVisibilityChange($ctrl.columnVisibility)"
type="checkbox"
ng-model="$ctrl.columnVisibility.columns.stack.display"
/>
<label for="col_vis_stack" ng-bind="$ctrl.columnVisibility.columns.stack.label"></label>
</div>
<div class="md-checkbox">
<input
id="col_vis_image"
ng-change="$ctrl.onColumnVisibilityChange($ctrl.columnVisibility)"
type="checkbox"
ng-model="$ctrl.columnVisibility.columns.image.display"
/>
<label for="col_vis_image" ng-bind="$ctrl.columnVisibility.columns.image.label"></label>
</div>
<div class="md-checkbox">
<input
id="col_vis_created"
ng-change="$ctrl.onColumnVisibilityChange($ctrl.columnVisibility)"
type="checkbox"
ng-model="$ctrl.columnVisibility.columns.created.display"
/>
<label for="col_vis_created" ng-bind="$ctrl.columnVisibility.columns.created.label"></label>
</div>
<div class="md-checkbox" ng-if="$ctrl.showHostColumn">
<input
id="col_vis_host"
ng-change="$ctrl.onColumnVisibilityChange($ctrl.columnVisibility)"
type="checkbox"
ng-model="$ctrl.columnVisibility.columns.host.display"
/>
<label for="col_vis_host" ng-bind="$ctrl.columnVisibility.columns.host.label"></label>
</div>
<div class="md-checkbox">
<input
id="col_vis_ports"
ng-change="$ctrl.onColumnVisibilityChange($ctrl.columnVisibility)"
type="checkbox"
ng-model="$ctrl.columnVisibility.columns.ports.display"
/>
<label for="col_vis_ports" ng-bind="$ctrl.columnVisibility.columns.ports.label"></label>
</div>
<div class="md-checkbox">
<input
id="col_vis_ownership"
ng-change="$ctrl.onColumnVisibilityChange($ctrl.columnVisibility)"
type="checkbox"
ng-model="$ctrl.columnVisibility.columns.ownership.display"
/>
<label for="col_vis_ownership" ng-bind="$ctrl.columnVisibility.columns.ownership.label"></label>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.columnVisibility.state.open = false;">Close</a>
</div>
</div>
</div>
</span>
<datatable-columns-visibility columns="$ctrl.columnVisibility.columns" on-change="($ctrl.onColumnVisibilityChange)"></datatable-columns-visibility>
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>

View File

@@ -36,9 +36,6 @@ angular.module('portainer.docker').controller('ContainersDatatableController', [
};
this.columnVisibility = {
state: {
open: false,
},
columns: {
state: {
label: 'State',
@@ -75,9 +72,11 @@ angular.module('portainer.docker').controller('ContainersDatatableController', [
},
};
this.onColumnVisibilityChange = function (columnVisibility) {
DatatableService.setColumnVisibilitySettings(this.tableKey, columnVisibility);
};
this.onColumnVisibilityChange = onColumnVisibilityChange.bind(this);
function onColumnVisibilityChange(columns) {
this.columnVisibility.columns = columns;
DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility);
}
this.onSelectionChanged = function () {
this.updateSelectionState();
@@ -199,7 +198,6 @@ angular.module('portainer.docker').controller('ContainersDatatableController', [
var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
if (storedColumnVisibility !== null) {
this.columnVisibility = storedColumnVisibility;
this.columnVisibility.state.open = false;
}
};
},

View File

@@ -36,10 +36,13 @@
<!-- don't use registry -->
<div ng-if="!$ctrl.model.UseRegistry">
<div class="form-group">
<label for="image_name" ng-class="$ctrl.labelClass" class="control-label text-left"
>Image
<portainer-tooltip position="bottom" message="Image and repository should be publicly available."></portainer-tooltip>
</label>
<span class="small">
<p class="text-muted" style="margin-left: 15px;">
<i class="fa fa-exclamation-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
When using advanced mode, image and repository <b>must be</b> publicly available.
</p>
</span>
<label for="image_name" ng-class="$ctrl.labelClass" class="control-label text-left">Image </label>
<div ng-class="$ctrl.inputClass">
<input type="text" class="form-control" ng-model="$ctrl.model.Image" name="image_name" placeholder="e.g. registry:port/myImage:myTag" required />
</div>

View File

@@ -4,7 +4,6 @@ export function ImageViewModel(data) {
this.Repository = data.Repository;
this.Created = data.Created;
this.Checked = false;
this.RepoTags = data.RepoTags;
if (!this.RepoTags && data.RepoDigests) {
this.RepoTags = [];
@@ -21,6 +20,7 @@ export function ImageViewModel(data) {
if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) {
this.NodeName = data.Portainer.Agent.NodeName;
}
this.Labels = data.Labels;
}
export function ImageBuildModel(data) {

View File

@@ -16,4 +16,5 @@ export function ImageDetailsViewModel(data) {
this.ExposedPorts = data.ContainerConfig.ExposedPorts ? Object.keys(data.ContainerConfig.ExposedPorts) : [];
this.Volumes = data.ContainerConfig.Volumes ? Object.keys(data.ContainerConfig.Volumes) : [];
this.Env = data.ContainerConfig.Env ? data.ContainerConfig.Env : [];
this.Labels = data.ContainerConfig.Labels;
}

View File

@@ -128,6 +128,17 @@
<td>Build</td>
<td>Docker {{ image.DockerVersion }} on {{ image.Os }}, {{ image.Architecture }}</td>
</tr>
<tr ng-if="!(image.Labels | emptyobject)">
<td>Labels</td>
<td>
<table class="table table-bordered table-condensed">
<tr ng-repeat="(k, v) in image.Labels">
<td>{{ k }}</td>
<td>{{ v }}</td>
</tr>
</table>
</td>
</tr>
<tr ng-if="image.Author">
<td>Author</td>
<td>{{ image.Author }}</td>

View File

@@ -196,7 +196,8 @@
<div class="form-group" ng-hide="config.Driver === 'macvlan' && formValues.Macvlan.Scope === 'local'">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Restrict external access to the network
Isolated network
<portainer-tooltip position="bottom" message="An isolated network has no inbound or outbound communications."></portainer-tooltip>
</label>
<label name="ownership" class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="config.Internal" />

View File

@@ -309,8 +309,8 @@
<!-- volume-type -->
<div class="input-group col-sm-5" style="margin-left: 5px; vertical-align: top;">
<div class="btn-group btn-group-sm" ng-if="allowBindMounts">
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'bind'" ng-click="volume.Id = ''">Bind</label>
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'volume'" ng-click="volume.Source = null">Volume</label>
<label class="btn btn-primary" ng-model="volume.Type" uib-btn-radio="'bind'" ng-click="volume.Source = null">Bind</label>
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
@@ -333,7 +333,7 @@
ng-model="volume.Source"
ng-options="vol as ((vol.Id|truncate:30) + ' - ' + (vol.Driver|truncate:30)) for vol in availableVolumes"
>
<option selected disabled hidden value="">Select a volume</option>
<option selected disabled value="">Select a volume</option>
</select>
</div>
<div class="small text-warning" ng-show="!volume.Source"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Source is required. </div>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html
><html lang="en" ng-app="<%= name %>">
><html lang="en" ng-app="<%= name %>" ng-strict-di>
<head>
<meta charset="utf-8" />
<title>Portainer</title>

View File

@@ -71,7 +71,7 @@
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th>
<th style="width: 55px;">
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
@@ -142,8 +142,9 @@
</td>
</tr>
<tr dir-paginate-end ng-show="item.Expanded" ng-repeat="app in item.Applications" ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }">
<td colspan="5">
<a ui-sref="kubernetes.applications.application({ name: app.Name, namespace: app.ResourcePool })" style="margin-left: 25px;">{{ app.Name }}</a>
<td></td>
<td colspan="4">
<a ui-sref="kubernetes.applications.application({ name: app.Name, namespace: app.ResourcePool })">{{ app.Name }}</a>
<span style="margin-left: 5px;" class="label label-primary image-tag" ng-if="!$ctrl.isSystemNamespace(app.ResourcePool) && $ctrl.isExternalApplication(app)"
>external</span
>

View File

@@ -3,13 +3,13 @@
Data
</div>
<div class="form-group">
<div class="form-group" ng-if="$ctrl.isCreation">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-if="$ctrl.formValues.IsSimple" ng-click="$ctrl.formValues.IsSimple = false">
<a class="small interactive" ng-if="$ctrl.formValues.IsSimple" ng-click="$ctrl.showAdvancedMode()">
<i class="fa fa-list-ol space-right" aria-hidden="true"></i> Advanced mode
</a>
<a class="small interactive" ng-if="!$ctrl.formValues.IsSimple" ng-click="$ctrl.formValues.IsSimple = true">
<a class="small interactive" ng-if="!$ctrl.formValues.IsSimple" ng-click="$ctrl.showSimpleMode()">
<i class="fa fa-edit space-right" aria-hidden="true"></i> Simple mode
</a>
</p>
@@ -61,7 +61,7 @@
</div>
</div>
<div class="form-group" ng-if="$ctrl.formValues.IsSimple">
<div class="form-group" ng-if="$ctrl.formValues.IsSimple && !entry.IsBinary">
<label for="configuration_data_value_{{ index }}" class="col-sm-1 control-label text-left">Value</label>
<div class="col-sm-11">
<textarea
@@ -80,6 +80,13 @@
</div>
</div>
<div class="form-group" ng-if="$ctrl.formValues.IsSimple && entry.IsBinary">
<label for="configuration_data_value_{{ index }}" class="col-sm-1 control-label text-left">Value</label>
<div class="col-sm-11 control-label small text-muted text-left"
>Binary data <portainer-tooltip position="bottom" message="This key holds binary data and cannot be displayed."></portainer-tooltip
></div>
</div>
<div class="form-group" ng-if="$ctrl.formValues.IsSimple">
<div class="col-sm-1"></div>
<div class="col-sm-11">

View File

@@ -4,5 +4,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
bindings: {
formValues: '=',
isValid: '=',
isCreation: '=',
},
});

View File

@@ -1,7 +1,10 @@
import angular from 'angular';
import _ from 'lodash-es';
import { KubernetesConfigurationFormValuesDataEntry } from 'Kubernetes/models/configuration/formvalues';
import chardet from 'chardet';
import { Base64 } from 'js-base64';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
class KubernetesConfigurationDataController {
/* @ngInject */
@@ -12,6 +15,8 @@ class KubernetesConfigurationDataController {
this.editorUpdateAsync = this.editorUpdateAsync.bind(this);
this.onFileLoad = this.onFileLoad.bind(this);
this.onFileLoadAsync = this.onFileLoadAsync.bind(this);
this.showSimpleMode = this.showSimpleMode.bind(this);
this.showAdvancedMode = this.showAdvancedMode.bind(this);
}
onChangeKey() {
@@ -20,7 +25,7 @@ class KubernetesConfigurationDataController {
}
addEntry() {
this.formValues.Data.push(new KubernetesConfigurationFormValuesDataEntry());
this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry());
}
removeEntry(index) {
@@ -37,9 +42,20 @@ class KubernetesConfigurationDataController {
}
async onFileLoadAsync(event) {
const entry = new KubernetesConfigurationFormValuesDataEntry();
const entry = new KubernetesConfigurationFormValuesEntry();
const encoding = chardet.detect(Buffer.from(event.target.result));
const decoder = new TextDecoder(encoding);
entry.Key = event.target.fileName;
entry.Value = event.target.result;
entry.IsBinary = KubernetesConfigurationHelper.isBinary(encoding);
if (!entry.IsBinary) {
entry.Value = decoder.decode(event.target.result);
} else {
const stringValue = decoder.decode(event.target.result);
entry.Value = Base64.encode(stringValue);
}
this.formValues.Data.push(entry);
this.onChangeKey();
}
@@ -53,10 +69,20 @@ class KubernetesConfigurationDataController {
const temporaryFileReader = new FileReader();
temporaryFileReader.fileName = file.name;
temporaryFileReader.onload = this.onFileLoad;
temporaryFileReader.readAsText(file);
temporaryFileReader.readAsArrayBuffer(file);
}
}
showSimpleMode() {
this.formValues.IsSimple = true;
this.formValues.Data = KubernetesConfigurationHelper.parseYaml(this.formValues);
}
showAdvancedMode() {
this.formValues.IsSimple = false;
this.formValues.DataYaml = KubernetesConfigurationHelper.parseData(this.formValues);
}
$onInit() {
this.state = {
duplicateKeys: {},

View File

@@ -1,8 +1,8 @@
import _ from 'lodash-es';
import YAML from 'yaml';
import { KubernetesConfigMap } from 'Kubernetes/models/config-map/models';
import { KubernetesConfigMapCreatePayload, KubernetesConfigMapUpdatePayload } from 'Kubernetes/models/config-map/payloads';
import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
class KubernetesConfigMapConverter {
/**
@@ -16,7 +16,23 @@ class KubernetesConfigMapConverter {
res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.CreationDate = data.metadata.creationTimestamp;
res.Yaml = yaml ? yaml.data : '';
res.Data = data.data;
res.Data = _.concat(
_.map(data.data, (value, key) => {
const entry = new KubernetesConfigurationFormValuesEntry();
entry.Key = key;
entry.Value = value;
return entry;
}),
_.map(data.binaryData, (value, key) => {
const entry = new KubernetesConfigurationFormValuesEntry();
entry.Key = key;
entry.Value = value;
entry.IsBinary = true;
return entry;
})
);
return res;
}
@@ -39,8 +55,16 @@ class KubernetesConfigMapConverter {
const res = new KubernetesConfigMapCreatePayload();
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace;
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = data.ConfigurationOwner;
res.data = data.Data;
const configurationOwner = _.truncate(data.ConfigurationOwner, { length: 63, omission: '' });
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner;
_.forEach(data.Data, (entry) => {
if (entry.IsBinary) {
res.binaryData[entry.Key] = entry.Value;
} else {
res.data[entry.Key] = entry.Value;
}
});
return res;
}
@@ -53,7 +77,13 @@ class KubernetesConfigMapConverter {
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace;
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = data.ConfigurationOwner;
res.data = data.Data;
_.forEach(data.Data, (entry) => {
if (entry.IsBinary) {
res.binaryData[entry.Key] = entry.Value;
} else {
res.data[entry.Key] = entry.Value;
}
});
return res;
}
@@ -63,18 +93,7 @@ class KubernetesConfigMapConverter {
res.Name = formValues.Name;
res.Namespace = formValues.ResourcePool.Namespace.Name;
res.ConfigurationOwner = formValues.ConfigurationOwner;
if (formValues.IsSimple) {
res.Data = _.reduce(
formValues.Data,
(acc, entry) => {
acc[entry.Key] = entry.Value;
return acc;
},
{}
);
} else {
res.Data = YAML.parse(formValues.DataYaml);
}
res.Data = formValues.Data;
return res;
}
}

View File

@@ -1,3 +1,4 @@
import _ from 'lodash-es';
import { KubernetesConfiguration, KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
class KubernetesConfigurationConverter {
@@ -9,7 +10,9 @@ class KubernetesConfigurationConverter {
res.Namespace = secret.Namespace;
res.CreationDate = secret.CreationDate;
res.Yaml = secret.Yaml;
res.Data = secret.Data;
_.forEach(secret.Data, (entry) => {
res.Data[entry.Key] = entry.Value;
});
res.ConfigurationOwner = secret.ConfigurationOwner;
return res;
}
@@ -22,7 +25,9 @@ class KubernetesConfigurationConverter {
res.Namespace = configMap.Namespace;
res.CreationDate = configMap.CreationDate;
res.Yaml = configMap.Yaml;
res.Data = configMap.Data;
_.forEach(configMap.Data, (entry) => {
res.Data[entry.Key] = entry.Value;
});
res.ConfigurationOwner = configMap.ConfigurationOwner;
return res;
}

View File

@@ -1,5 +1,4 @@
import * as JsonPatch from 'fast-json-patch';
import { KubernetesDaemonSet } from 'Kubernetes/models/daemon-set/models';
import { KubernetesDaemonSetCreatePayload } from 'Kubernetes/models/daemon-set/payloads';
import {

View File

@@ -1,3 +1,4 @@
import _ from 'lodash-es';
import { KubernetesNamespace } from 'Kubernetes/models/namespace/models';
import { KubernetesNamespaceCreatePayload } from 'Kubernetes/models/namespace/payloads';
import { KubernetesPortainerResourcePoolNameLabel, KubernetesPortainerResourcePoolOwnerLabel } from 'Kubernetes/models/resource-pool/models';
@@ -20,7 +21,8 @@ class KubernetesNamespaceConverter {
res.metadata.name = namespace.Name;
res.metadata.labels[KubernetesPortainerResourcePoolNameLabel] = namespace.ResourcePoolName;
if (namespace.ResourcePoolOwner) {
res.metadata.labels[KubernetesPortainerResourcePoolOwnerLabel] = namespace.ResourcePoolOwner;
const resourcePoolOwner = _.truncate(namespace.ResourcePoolOwner, { length: 63, omission: '' });
res.metadata.labels[KubernetesPortainerResourcePoolOwnerLabel] = resourcePoolOwner;
}
return res;
}

View File

@@ -1,16 +1,30 @@
import { KubernetesSecretCreatePayload, KubernetesSecretUpdatePayload } from 'Kubernetes/models/secret/payloads';
import { KubernetesApplicationSecret } from 'Kubernetes/models/secret/models';
import YAML from 'yaml';
import { KubernetesPortainerConfigurationDataAnnotation } from 'Kubernetes/models/configuration/models';
import _ from 'lodash-es';
import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
class KubernetesSecretConverter {
static createPayload(secret) {
const res = new KubernetesSecretCreatePayload();
res.metadata.name = secret.Name;
res.metadata.namespace = secret.Namespace;
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = secret.ConfigurationOwner;
res.stringData = secret.Data;
const configurationOwner = _.truncate(secret.ConfigurationOwner, { length: 63, omission: '' });
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner;
let annotation = '';
_.forEach(secret.Data, (entry) => {
if (entry.IsBinary) {
res.data[entry.Key] = entry.Value;
annotation += annotation !== '' ? '|' + entry.Key : entry.Key;
} else {
res.stringData[entry.Key] = entry.Value;
}
});
if (annotation !== '') {
res.metadata.annotations[KubernetesPortainerConfigurationDataAnnotation] = annotation;
}
return res;
}
@@ -19,7 +33,19 @@ class KubernetesSecretConverter {
res.metadata.name = secret.Name;
res.metadata.namespace = secret.Namespace;
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = secret.ConfigurationOwner;
res.stringData = secret.Data;
let annotation = '';
_.forEach(secret.Data, (entry) => {
if (entry.IsBinary) {
res.data[entry.Key] = entry.Value;
annotation += annotation !== '' ? '|' + entry.Key : entry.Key;
} else {
res.stringData[entry.Key] = entry.Value;
}
});
if (annotation !== '') {
res.metadata.annotations[KubernetesPortainerConfigurationDataAnnotation] = annotation;
}
return res;
}
@@ -31,7 +57,21 @@ class KubernetesSecretConverter {
res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.CreationDate = payload.metadata.creationTimestamp;
res.Yaml = yaml ? yaml.data : '';
res.Data = payload.data;
res.Data = _.map(payload.data, (value, key) => {
const annotations = payload.metadata.annotations ? payload.metadata.annotations[KubernetesPortainerConfigurationDataAnnotation] : '';
const entry = new KubernetesConfigurationFormValuesEntry();
entry.Key = key;
entry.IsBinary = _.includes(annotations, entry.Key);
if (!entry.IsBinary) {
entry.Value = atob(value);
} else {
entry.Value = value;
}
return entry;
});
return res;
}
@@ -40,18 +80,7 @@ class KubernetesSecretConverter {
res.Name = formValues.Name;
res.Namespace = formValues.ResourcePool.Namespace.Name;
res.ConfigurationOwner = formValues.ConfigurationOwner;
if (formValues.IsSimple) {
res.Data = _.reduce(
formValues.Data,
(acc, entry) => {
acc[entry.Key] = entry.Value;
return acc;
},
{}
);
} else {
res.Data = YAML.parse(formValues.DataYaml);
}
res.Data = formValues.Data;
return res;
}
}

View File

@@ -69,7 +69,7 @@ class KubernetesServiceConverter {
payload.metadata.namespace = service.Namespace;
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = service.StackName;
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = service.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = service.ApplicationOwner;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = service.Application;
payload.spec.ports = service.Ports;
payload.spec.selector.app = service.ApplicationName;
if (service.Headless) {

View File

@@ -10,7 +10,11 @@ class KubernetesCommonHelper {
}
static ownerToLabel(owner) {
return _.replace(owner, /[^-A-Za-z0-9_.]/g, '.');
let label = _.replace(owner, /[^-A-Za-z0-9_.]/g, '.');
label = _.truncate(label, { length: 63, omission: '' });
label = _.replace(label, /^[-_.]*/g, '');
label = _.replace(label, /[-_.]*$/g, '');
return label;
}
}
export default KubernetesCommonHelper;

View File

@@ -1,5 +1,7 @@
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import _ from 'lodash-es';
import YAML from 'yaml';
class KubernetesConfigurationHelper {
static getUsingApplications(config, applications) {
@@ -21,6 +23,10 @@ class KubernetesConfigurationHelper {
return _.startsWith(config.Name, 'default-token-');
}
static isBinary(encoding) {
return encoding !== '' && !_.includes(encoding, 'ISO') && !_.includes(encoding, 'UTF');
}
static setConfigurationUsed(config) {
config.Used = config.Applications && config.Applications.length !== 0;
}
@@ -32,6 +38,31 @@ class KubernetesConfigurationHelper {
});
}
static parseYaml(formValues) {
YAML.defaultOptions.customTags = ['binary'];
const data = _.map(YAML.parse(formValues.DataYaml), (value, key) => {
const entry = new KubernetesConfigurationFormValuesEntry();
entry.Key = key;
entry.Value = value;
const oldEntry = _.find(formValues.Data, { Key: entry.Key });
entry.IsBinary = oldEntry ? oldEntry.IsBinary : false;
return entry;
});
return data;
}
static parseData(formValues) {
const data = _.reduce(
formValues.Data,
(acc, entry) => {
acc[entry.Key] = entry.Value;
return acc;
},
{}
);
return YAML.stringify(data);
}
static isExternalConfiguration(configuration) {
return !configuration.ConfigurationOwner;
}

View File

@@ -11,7 +11,7 @@ const _KubernetesConfigMap = Object.freeze({
Namespace: '',
Yaml: '',
ConfigurationOwner: '',
Data: {},
Data: [],
});
export class KubernetesConfigMap {

View File

@@ -6,6 +6,7 @@ import { KubernetesCommonMetadataPayload } from 'Kubernetes/models/common/payloa
const _KubernetesConfigMapCreatePayload = Object.freeze({
metadata: new KubernetesCommonMetadataPayload(),
data: {},
binaryData: {},
});
export class KubernetesConfigMapCreatePayload {
constructor() {
@@ -19,6 +20,7 @@ export class KubernetesConfigMapCreatePayload {
const _KubernetesConfigMapUpdatePayload = Object.freeze({
metadata: new KubernetesCommonMetadataPayload(),
data: {},
binaryData: {},
});
export class KubernetesConfigMapUpdatePayload {
constructor() {

View File

@@ -20,16 +20,14 @@ export class KubernetesConfigurationFormValues {
}
}
/**
* KubernetesConfigurationEntry Model
*/
const _KubernetesConfigurationFormValuesDataEntry = Object.freeze({
const _KubernetesConfigurationFormValuesEntry = Object.freeze({
Key: '',
Value: '',
IsBinary: false,
});
export class KubernetesConfigurationFormValuesDataEntry {
export class KubernetesConfigurationFormValuesEntry {
constructor() {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesConfigurationFormValuesDataEntry)));
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesConfigurationFormValuesEntry)));
}
}

View File

@@ -1,4 +1,5 @@
export const KubernetesPortainerConfigurationOwnerLabel = 'io.portainer.kubernetes.configuration.owner';
export const KubernetesPortainerConfigurationDataAnnotation = 'io.portainer.kubernetes.configuration.data';
/**
* Configuration Model (Composite)

View File

@@ -8,7 +8,7 @@ const _KubernetesApplicationSecret = Object.freeze({
CreationDate: '',
ConfigurationOwner: '',
Yaml: '',
Data: {},
Data: [],
});
export class KubernetesApplicationSecret {

View File

@@ -7,6 +7,7 @@ const _KubernetesSecretCreatePayload = Object.freeze({
metadata: new KubernetesCommonMetadataPayload(),
type: 'Opaque',
data: {},
stringData: {},
});
export class KubernetesSecretCreatePayload {
@@ -22,6 +23,7 @@ const _KubernetesSecretUpdatePayload = Object.freeze({
metadata: new KubernetesCommonMetadataPayload(),
type: 'Opaque',
data: {},
stringData: {},
});
export class KubernetesSecretUpdatePayload {

View File

@@ -1,5 +1,16 @@
import * as JsonPatch from 'fast-json-patch';
import _ from 'lodash-es';
import { KubernetesPod, KubernetesPodToleration, KubernetesPodAffinity, KubernetesPodContainer, KubernetesPodContainerTypes } from 'Kubernetes/pod/models';
import KubernetesCommonHelper from 'Kubernetes/helpers/commonHelper';
import {
KubernetesPortainerApplicationStackNameLabel,
KubernetesPortainerApplicationNameLabel,
KubernetesPortainerApplicationOwnerLabel,
KubernetesPortainerApplicationNote,
} from 'Kubernetes/models/application/models';
import { createPayloadFactory } from './payloads/create';
import { KubernetesPod, KubernetesPodToleration, KubernetesPodAffinity, KubernetesPodContainer, KubernetesPodContainerTypes } from './models';
function computeStatus(statuses) {
const containerStatuses = _.map(statuses, 'state');
@@ -104,4 +115,48 @@ export default class KubernetesPodConverter {
res.Tolerations = computeTolerations(data.spec.tolerations);
return res;
}
static patchPayload(oldPod, newPod) {
const oldPayload = createPayload(oldPod);
const newPayload = createPayload(newPod);
const payload = JsonPatch.compare(oldPayload, newPayload);
return payload;
}
}
function createPayload(pod) {
const payload = createPayloadFactory();
payload.metadata.name = pod.Name;
payload.metadata.namespace = pod.Namespace;
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = pod.StackName;
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = pod.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = pod.ApplicationOwner;
if (pod.Note) {
payload.metadata.annotations[KubernetesPortainerApplicationNote] = pod.Note;
} else {
payload.metadata.annotations = undefined;
}
payload.spec.replicas = pod.ReplicaCount;
payload.spec.selector.matchLabels.app = pod.Name;
payload.spec.template.metadata.labels.app = pod.Name;
payload.spec.template.metadata.labels[KubernetesPortainerApplicationNameLabel] = pod.ApplicationName;
payload.spec.template.spec.containers[0].name = pod.Name;
payload.spec.template.spec.containers[0].image = pod.Image;
payload.spec.template.spec.affinity = pod.Affinity;
KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].env', pod.Env);
KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.containers[0].volumeMounts', pod.VolumeMounts);
KubernetesCommonHelper.assignOrDeleteIfEmpty(payload, 'spec.template.spec.volumes', pod.Volumes);
if (pod.MemoryLimit) {
payload.spec.template.spec.containers[0].resources.limits.memory = pod.MemoryLimit;
payload.spec.template.spec.containers[0].resources.requests.memory = pod.MemoryLimit;
}
if (pod.CpuLimit) {
payload.spec.template.spec.containers[0].resources.limits.cpu = pod.CpuLimit;
payload.spec.template.spec.containers[0].resources.requests.cpu = pod.CpuLimit;
}
if (!pod.CpuLimit && !pod.MemoryLimit) {
delete payload.spec.template.spec.containers[0].resources;
}
return payload;
}

View File

@@ -0,0 +1,45 @@
import { KubernetesCommonMetadataPayload } from 'Kubernetes/models/common/payloads';
export function createPayloadFactory() {
return {
metadata: new KubernetesCommonMetadataPayload(),
spec: {
replicas: 0,
selector: {
matchLabels: {
app: '',
},
},
strategy: {
type: 'RollingUpdate',
rollingUpdate: {
maxSurge: 0,
maxUnavailable: '100%',
},
},
template: {
metadata: {
labels: {
app: '',
},
},
spec: {
affinity: {},
containers: [
{
name: '',
image: '',
env: [],
resources: {
limits: {},
requests: {},
},
volumeMounts: [],
},
],
volumes: [],
},
},
},
};
}

View File

@@ -2,6 +2,7 @@ import angular from 'angular';
import PortainerError from 'Portainer/error';
import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
import KubernetesPodConverter from './converter';
class KubernetesPodService {
/* @ngInject */
@@ -13,6 +14,7 @@ class KubernetesPodService {
this.getAllAsync = this.getAllAsync.bind(this);
this.logsAsync = this.logsAsync.bind(this);
this.deleteAsync = this.deleteAsync.bind(this);
this.patchAsync = this.patchAsync.bind(this);
}
async getAsync(namespace, name) {
@@ -74,6 +76,29 @@ class KubernetesPodService {
return this.$async(this.logsAsync, namespace, podName, containerName);
}
/**
* PATCH
*/
async patchAsync(oldPod, newPod) {
try {
const params = new KubernetesCommonParams();
params.id = newPod.Name;
const namespace = newPod.Namespace;
const payload = KubernetesPodConverter.patchPayload(oldPod, newPod);
if (!payload.length) {
return;
}
const data = await this.KubernetesPods(namespace).patch(params, payload).$promise;
return data;
} catch (err) {
throw new PortainerError('Unable to patch pod', err);
}
}
patch(oldPod, newPod) {
return this.$async(this.patchAsync, oldPod, newPod);
}
/**
* DELETE
*/

View File

@@ -31,6 +31,12 @@ angular.module('portainer.kubernetes').factory('KubernetesPods', [
create: { method: 'POST' },
update: { method: 'PUT' },
delete: { method: 'DELETE' },
patch: {
method: 'PATCH',
headers: {
'Content-Type': 'application/json-patch+json',
},
},
logs: {
method: 'GET',
params: { action: 'log' },

View File

@@ -92,8 +92,8 @@
</div>
</div>
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded()">
<div class="col-sm-12 small text-warning">
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<div class="col-sm-12 small text-danger">
<i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This resource pool has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
resource pool.
</div>
@@ -634,8 +634,8 @@
</div>
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded()">
<div class="col-sm-12 small text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<div class="col-sm-12 small text-danger">
<i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This resource pool has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
resource pool.
</div>
@@ -813,8 +813,8 @@
</div>
<div class="form-group" ng-if="ctrl.resourceReservationsOverflow()">
<div class="col-sm-12 small text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<div class="col-sm-12 small text-danger">
<i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application would exceed available resources. Please review resource reservations or the instance count.
</div>
</div>
@@ -930,8 +930,8 @@
</table>
<div class="form-group" ng-if="ctrl.autoScalerOverflow()" style="margin-bottom: 10px;">
<div class="col-sm-12 small text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<div class="col-sm-12 small text-danger">
<i class="fa fa-exclamation-circle red-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This application would exceed available resources. Please review resource reservations or the maximum instance count of the auto-scaling policy.
</div>
</div>
@@ -1299,7 +1299,7 @@
ng-min="1"
ng-max="65535"
ng-required="!publishedPort.NeedsDeletion"
ng-change="ctrl.onChangePortMappingLoadBalancerPort()"
ng-change="ctrl.onChangePortMappingLoadBalancer()"
ng-disabled="ctrl.disableLoadBalancerEdit() || ctrl.isEditAndNotNewPublishedPort($index)"
/>
</div>
@@ -1353,7 +1353,7 @@
class="btn btn-primary"
ng-model="publishedPort.Protocol"
uib-btn-radio="'TCP'"
ng-change="ctrl.onChangePortMappingContainerPort()"
ng-change="ctrl.onChangePortProtocol($index)"
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'TCP')"
>TCP</label
>
@@ -1361,7 +1361,7 @@
class="btn btn-primary"
ng-model="publishedPort.Protocol"
uib-btn-radio="'UDP'"
ng-change="ctrl.onChangePortMappingContainerPort()"
ng-change="ctrl.onChangePortProtocol($index)"
ng-disabled="ctrl.isProtocolOptionDisabled($index, 'UDP')"
>UDP</label
>

View File

@@ -303,6 +303,9 @@ class KubernetesCreateApplicationController {
const ingresses = this.filteredIngresses;
p.IngressName = ingresses && ingresses.length ? ingresses[0].Name : undefined;
p.IngressHost = ingresses && ingresses.length ? ingresses[0].Host : undefined;
if (this.formValues.PublishedPorts.length) {
p.Protocol = this.formValues.PublishedPorts[0].Protocol;
}
this.formValues.PublishedPorts.push(p);
}
@@ -335,6 +338,7 @@ class KubernetesCreateApplicationController {
this.onChangePortMappingNodePort();
this.onChangePortMappingIngressRoute();
this.onChangePortMappingLoadBalancer();
this.onChangePortProtocol();
}
onChangePortMappingContainerPort() {
@@ -403,6 +407,17 @@ class KubernetesCreateApplicationController {
state.hasDuplicates = false;
}
}
onChangePortProtocol(index) {
if (this.formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER) {
const newPorts = _.filter(this.formValues.PublishedPorts, { IsNew: true });
_.forEach(newPorts, (port) => {
port.Protocol = index ? this.formValues.PublishedPorts[index].Protocol : newPorts[0].Protocol;
});
this.onChangePortMappingLoadBalancer();
}
this.onChangePortMappingContainerPort();
}
/* #endregion */
/* #region STATE VALIDATION FUNCTIONS */
@@ -561,6 +576,10 @@ class KubernetesCreateApplicationController {
return this.state.isEdit && !this.formValues.Placements[index].IsNew;
}
isNewAndNotFirst(index) {
return !this.state.isEdit && index !== 0;
}
showPlacementPolicySection() {
const placements = _.filter(this.formValues.Placements, { NeedsDeletion: false });
return placements.length !== 0;
@@ -600,8 +619,17 @@ class KubernetesCreateApplicationController {
return this.state.isEdit && this.formValues.PublishedPorts.length > 0 && ports.length > 0;
}
isEditLBWithPorts() {
return this.formValues.PublishingType === KubernetesApplicationPublishingTypes.LOAD_BALANCER && _.filter(this.formValues.PublishedPorts, { IsNew: false }).length;
}
isProtocolOptionDisabled(index, protocol) {
return this.disableLoadBalancerEdit() || (this.isEditAndNotNewPublishedPort(index) && this.formValues.PublishedPorts[index].Protocol !== protocol);
return (
this.disableLoadBalancerEdit() ||
(this.isEditAndNotNewPublishedPort(index) && this.formValues.PublishedPorts[index].Protocol !== protocol) ||
(this.isEditLBWithPorts() && this.formValues.PublishedPorts[index].Protocol !== protocol) ||
(this.isNewAndNotFirst(index) && this.formValues.PublishedPorts[index].Protocol !== protocol)
);
}
/* #endregion */

View File

@@ -7,8 +7,6 @@
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<div ng-if="ctrl.state.isAdmin" ng-include="'app/kubernetes/templates/advancedDeploymentPanel.html'"></div>
<div class="row">
<div class="col-sm-12">
<rd-widget>
@@ -57,9 +55,7 @@
<tr ng-if="ctrl.application.Requests.Cpu || ctrl.application.Requests.Memory">
<td>
<div>Resource reservations</div>
<div ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD" class="text-muted small">
per instance
</div>
<div ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD" class="text-muted small"> per instance </div>
</td>
<td>
<div ng-if="ctrl.application.Requests.Cpu">CPU {{ ctrl.application.Requests.Cpu | kubernetesApplicationCPUValue }}</div>
@@ -135,7 +131,13 @@
</uib-tab>
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
<uib-tab-heading> <i class="fas fa-compress-arrows-alt space-right" aria-hidden="true"></i> Placement </uib-tab-heading>
<uib-tab-heading>
<i class="fas fa-compress-arrows-alt space-right" aria-hidden="true"></i> Placement
<div ng-if="ctrl.state.placementWarning">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
warning
</div>
</uib-tab-heading>
<div class="small text-muted" style="padding: 20px;">
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
The placement component helps you understand whether or not this application can be deployed on a specific node.

View File

@@ -1,5 +1,3 @@
require('../../../templates/advancedDeploymentPanel.html');
import angular from 'angular';
import * as _ from 'lodash-es';
import * as JsonPatch from 'fast-json-patch';
@@ -103,7 +101,6 @@ class KubernetesApplicationController {
Notifications,
LocalStorage,
ModalService,
Authentication,
KubernetesApplicationService,
KubernetesEventService,
KubernetesStackService,
@@ -117,7 +114,6 @@ class KubernetesApplicationController {
this.Notifications = Notifications;
this.LocalStorage = LocalStorage;
this.ModalService = ModalService;
this.Authentication = Authentication;
this.KubernetesApplicationService = KubernetesApplicationService;
this.KubernetesEventService = KubernetesEventService;
@@ -310,6 +306,7 @@ class KubernetesApplicationController {
});
this.placements = computePlacements(nodes, this.application);
this.state.placementWarning = _.find(this.placements, { AcceptsApplication: true }) ? false : true;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve application details');
} finally {
@@ -335,9 +332,9 @@ class KubernetesApplicationController {
name: this.$transition$.params().name,
},
eventWarningCount: 0,
placementWarning: false,
expandedNote: false,
useIngress: false,
isAdmin: this.Authentication.isAdmin(),
};
this.state.activeTab = this.LocalStorage.getActiveTab('application');

View File

@@ -106,7 +106,21 @@
</div>
<!-- !type options -->
<kubernetes-configuration-data ng-if="ctrl.formValues" form-values="ctrl.formValues" is-valid="ctrl.state.isDataValid"></kubernetes-configuration-data>
<div class="col-sm-12 form-section-title" ng-if="ctrl.formValues.Type == ctrl.KubernetesConfigurationTypes.SECRET">
Information
</div>
<div class="form-group" ng-if="ctrl.formValues.Type == ctrl.KubernetesConfigurationTypes.SECRET">
<div class="col-sm-12 small text-muted">
Creating a sensitive configuration will create a Kubernetes Secret of type <code>Opaque</code>. You can find more information about this in the
<a href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">official documentation</a>.
</div>
</div>
<kubernetes-configuration-data
ng-if="ctrl.formValues"
form-values="ctrl.formValues"
is-valid="ctrl.state.isDataValid"
is-creation="true"
></kubernetes-configuration-data>
<!-- actions -->
<div class="col-sm-12 form-section-title" style="margin-top: 10px;">

View File

@@ -1,7 +1,8 @@
import angular from 'angular';
import _ from 'lodash-es';
import { KubernetesConfigurationFormValues, KubernetesConfigurationFormValuesDataEntry } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesConfigurationFormValues, KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
class KubernetesCreateConfigurationController {
/* @ngInject */
@@ -41,6 +42,9 @@ class KubernetesCreateConfigurationController {
try {
this.state.actionInProgress = true;
this.formValues.ConfigurationOwner = this.Authentication.getUserDetails().username;
if (!this.formValues.IsSimple) {
this.formValues.Data = KubernetesConfigurationHelper.parseYaml(this.formValues);
}
await this.KubernetesConfigurationService.create(this.formValues);
this.Notifications.success('Configuration succesfully created');
this.$state.go('kubernetes.configurations');
@@ -76,7 +80,7 @@ class KubernetesCreateConfigurationController {
};
this.formValues = new KubernetesConfigurationFormValues();
this.formValues.Data.push(new KubernetesConfigurationFormValuesDataEntry());
this.formValues.Data.push(new KubernetesConfigurationFormValuesEntry());
try {
const resourcePools = await this.KubernetesResourcePoolService.get();

View File

@@ -77,7 +77,12 @@
<rd-widget>
<rd-widget-body>
<form ng-if="!ctrl.isSystemNamespace()" class="form-horizontal" name="kubernetesConfigurationCreationForm" autocomplete="off">
<kubernetes-configuration-data ng-if="ctrl.formValues" form-values="ctrl.formValues" is-valid="ctrl.state.isDataValid"></kubernetes-configuration-data>
<kubernetes-configuration-data
ng-if="ctrl.formValues"
form-values="ctrl.formValues"
is-valid="ctrl.state.isDataValid"
is-creation="false"
></kubernetes-configuration-data>
<!-- actions -->
<div class="col-sm-12 form-section-title" style="margin-top: 10px;">

View File

@@ -1,7 +1,8 @@
import angular from 'angular';
import { KubernetesConfigurationFormValues, KubernetesConfigurationFormValuesDataEntry } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesConfigurationTypes } from 'Kubernetes/models/configuration/models';
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
import KubernetesConfigurationConverter from 'Kubernetes/converters/configuration';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import _ from 'lodash-es';
@@ -14,6 +15,8 @@ class KubernetesConfigurationController {
Notifications,
LocalStorage,
KubernetesConfigurationService,
KubernetesConfigMapService,
KubernetesSecretService,
KubernetesResourcePoolService,
ModalService,
KubernetesApplicationService,
@@ -32,6 +35,8 @@ class KubernetesConfigurationController {
this.KubernetesEventService = KubernetesEventService;
this.KubernetesConfigurationTypes = KubernetesConfigurationTypes;
this.KubernetesNamespaceHelper = KubernetesNamespaceHelper;
this.KubernetesConfigMapService = KubernetesConfigMapService;
this.KubernetesSecretService = KubernetesSecretService;
this.onInit = this.onInit.bind(this);
this.getConfigurationAsync = this.getConfigurationAsync.bind(this);
@@ -126,7 +131,18 @@ class KubernetesConfigurationController {
this.state.configurationLoading = true;
const name = this.$transition$.params().name;
const namespace = this.$transition$.params().namespace;
this.configuration = await this.KubernetesConfigurationService.get(namespace, name);
const [configMap, secret] = await Promise.allSettled([this.KubernetesConfigMapService.get(namespace, name), this.KubernetesSecretService.get(namespace, name)]);
if (secret.status === 'fulfilled') {
this.configuration = KubernetesConfigurationConverter.secretToConfiguration(secret.value);
this.formValues.Data = secret.value.Data;
} else {
this.configuration = KubernetesConfigurationConverter.configMapToConfiguration(configMap.value);
this.formValues.Data = configMap.value.Data;
}
this.formValues.ResourcePool = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.configuration.Namespace);
this.formValues.Id = this.configuration.Id;
this.formValues.Name = this.configuration.Name;
this.formValues.Type = this.configuration.Type;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve configuration');
} finally {
@@ -211,20 +227,6 @@ class KubernetesConfigurationController {
await this.getConfiguration();
await this.getApplications(this.configuration.Namespace);
await this.getEvents(this.configuration.Namespace);
this.formValues.ResourcePool = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.configuration.Namespace);
this.formValues.Id = this.configuration.Id;
this.formValues.Name = this.configuration.Name;
this.formValues.Type = this.configuration.Type;
this.formValues.Data = _.map(this.configuration.Data, (value, key) => {
if (this.configuration.Type === KubernetesConfigurationTypes.SECRET) {
value = atob(value);
}
this.formValues.DataYaml += key + ': ' + value + '\n';
const entry = new KubernetesConfigurationFormValuesDataEntry();
entry.Key = key;
entry.Value = value;
return entry;
});
await this.getConfigurations();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');

View File

@@ -33,7 +33,7 @@
<div class="form-group">
<span class="col-sm-12 text-muted small">
Adding ingress controllers will allow users to expose application they deploy over a HTTP route.<br />
Configuring ingress controllers will allow users to expose application they deploy over a HTTP route.<br />
<p style="margin-top: 2px;">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Ingress classes must be manually specified for each controller you want to use in the cluster. Make sure that each controller is running inside your cluster.
@@ -44,7 +44,7 @@
<div class="col-sm-12">
<label class="control-label text-left">Ingress controller</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="ctrl.addIngressClass()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add ingress controller
<i class="fa fa-plus-circle" aria-hidden="true"></i> configure ingress controller
</span>
</div>

View File

@@ -58,19 +58,19 @@
<table class="table table-hover nowrap-cells">
<thead>
<tr>
<th style="width: 10%;">
<th style="width: 20px;">
<a ng-click="$ctrl.expandAll()" ng-if="$ctrl.hasExpandableItems()">
<i ng-class="{ 'fas fa-angle-down': $ctrl.state.expandAll, 'fas fa-angle-right': !$ctrl.state.expandAll }" aria-hidden="true"></i>
</a>
</th>
<th style="width: 55%;">
<th style="width: 60%;">
<a ng-click="$ctrl.changeOrderBy('Name')">
Storage
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th style="width: 35%;">
<th style="width: 38%;">
<a ng-click="$ctrl.changeOrderBy('Size')">
Usage
<i class="fa fa-sort-numeric-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && !$ctrl.state.reverseOrder"></i>
@@ -96,8 +96,9 @@
<td>{{ item.Size }}</td>
</tr>
<tr dir-paginate-end ng-show="item.Expanded" ng-repeat="vol in item.Volumes" ng-style="{ background: item.Highlighted ? '#d5e8f3' : '#f5f5f5' }">
<td colspan="2">
<a ui-sref="kubernetes.volumes.volume({ name: vol.PersistentVolumeClaim.Name, namespace: vol.PersistentVolumeClaim.Namespace })" style="margin-left: 25px;">
<td></td>
<td>
<a ui-sref="kubernetes.volumes.volume({ name: vol.PersistentVolumeClaim.Name, namespace: vol.PersistentVolumeClaim.Namespace })">
{{ vol.PersistentVolumeClaim.Name }}
</a>
</td>

View File

@@ -37,6 +37,17 @@
<td>Storage</td>
<td>{{ ctrl.volume.PersistentVolumeClaim.StorageClass.Name }}</td>
</tr>
<tr>
<td>Shared Access Policy</td>
<td
>{{ ctrl.state.volumeSharedAccessPolicy }}
<portainer-tooltip
position="bottom"
ng-if="ctrl.state.volumeSharedAccessPolicyTooltip"
message="{{ ctrl.state.volumeSharedAccessPolicyTooltip }}"
></portainer-tooltip
></td>
</tr>
<tr>
<td>Provisioner</td>
<td>{{ ctrl.volume.PersistentVolumeClaim.StorageClass.Provisioner ? ctrl.volume.PersistentVolumeClaim.StorageClass.Provisioner : '-' }}</td>

View File

@@ -2,6 +2,7 @@ import angular from 'angular';
import _ from 'lodash-es';
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import { KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models';
import filesizeParser from 'filesize-parser';
class KubernetesVolumeController {
@@ -179,6 +180,8 @@ class KubernetesVolumeController {
volumeSize: 0,
volumeSizeUnit: 'GB',
volumeSizeError: false,
volumeSharedAccessPolicy: '',
volumeSharedAccessPolicyTooltip: '',
};
this.state.activeTab = this.LocalStorage.getActiveTab('volume');
@@ -186,6 +189,16 @@ class KubernetesVolumeController {
try {
await this.getVolume();
await this.getEvents();
if (this.volume.PersistentVolumeClaim.StorageClass !== undefined) {
this.state.volumeSharedAccessPolicy = this.volume.PersistentVolumeClaim.StorageClass.AccessModes[this.volume.PersistentVolumeClaim.StorageClass.AccessModes.length - 1];
let policies = KubernetesStorageClassAccessPolicies();
policies.forEach((policy) => {
if (policy.Name == this.state.volumeSharedAccessPolicy) {
this.state.volumeSharedAccessPolicyTooltip = policy.Description;
}
});
}
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');
} finally {

View File

@@ -0,0 +1,7 @@
export default class DatatableColumnsVisibilityController {
constructor() {
this.state = {
isOpen: false,
};
}
}

View File

@@ -0,0 +1,19 @@
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.isOpen }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.state.isOpen">
<span uib-dropdown-toggle><i class="fa fa-columns space-right" aria-hidden="true"></i>Columns</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Show / Hide Columns
</div>
<div class="menuContent">
<div class="md-checkbox" ng-repeat="(key, value) in $ctrl.columns">
<input id="col_vis_{{::key}}" ng-change="$ctrl.onChange($ctrl.columns)" type="checkbox" ng-model="value.display" />
<label for="col_vis_{{::key}}">{{ value.label }}</label>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.state.isOpen = false;">Close</a>
</div>
</div>
</div>
</span>

View File

@@ -0,0 +1,12 @@
import angular from 'angular';
import controller from './datatable-columns-visibility.controller';
angular.module('portainer.app').component('datatableColumnsVisibility', {
templateUrl: './datatable-columns-visibility.html',
controller,
bindings: {
columns: '<',
onChange: '<',
},
});

View File

@@ -33,6 +33,7 @@ angular.module('portainer.app').controller('GenericDatatableController', [
refreshRate: '30',
},
};
this.resetSelectionState = function () {
this.state.selectAll = false;
this.state.selectedItems = [];
@@ -158,6 +159,11 @@ angular.module('portainer.app').controller('GenericDatatableController', [
this.settings.open = false;
}
this.onSettingsRepeaterChange();
var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
if (storedColumnVisibility !== null) {
this.columnVisibility = storedColumnVisibility;
}
};
/**

View File

@@ -4,6 +4,7 @@
<div class="toolBar">
<div class="toolBarTitle"><i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
<div class="settings">
<datatable-columns-visibility columns="$ctrl.columnVisibility.columns" on-change="($ctrl.onColumnVisibilityChange)"></datatable-columns-visibility>
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
@@ -117,6 +118,20 @@
</a>
</th>
<th>Control</th>
<th>
<a ng-click="$ctrl.changeOrderBy('ResourceControl.CreationDate')">
Created
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.CreationDate' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.CreationDate' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.columnVisibility.columns.updated.display">
<a ng-click="$ctrl.changeOrderBy('ResourceControl.UpdateDate')">
Updated
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.UpdateDate' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ResourceControl.UpdateDate' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('ResourceControl.Ownership')">
Ownership
@@ -154,6 +169,14 @@
</span>
<span ng-if="!item.External">Total</span>
</td>
<td>
<span ng-if="item.CreationDate">{{ item.CreationDate | getisodatefromtimestamp }} {{ item.CreatedBy ? 'by ' + item.CreatedBy : '' }}</span>
<span ng-if="!item.CreationDate"> - </span>
</td>
<td ng-if="$ctrl.columnVisibility.columns.updated.display">
<span ng-if="item.UpdateDate">{{ item.UpdateDate | getisodatefromtimestamp }} {{ item.UpdatedBy ? 'by ' + item.UpdatedBy : '' }}</span>
<span ng-if="!item.UpdateDate"> - </span>
</td>
<td>
<span>
<i ng-class="item.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
@@ -162,10 +185,10 @@
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="4" class="text-center text-muted">Loading...</td>
<td colspan="6" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="4" class="text-center text-muted">No stack available.</td>
<td colspan="6" class="text-center text-muted">No stack available.</td>
</tr>
</tbody>
</table>

View File

@@ -15,6 +15,24 @@ angular.module('portainer.app').controller('StacksDatatableController', [
},
};
this.columnVisibility = {
state: {
open: false,
},
columns: {
updated: {
label: 'Updated',
display: false,
},
},
};
this.onColumnVisibilityChange = onColumnVisibilityChange.bind(this);
function onColumnVisibilityChange(columns) {
this.columnVisibility.columns = columns;
DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility);
}
/**
* Do not allow external items
*/
@@ -71,6 +89,11 @@ angular.module('portainer.app').controller('StacksDatatableController', [
this.settings.open = false;
}
this.onSettingsRepeaterChange();
var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
if (storedColumnVisibility !== null) {
this.columnVisibility = storedColumnVisibility;
}
};
},
]);

View File

@@ -1,37 +1,27 @@
import _ from 'lodash-es';
import { ExternalStackViewModel } from '@/portainer/models/stack';
angular.module('portainer.app').factory('StackHelper', [
function StackHelperFactory() {
'use strict';
var helper = {};
helper.getExternalStackNamesFromContainers = function (containers) {
var stackNames = [];
for (var i = 0; i < containers.length; i++) {
var container = containers[i];
if (!container.Labels || !container.Labels['com.docker.compose.project']) continue;
var stackName = container.Labels['com.docker.compose.project'];
stackNames.push(stackName);
}
return _.uniq(stackNames);
helper.getExternalStacksFromContainers = function (containers) {
return getExternalStacksFromLabel(containers, 'com.docker.compose.project', 2);
};
helper.getExternalStackNamesFromServices = function (services) {
var stackNames = [];
for (var i = 0; i < services.length; i++) {
var service = services[i];
if (!service.Labels || !service.Labels['com.docker.stack.namespace']) continue;
var stackName = service.Labels['com.docker.stack.namespace'];
stackNames.push(stackName);
}
return _.uniq(stackNames);
helper.getExternalStacksFromServices = function (services) {
return getExternalStacksFromLabel(services, 'com.docker.stack.namespace', 1);
};
function getExternalStacksFromLabel(items, label, type) {
return _.uniqBy(
items.filter((item) => item.Labels && item.Labels[label]).map((item) => new ExternalStackViewModel(item.Labels[label], type, item.Created)),
'Name'
);
}
return helper;
},
]);

View File

@@ -13,11 +13,16 @@ export function StackViewModel(data) {
}
this.External = false;
this.Status = data.Status;
this.CreationDate = data.CreationDate;
this.CreatedBy = data.CreatedBy;
this.UpdateDate = data.UpdateDate;
this.UpdatedBy = data.UpdatedBy;
}
export function ExternalStackViewModel(name, type) {
export function ExternalStackViewModel(name, type, creationDate) {
this.Name = name;
this.Type = type;
this.External = true;
this.Checked = false;
this.CreationDate = creationDate;
}

View File

@@ -1,5 +1,5 @@
import _ from 'lodash-es';
import { ExternalStackViewModel, StackViewModel } from '../../models/stack';
import { StackViewModel } from '../../models/stack';
angular.module('portainer.app').factory('StackService', [
'$q',
@@ -121,13 +121,8 @@ angular.module('portainer.app').factory('StackService', [
var deferred = $q.defer();
ServiceService.services()
.then(function success(data) {
var services = data;
var stackNames = StackHelper.getExternalStackNamesFromServices(services);
var stacks = stackNames.map(function (name) {
return new ExternalStackViewModel(name, 1);
});
deferred.resolve(stacks);
.then(function success(services) {
deferred.resolve(StackHelper.getExternalStacksFromServices(services));
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve external stacks', err: err });
@@ -140,13 +135,8 @@ angular.module('portainer.app').factory('StackService', [
var deferred = $q.defer();
ContainerService.containers(1)
.then(function success(data) {
var containers = data;
var stackNames = StackHelper.getExternalStackNamesFromContainers(containers);
var stacks = stackNames.map(function (name) {
return new ExternalStackViewModel(name, 2);
});
deferred.resolve(stacks);
.then(function success(containers) {
deferred.resolve(StackHelper.getExternalStacksFromContainers(containers));
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve external stacks', err: err });

View File

@@ -179,7 +179,7 @@ angular.module('portainer.app').factory('StateManager', [
}
var applicationState = LocalStorage.getApplicationState();
if (applicationState) {
if (applicationState && applicationState.validity) {
var now = moment().unix();
var cacheValidity = now - applicationState.validity;
if (cacheValidity > APPLICATION_CACHE_VALIDITY) {

View File

@@ -14,7 +14,8 @@ angular
FormValidator,
ResourceControlService,
FormHelper,
CustomTemplateService
CustomTemplateService,
EndpointProvider
) {
$scope.formValues = {
Name: '',
@@ -166,6 +167,7 @@ angular
async function initView() {
var endpointMode = $scope.applicationState.endpoint.mode;
const endpointId = +$state.params.endpointId;
$scope.state.StackType = 2;
if (endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER') {
$scope.state.StackType = 1;
@@ -177,6 +179,13 @@ angular
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve Custom Templates');
}
try {
const endpoint = EndpointProvider.currentEndpoint();
$scope.composeSyntaxMaxVersion = endpoint.ComposeSyntaxMaxVersion;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve the ComposeSyntaxMaxVersion');
}
}
initView();

View File

@@ -20,10 +20,15 @@
<span class="col-sm-12 text-muted small" ng-if="state.StackType === 1">
This stack will be deployed using the equivalent of the <code>docker stack deploy</code> command.
</span>
<span class="col-sm-12 text-muted small" ng-if="state.StackType === 2">
This stack will be deployed using the equivalent of <code>docker-compose</code>. Only Compose file format version <b>2</b> is supported at the moment. <br /><br />
<div class="col-sm-12 text-muted small" ng-if="state.StackType === 2 && composeSyntaxMaxVersion == 2">
<div style="margin-bottom: 7px;">
This stack will be deployed using the equivalent of <code>docker-compose</code>. Only Compose file format version <b>2</b> is supported at the moment.
</div>
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Note: Due to a limitation of libcompose, the name of the stack will be standardized to remove all special characters and uppercase letters.
</div>
<span class="col-sm-12 text-muted small" ng-if="state.StackType === 2 && composeSyntaxMaxVersion > 2">
This stack will be deployed using <code>docker-compose</code>.
</span>
</div>
<!-- build-method -->

View File

@@ -39,14 +39,6 @@
<div class="form-group">
{{ stackName }}
<a
ng-if="!state.externalStack && stackFileContent"
class="btn btn-primary btn-xs"
ui-sref="docker.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
>
Create template from stack
</a>
<button
authorization="PortainerStackUpdate"
ng-if="!state.externalStack && stack.Status === 2"
@@ -73,6 +65,15 @@
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>
Delete this stack
</button>
<button
ng-if="!state.externalStack && stackFileContent"
class="btn btn-primary btn-xs"
ui-sref="docker.templates.custom.new({fileContent: stackFileContent, type: stack.Type})"
>
<i class="fa fa-plus space-right" aria-hidden="true"></i>
Create template from stack
</button>
</div>
</div>
<!-- !stack-details -->
@@ -93,6 +94,12 @@
<uib-tab-heading> <i class="fa fa-pencil-alt space-right" aria-hidden="true"></i> Editor </uib-tab-heading>
<form class="form-horizontal" ng-if="state.showEditorTab" style="margin-top: 10px;">
<div class="form-group">
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px;" ng-if="stackType == 2 && composeSyntaxMaxVersion == 2">
This stack will be deployed using the equivalent of <code>docker-compose</code>. Only Compose file format version <b>2</b> is supported at the moment.
</span>
<span class="col-sm-12 text-muted small" style="margin-bottom: 7px;" ng-if="stackType == 2 && composeSyntaxMaxVersion > 2">
This stack will be deployed using <code>docker-compose</code>.
</span>
<span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span>

View File

@@ -359,7 +359,7 @@ angular.module('portainer.app').controller('StackController', [
});
}
function initView() {
async function initView() {
var stackName = $transition$.params().name;
$scope.stackName = stackName;
var external = $transition$.params().external;
@@ -372,6 +372,15 @@ angular.module('portainer.app').controller('StackController', [
var stackId = $transition$.params().id;
loadStack(stackId);
}
try {
const endpoint = EndpointProvider.currentEndpoint();
$scope.composeSyntaxMaxVersion = endpoint.ComposeSyntaxMaxVersion;
} catch (err) {
Notifications.error('Failure', err, 'Unable to retrieve the ComposeSyntaxMaxVersion');
}
$scope.stackType = $transition$.params().type;
}
initView();

View File

@@ -1,24 +0,0 @@
param (
[string]$platform,
[string]$arch
)
$ErrorActionPreference = "Stop";
$binary = "portainer.exe"
$go_path = "$($(Get-ITEM -Path env:AGENT_TEMPDIRECTORY).Value)\go"
Set-Item env:GOPATH "$go_path"
New-Item -Name dist -Path "." -ItemType Directory -Force | Out-Null
New-Item -Name portainer -Path "$go_path\src\github.com\portainer" -ItemType Directory -Force | Out-Null
Copy-Item -Path "api" -Destination "$go_path\src\github.com\portainer\portainer\api" -Recurse -Force
Set-Location -Path "api\cmd\portainer"
go get -t -d -v ./...
## go build -v
& cmd /c 'go build -v 2>&1'
Copy-Item -Path "portainer.exe" -Destination "$($env:BUILD_SOURCESDIRECTORY)\dist\portainer.exe" -Force

View File

@@ -1,3 +1,8 @@
#!/usr/bin/env bash
PLATFORM=$1
ARCH=$2
export GOPATH="/tmp/go"
binary="portainer"
@@ -10,6 +15,10 @@ cp -R api ${GOPATH}/src/github.com/portainer/portainer/api
cd 'api/cmd/portainer'
go get -t -d -v ./...
GOOS=$1 GOARCH=$2 CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s'
GOOS=${PLATFORM} GOARCH=${ARCH} CGO_ENABLED=0 go build -a --installsuffix cgo --ldflags '-s'
mv "$BUILD_SOURCESDIRECTORY/api/cmd/portainer/$binary" "$BUILD_SOURCESDIRECTORY/dist/portainer"
if [ "${PLATFORM}" == 'windows' ]; then
mv "$BUILD_SOURCESDIRECTORY/api/cmd/portainer/${binary}.exe" "$BUILD_SOURCESDIRECTORY/dist/portainer.exe"
else
mv "$BUILD_SOURCESDIRECTORY/api/cmd/portainer/$binary" "$BUILD_SOURCESDIRECTORY/dist/portainer"
fi

View File

@@ -1,14 +0,0 @@
param (
[string]$docker_version
)
$ErrorActionPreference = "Stop";
$ProgressPreference = "SilentlyContinue";
New-Item -Path "docker-binary" -ItemType Directory | Out-Null
$download_folder = "docker-binary"
Invoke-WebRequest -O "$($download_folder)/docker-binaries.zip" "https://download.docker.com/win/static/stable/x86_64/docker-$($docker_version).zip"
Expand-Archive -Path "$($download_folder)/docker-binaries.zip" -DestinationPath "$($download_folder)"
Move-Item -Path "$($download_folder)/docker/docker.exe" -Destination "dist"

View File

@@ -10,9 +10,10 @@ rm -rf "${DOWNLOAD_FOLDER}"
mkdir -pv "${DOWNLOAD_FOLDER}"
if [ "${PLATFORM}" == 'win' ]; then
wget -O "${DOWNLOAD_FOLDER}/docker-binaries.zip" "https://download.docker.com/${PLATFORM}/static/stable/${ARCH}/docker-${DOCKER_VERSION}.zip"
wget -O "${DOWNLOAD_FOLDER}/docker-binaries.zip" "https://dockermsft.azureedge.net/dockercontainer/docker-${DOCKER_VERSION}.zip"
unzip "${DOWNLOAD_FOLDER}/docker-binaries.zip" -d "${DOWNLOAD_FOLDER}"
mv "${DOWNLOAD_FOLDER}/docker/docker.exe" dist/
mv ${DOWNLOAD_FOLDER}/docker/*.dll dist/
else
wget -O "${DOWNLOAD_FOLDER}/docker-binaries.tgz" "https://download.docker.com/${PLATFORM}/static/stable/${ARCH}/docker-${DOCKER_VERSION}.tgz"
tar -xf "${DOWNLOAD_FOLDER}/docker-binaries.tgz" -C "${DOWNLOAD_FOLDER}"

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
PLATFORM=$1
ARCH=$2
DOCKER_COMPOSE_VERSION=$3
if [ "${PLATFORM}" == 'linux' ] && [ "${ARCH}" == 'amd64' ]; then
wget -O "dist/docker-compose" "https://github.com/portainer/docker-compose-linux-amd64-static-binary/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose"
chmod +x "dist/docker-compose"
elif [ "${PLATFORM}" == 'mac' ]; then
wget -O "dist/docker-compose" "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-Darwin-x86_64"
chmod +x "dist/docker-compose"
elif [ "${PLATFORM}" == 'win' ]; then
wget -O "dist/docker-compose.exe" "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-Windows-x86_64.exe"
chmod +x "dist/docker-compose.exe"
fi
exit 0

View File

@@ -1,8 +0,0 @@
param (
[string]$kompose_version
)
$ErrorActionPreference = "Stop";
$ProgressPreference = "SilentlyContinue";
Invoke-WebRequest -O "dist/kompose.exe" "https://github.com/kubernetes/kompose/releases/download/$($kompose_version)/kompose-windows-amd64.exe"

View File

@@ -4,7 +4,12 @@ PLATFORM=$1
ARCH=$2
KOMPOSE_VERSION=$3
wget -O "dist/kompose" "https://github.com/kubernetes/kompose/releases/download/${KOMPOSE_VERSION}/kompose-${PLATFORM}-${ARCH}"
chmod +x "dist/kompose"
if [ "${PLATFORM}" == 'linux' ]; then
wget -O "dist/kompose" "https://github.com/kubernetes/kompose/releases/download/${KOMPOSE_VERSION}/kompose-${PLATFORM}-${ARCH}"
chmod +x "dist/kompose"
elif [ "${PLATFORM}" == 'windows' ]; then
wget -O "dist/kompose.exe" "https://github.com/kubernetes/kompose/releases/download/${KOMPOSE_VERSION}/kompose-windows-amd64.exe"
chmod +x "dist/kompose.exe"
fi
exit 0

View File

@@ -1,8 +0,0 @@
param (
[string]$kubectl_version
)
$ErrorActionPreference = "Stop";
$ProgressPreference = "SilentlyContinue";
Invoke-WebRequest -O "dist/kubectl.exe" "https://storage.googleapis.com/kubernetes-release/release/$($kubectl_version)/bin/windows/amd64/kubectl.exe"

View File

@@ -4,7 +4,12 @@ PLATFORM=$1
ARCH=$2
KUBECTL_VERSION=$3
wget -O "dist/kubectl" "https://storage.googleapis.com/kubernetes-release/release/${KUBECTL_VERSION}/bin/${PLATFORM}/${ARCH}/kubectl"
chmod +x "dist/kubectl"
if [ "${PLATFORM}" == 'linux' ]; then
wget -O "dist/kubectl" "https://storage.googleapis.com/kubernetes-release/release/${KUBECTL_VERSION}/bin/${PLATFORM}/${ARCH}/kubectl"
chmod +x "dist/kubectl"
elif [ "${PLATFORM}" == 'windows' ]; then
wget -O "dist/kubectl.exe" "https://storage.googleapis.com/kubernetes-release/release/${KUBECTL_VERSION}/bin/windows/amd64/kubectl.exe"
chmod +x "dist/kubectl.exe"
fi
exit 0

24
build/windows/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
ARG OSVERSION
FROM --platform=linux/amd64 gcr.io/k8s-staging-e2e-test-images/windows-servercore-cache:1.0-linux-amd64-${OSVERSION} as core
FROM --platform=linux/amd64 alpine:3.13.0 as downloader
ENV GIT_VERSION 2.30.0
ENV GIT_PATCH_VERSION 2
RUN mkdir mingit/ \
&& wget https://github.com/git-for-windows/git/releases/download/v$GIT_VERSION.windows.$GIT_PATCH_VERSION/MinGit-$GIT_VERSION.$GIT_PATCH_VERSION-busybox-64-bit.zip \
&& unzip MinGit-$GIT_VERSION.$GIT_PATCH_VERSION-busybox-64-bit.zip -d mingit/
FROM mcr.microsoft.com/windows/nanoserver:${OSVERSION}
ENV PATH "C:\mingit\cmd;C:\Windows\system32;C:\Windows;"
COPY --from=downloader /mingit mingit/
COPY --from=core /Windows/System32/netapi32.dll /Windows/System32/netapi32.dll
USER ContainerAdministrator
COPY dist /
EXPOSE 9000
EXPOSE 8000
ENTRYPOINT ["/portainer.exe"]

View File

@@ -1,15 +0,0 @@
FROM mcr.microsoft.com/windows/servercore:ltsc2019 as core
FROM mcr.microsoft.com/windows/nanoserver:1809-amd64
USER ContainerAdministrator
COPY --from=core /windows/system32/netapi32.dll /windows/system32/netapi32.dll
COPY dist /
WORKDIR /
EXPOSE 9000
EXPOSE 8000
ENTRYPOINT ["/portainer.exe"]

Some files were not shown because too many files have changed in this diff Show More