Compare commits

...

33 Commits

Author SHA1 Message Date
Dakota Walsh
db1d56621e fix(ingresses): migrate to new allow/disallow format EE-4465 (#7894) 2022-10-28 16:27:01 +13:00
Dmitry Salakhov
4f173d0fd2 fix(image): build image from file (#7928) [EE-4501] 2022-10-27 23:31:42 +13:00
Dakota Walsh
60183be3fe fix(ingress): allow none controller type EE-4420 (#7884)
Co-authored-by: testA113 <alex.harris@portainer.io>
2022-10-25 08:12:33 +13:00
Chaim Lev-Ari
ae467a9754 chore(edge): add aria-label for edge-group selector [EE-4466] (#7895)
* chore(edge): add aria-label for edge-group selector

* style(edge): remove comment
2022-10-21 08:22:55 +03:00
andres-portainer
e298df78b2 fix(logging): default to pretty logging [EE-4371] (#7846)
* fix(logging): default to pretty logging EE-4371

* feat(app/logs): prettify stack traces in JSON logs

* feat(nomad/logs): prettify JSON logs in log viewer

* feat(kubernetes/logs): prettigy JSON logs in log viewers

* feat(app/logs): format and color zerolog prettified logs

* fix(app/logs): pre-parse logs when they are double serialized

Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2022-10-20 16:34:01 +02:00
Prabhat Khera
d27df1d4cd fix volume claims with k8s app (#7900) 2022-10-20 15:43:24 +13:00
itsconquest
7343a2ebd7 fix(UAC): put team into resource control when editing as team lead [EE-4457] (#7885)
* fix(UAC): put team into resource control when editing as team lead [EE-4457]

* populate form values & payload correctly
2022-10-20 10:18:51 +13:00
itsconquest
71d2473070 fix(notifications): sort by newest first by default [EE-4467] (#7890) 2022-10-19 15:25:15 +13:00
Dakota Walsh
a308cf4ae4 fix(kubernetes): create proxied kubeclient EE-4326 (#7851) 2022-10-18 11:05:42 +13:00
itsconquest
9c04c253e7 fix(UAC): provide required UI context [EE-4415] (#7853) 2022-10-18 09:45:43 +13:00
Prabhat Khera
446e1822bf fix reloading page when ing class disallowed (#7831) 2022-10-17 10:44:22 +13:00
Ali
4a27aa12bc fix(ing): nodeport validate and show errors (#7802) 2022-10-12 10:06:33 +13:00
andres-portainer
a9973c8d53 fix(build): add -trimpath EE-4406 (#7835) 2022-10-11 13:01:04 -03:00
andres-portainer
677d61b855 fix(logging): convert missing cases to Zerolog EE-4400 (#7816) 2022-10-11 12:59:07 -03:00
Oscar Zhou
b0938875dc fix(gitops): update the git ref cache key from url to url and pat (#7840) 2022-10-11 18:31:28 +13:00
itsconquest
1e1cb3784c fix(notifications): cleanup notifications code [EE-4274] (#7789)
* fix(notifications): cleanup notifications code [EE-4274]

* break long words
2022-10-11 14:05:50 +13:00
Ali
f1e7417e33 fix(ingress): update ingress tls after deletion EE-4387 (#7805)
* fix(ing): update tls value EE-4387
2022-10-10 09:32:37 +13:00
Ali
c45d41f55f fix(clustersetup): dont show modal when loading (#7811) 2022-10-08 17:48:39 +13:00
Ali
e02238365a fix(application): edit cluster ip services EE-4328 (#7774) 2022-10-07 16:55:25 +13:00
congs
6e1e9e9341 fix(UI): EE-4381 environment ID is shown instead of its name when deleting an environment (#7809) 2022-10-07 16:36:26 +13:00
congs
eb0cfdd2c1 fix(wizard): EE-4350 Environment creating script should only showed for relevant type of environment (#7787) 2022-10-07 15:43:12 +13:00
congs
44f74a7441 fix(help): EE-4335 context sensitive help improvement (#7755) 2022-10-07 14:25:34 +13:00
matias-portainer
4ee064eeee fix(edge): fix docker proxy EE-4380 (#7800) 2022-10-06 11:12:44 -03:00
Ali
da0661a1bd fix(ingress): ingress indicate missing services EE-4358 (#7795) 2022-10-06 15:25:09 +13:00
Dakota Walsh
19c2bc12de fix(ingress-controllers): rework namespace allow and disallow EE-4322 (#7743)
* fix(ingress-controllers): rework namespace allow and disallow

* add check for ingressAvailabilityPerNamespace

Co-authored-by: Prabhat Khera <prabhat.khera@portainer.io>
2022-10-05 15:50:48 +13:00
Prabhat Khera
bc6a43d88b bug(ingress): fix ingress class disallowed to not found issue EE-4311 (#7749) 2022-10-05 15:17:46 +13:00
Rex Wang
6d6632d4e2 fix(docker): fix text info format [EE-2681] (#7762)
* EE-2681 fix(docker): fix text info format

* EE-2681 fix(docker): revert message changes

* EE-2681 fix(docker) add missing space
2022-10-04 09:12:16 +08:00
Ali
a32b004fb3 fix(cluster): fix cluster setup no ingress release EE-4352 (#7777)
* fix(cluster) update cluster wo controllers EE-4352

* fix(ing): stop errors in ns EE-4352
2022-10-04 12:14:02 +13:00
Ali
ba441da519 fix(deploy): update option text EE-4362 (#7782) 2022-10-04 10:20:22 +13:00
Ali
07f8abe2f3 fix(customtemplate) fix custom var payload EE-4340 (#7753) 2022-10-03 09:49:34 +13:00
Xuing
071962de2d fix(readme) update deploy portainer url (#7760)
(cherry picked from commit a0fa64781a)
2022-09-30 14:50:03 +13:00
Ali
04fd2a2b44 fix(clustersetup): set a default access mode (#7746) 2022-09-29 10:26:22 +13:00
Ali
70d89e9a24 fix(secrets): fix edit, refactor form type (#7734) 2022-09-29 09:57:29 +13:00
126 changed files with 2658 additions and 899 deletions

View File

@@ -20,7 +20,7 @@ Portainer CE is updated regularly. We aim to do an update release every couple o
## Getting started
- [Deploy Portainer](https://docs.portainer.io/v/ce-2.9/start/install)
- [Deploy Portainer](https://docs.portainer.io/start/install)
- [Documentation](https://documentation.portainer.io)
- [Contribute to the project](https://documentation.portainer.io/contributing/instructions/)

View File

@@ -62,6 +62,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
MaxBatchDelay: kingpin.Flag("max-batch-delay", "Maximum delay before a batch starts").Duration(),
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("PRETTY", "JSON"),
}
kingpin.Parse()

View File

@@ -1,7 +1,9 @@
package main
import (
"fmt"
stdlog "log"
"os"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
@@ -31,3 +33,23 @@ func setLoggingLevel(level string) {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
}
func setLoggingMode(mode string) {
switch mode {
case "PRETTY":
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
NoColor: true,
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage})
case "JSON":
log.Logger = log.Output(os.Stderr)
}
}
func formatMessage(i interface{}) string {
if i == nil {
return ""
}
return fmt.Sprintf("%s |", i)
}

View File

@@ -235,8 +235,8 @@ func initDockerClientFactory(signatureService portainer.DigitalSignatureService,
return docker.NewClientFactory(signatureService, reverseTunnelService)
}
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore dataservices.DataStore) *kubecli.ClientFactory {
return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID, dataStore)
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore, instanceID, addrHTTPS, userSessionTimeout string) (*kubecli.ClientFactory, error) {
return kubecli.NewClientFactory(signatureService, reverseTunnelService, dataStore, instanceID, addrHTTPS, userSessionTimeout)
}
func initSnapshotService(snapshotIntervalFromFlag string, dataStore dataservices.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory, shutdownCtx context.Context) (portainer.SnapshotService, error) {
@@ -606,7 +606,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
reverseTunnelService := chisel.NewService(dataStore, shutdownCtx)
dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService)
kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID, dataStore)
kubernetesClientFactory, err := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, dataStore, instanceID, *flags.AddrHTTPS, settings.UserSessionTimeout)
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx)
if err != nil {
@@ -713,6 +713,23 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer)
stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService)
// FIXME: In 2.16 we changed the way ingress controller permissions are
// stored. Instead of being stored as annotation on an ingress rule, we keep
// them in our database. However, in order to run the migration we need an
// admin kube client to run lookup the old ingress rules and compare them
// with the current existing ingress classes.
//
// Unfortunately, our migrations run as part of the database initialization
// and our kubeclients require an initialized database. So it is not
// possible to do this migration as part of our normal flow. We DO have a
// migration which toggles a boolean in kubernetes configuration that
// indicated that this "post init" migration should be run. If/when this is
// resolved we can remove this function.
err = kubernetesClientFactory.PostInitMigrateIngresses()
if err != nil {
log.Fatal().Err(err).Msg("failure during creation of new database")
}
return &http.Server{
AuthorizationService: authorizationService,
ReverseTunnelService: reverseTunnelService,
@@ -752,10 +769,12 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
func main() {
configureLogger()
setLoggingMode("PRETTY")
flags := initCLI()
setLoggingLevel(*flags.LogLevel)
setLoggingMode(*flags.LogMode)
for {
server := buildServer(flags)

View File

@@ -59,9 +59,6 @@ func (r K8sIngressInfo) Validate(request *http.Request) error {
if r.Namespace == "" {
return errors.New("missing ingress Namespace from the request payload")
}
if r.ClassName == "" {
return errors.New("missing ingress ClassName from the request payload")
}
return nil
}

View File

@@ -275,7 +275,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string) error {
// Compare the result we got with the one we wanted.
if diff := cmp.Diff(wantJSON, gotJSON); diff != "" {
gotPath := filepath.Join(t.TempDir(), "portainer-migrator-test-fail.json")
gotPath := filepath.Join(os.TempDir(), "portainer-migrator-test-fail.json")
os.WriteFile(
gotPath,
gotJSON,

View File

@@ -7,7 +7,7 @@ import (
func (m *Migrator) migrateDBVersionToDB70() error {
log.Info().Msg("- add IngressAvailabilityPerNamespace field")
if err := m.addIngressAvailabilityPerNamespaceFieldDB70(); err != nil {
if err := m.updateIngressFieldsForEnvDB70(); err != nil {
return err
}
@@ -50,7 +50,7 @@ func (m *Migrator) migrateDBVersionToDB70() error {
return nil
}
func (m *Migrator) addIngressAvailabilityPerNamespaceFieldDB70() error {
func (m *Migrator) updateIngressFieldsForEnvDB70() error {
endpoints, err := m.endpointService.Endpoints()
if err != nil {
return err
@@ -58,6 +58,8 @@ func (m *Migrator) addIngressAvailabilityPerNamespaceFieldDB70() error {
for _, endpoint := range endpoints {
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = true
endpoint.Kubernetes.Configuration.AllowNoneIngressClass = false
endpoint.PostInitMigrations.MigrateIngresses = true
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {

View File

@@ -52,6 +52,7 @@
"IsEdgeDevice": false,
"Kubernetes": {
"Configuration": {
"AllowNoneIngressClass": false,
"EnableResourceOverCommit": false,
"IngressAvailabilityPerNamespace": true,
"IngressClasses": null,
@@ -65,6 +66,9 @@
},
"LastCheckInDate": 0,
"Name": "local",
"PostInitMigrations": {
"MigrateIngresses": true
},
"PublicURL": "",
"QueryDate": 0,
"SecuritySettings": {

View File

@@ -296,9 +296,9 @@ func TestService_HardRefresh_ListRefs_GitHub(t *testing.T) {
assert.GreaterOrEqual(t, len(refs), 1)
assert.Equal(t, 1, service.repoRefCache.Len())
refs, err = service.ListRefs(repositoryUrl, username, "fake-token", true)
refs, err = service.ListRefs(repositoryUrl, username, "fake-token", false)
assert.Error(t, err)
assert.Equal(t, 0, service.repoRefCache.Len())
assert.Equal(t, 1, service.repoRefCache.Len())
}
func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
@@ -324,10 +324,13 @@ func TestService_HardRefresh_ListRefs_And_RemoveAllCaches_GitHub(t *testing.T) {
assert.GreaterOrEqual(t, len(files), 1)
assert.Equal(t, 2, service.repoFileCache.Len())
refs, err = service.ListRefs(repositoryUrl, username, "fake-token", false)
assert.Error(t, err)
assert.Equal(t, 1, service.repoRefCache.Len())
refs, err = service.ListRefs(repositoryUrl, username, "fake-token", true)
assert.Error(t, err)
assert.Equal(t, 0, service.repoRefCache.Len())
assert.Equal(t, 1, service.repoRefCache.Len())
// The relevant file caches should be removed too
assert.Equal(t, 0, service.repoFileCache.Len())
}

View File

@@ -3,12 +3,12 @@ package git
import (
"context"
"errors"
"log"
"strings"
"sync"
"time"
lru "github.com/hashicorp/golang-lru"
"github.com/rs/zerolog/log"
)
var (
@@ -78,12 +78,12 @@ func newService(ctx context.Context, cacheSize int, cacheTTL time.Duration) *Ser
var err error
service.repoRefCache, err = lru.New(cacheSize)
if err != nil {
log.Printf("[DEBUG] [git] [message: failed to create ref cache: %v\n", err)
log.Debug().Err(err).Msg("failed to create ref cache")
}
service.repoFileCache, err = lru.New(cacheSize)
if err != nil {
log.Printf("[DEBUG] [git] [message: failed to create file cache: %v\n", err)
log.Debug().Err(err).Msg("failed to create file cache")
}
if cacheTTL > 0 {
@@ -167,9 +167,10 @@ func (service *Service) LatestCommitID(repositoryURL, referenceName, username, p
// ListRefs will list target repository's references without cloning the repository
func (service *Service) ListRefs(repositoryURL, username, password string, hardRefresh bool) ([]string, error) {
refCacheKey := generateCacheKey(repositoryURL, password)
if service.cacheEnabled && hardRefresh {
// Should remove the cache explicitly, so that the following normal list can show the correct result
service.repoRefCache.Remove(repositoryURL)
service.repoRefCache.Remove(refCacheKey)
// Remove file caches pointed to the same repository
for _, fileCacheKey := range service.repoFileCache.Keys() {
key, ok := fileCacheKey.(string)
@@ -183,7 +184,7 @@ func (service *Service) ListRefs(repositoryURL, username, password string, hardR
if service.repoRefCache != nil {
// Lookup the refs cache first
cache, ok := service.repoRefCache.Get(repositoryURL)
cache, ok := service.repoRefCache.Get(refCacheKey)
if ok {
refs, success := cache.([]string)
if success {
@@ -215,7 +216,7 @@ func (service *Service) ListRefs(repositoryURL, username, password string, hardR
}
if service.cacheEnabled && service.repoRefCache != nil {
service.repoRefCache.Add(options.repositoryUrl, refs)
service.repoRefCache.Add(refCacheKey, refs)
}
return refs, nil
}

View File

@@ -31,6 +31,7 @@ require (
github.com/json-iterator/go v1.1.12
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkg/errors v0.9.1
github.com/portainer/docker-compose-wrapper v0.0.0-20220708023447-a69a4ebaa021
github.com/portainer/libcrypto v0.0.0-20220506221303-1f4fb3b30f9a

View File

@@ -352,6 +352,8 @@ github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrB
github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6 h1:lNCW6THrCKBiJBpz8kbVGjC7MgdCGKwuvBgc7LoD6sw=
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

View File

@@ -2,6 +2,7 @@ package kubernetes
import (
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@@ -9,7 +10,23 @@ import (
)
func (handler *Handler) getKubernetesConfigMaps(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
@@ -22,7 +39,7 @@ func (handler *Handler) getKubernetesConfigMaps(w http.ResponseWriter, r *http.R
configmaps, err := cli.GetConfigMapsAndSecrets(namespace)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to retrieve configmaps and secrets",
err,
)
}

View File

@@ -3,6 +3,8 @@ package kubernetes
import (
"errors"
"net/http"
"net/url"
"strconv"
portainer "github.com/portainer/portainer/api"
portainerDsErrors "github.com/portainer/portainer/api/dataservices/errors"
@@ -24,7 +26,6 @@ type Handler struct {
*mux.Router
authorizationService *authorization.Service
dataStore dataservices.DataStore
KubernetesClient portainer.KubeClient
kubernetesClientFactory *cli.ClientFactory
jwtService dataservices.JWTService
kubeClusterAccessService kubernetes.KubeClusterAccessService
@@ -39,7 +40,6 @@ func NewHandler(bouncer *security.RequestBouncer, authorizationService *authoriz
jwtService: jwtService,
kubeClusterAccessService: kubeClusterAccessService,
kubernetesClientFactory: kubernetesClientFactory,
KubernetesClient: kubernetesClient,
}
kubeRouter := h.PathPrefix("/kubernetes").Subrouter()
@@ -85,13 +85,19 @@ func kubeOnlyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
endpoint, err := middlewares.FetchEndpoint(request)
if err != nil {
httperror.WriteError(rw, http.StatusInternalServerError, "Unable to find an environment on request context", err)
httperror.InternalServerError(
"Unable to find an environment on request context",
err,
)
return
}
if !endpointutils.IsKubernetesEndpoint(endpoint) {
errMessage := "Environment is not a kubernetes environment"
httperror.WriteError(rw, http.StatusBadRequest, errMessage, errors.New(errMessage))
errMessage := "environment is not a Kubernetes environment"
httperror.BadRequest(
errMessage,
errors.New(errMessage),
)
return
}
@@ -109,6 +115,7 @@ func (handler *Handler) kubeClient(next http.Handler) http.Handler {
"Invalid environment identifier route variable",
err,
)
return
}
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
@@ -119,6 +126,7 @@ func (handler *Handler) kubeClient(next http.Handler) http.Handler {
"Unable to find an environment with the specified identifier inside the database",
err,
)
return
} else if err != nil {
httperror.WriteError(
w,
@@ -126,23 +134,101 @@ func (handler *Handler) kubeClient(next http.Handler) http.Handler {
"Unable to find an environment with the specified identifier inside the database",
err,
)
return
}
if handler.kubernetesClientFactory == nil {
next.ServeHTTP(w, r)
return
}
kubeCli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
// Generate a proxied kubeconfig, then create a kubeclient using it.
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteError(
w,
http.StatusForbidden,
"Permission denied to access environment",
err,
)
return
}
bearerToken, err := handler.jwtService.GenerateTokenForKubeconfig(tokenData)
if err != nil {
httperror.WriteError(
w,
http.StatusInternalServerError,
"Unable to create Kubernetes client",
"Unable to create JWT token",
err,
)
return
}
handler.KubernetesClient = kubeCli
singleEndpointList := []portainer.Endpoint{
*endpoint,
}
config, handlerErr := handler.buildConfig(
r,
tokenData,
bearerToken,
singleEndpointList,
)
if err != nil {
httperror.WriteError(
w,
http.StatusInternalServerError,
"Unable to build endpoint kubeconfig",
handlerErr.Err,
)
return
}
if len(config.Clusters) == 0 {
httperror.WriteError(
w,
http.StatusInternalServerError,
"Unable build cluster kubeconfig",
errors.New("Unable build cluster kubeconfig"),
)
return
}
// Manually setting the localhost to route
// the request to proxy server
serverURL, err := url.Parse(config.Clusters[0].Cluster.Server)
if err != nil {
httperror.WriteError(
w,
http.StatusInternalServerError,
"Unable parse cluster's kubeconfig server URL",
nil,
)
return
}
serverURL.Scheme = "https"
serverURL.Host = "localhost" + handler.kubernetesClientFactory.AddrHTTPS
config.Clusters[0].Cluster.Server = serverURL.String()
yaml, err := cli.GenerateYAML(config)
if err != nil {
httperror.WriteError(
w,
http.StatusInternalServerError,
"Unable to generate yaml from endpoint kubeconfig",
err,
)
return
}
kubeCli, err := handler.kubernetesClientFactory.CreateKubeClientFromKubeConfig(endpoint.Name, []byte(yaml))
if err != nil {
httperror.WriteError(
w,
http.StatusInternalServerError,
"Failed to create client from kubeconfig",
err,
)
return
}
handler.kubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), r.Header.Get("Authorization"), kubeCli)
next.ServeHTTP(w, r)
})
}

View File

@@ -2,6 +2,7 @@ package kubernetes
import (
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@@ -49,43 +50,48 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
)
}
controllers := cli.GetIngressControllers()
controllers, err := cli.GetIngressControllers()
if err != nil {
return httperror.InternalServerError(
"Failed to fetch ingressclasses",
err,
)
}
// Add none controller if "AllowNone" is set for endpoint.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
ClassName: "none",
Type: "custom",
})
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
var updatedClasses []portainer.KubernetesIngressClassConfig
for i := range controllers {
controllers[i].Availability = true
controllers[i].New = true
// Check if the controller is blocked globally.
for _, a := range existingClasses {
controllers[i].New = false
if controllers[i].ClassName != a.Name {
continue
}
controllers[i].New = false
// Skip over non-global blocks.
if len(a.BlockedNamespaces) > 0 {
continue
}
if controllers[i].ClassName == a.Name {
controllers[i].Availability = !a.GloballyBlocked
}
if controllers[i].ClassName != "none" {
controllers[i].New = true
}
var updatedClass portainer.KubernetesIngressClassConfig
updatedClass.Name = controllers[i].ClassName
updatedClass.Type = controllers[i].Type
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
// Update the database to match the list of found + modified controllers.
// This includes pruning out controllers which no longer exist.
var newClasses []portainer.KubernetesIngressClassConfig
for _, controller := range controllers {
var class portainer.KubernetesIngressClassConfig
class.Name = controller.ClassName
class.Type = controller.Type
class.GloballyBlocked = !controller.Availability
class.BlockedNamespaces = []string{}
newClasses = append(newClasses, class)
}
endpoint.Kubernetes.Configuration.IngressClasses = newClasses
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.dataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
@@ -141,15 +147,42 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
)
}
cli := handler.KubernetesClient
currentControllers := cli.GetIngressControllers()
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
currentControllers, err := cli.GetIngressControllers()
if err != nil {
return httperror.InternalServerError(
"Failed to fetch ingressclasses",
err,
)
}
// Add none controller if "AllowNone" is set for endpoint.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
currentControllers = append(currentControllers, models.K8sIngressController{
Name: "none",
ClassName: "none",
Type: "custom",
})
}
kubernetesConfig := endpoint.Kubernetes.Configuration
existingClasses := kubernetesConfig.IngressClasses
ingressAvailabilityPerNamespace := kubernetesConfig.IngressAvailabilityPerNamespace
var updatedClasses []portainer.KubernetesIngressClassConfig
var controllers models.K8sIngressControllers
for i := range currentControllers {
var globallyblocked bool
currentControllers[i].Availability = true
currentControllers[i].New = true
if currentControllers[i].ClassName != "none" {
currentControllers[i].New = true
}
var updatedClass portainer.KubernetesIngressClassConfig
updatedClass.Name = currentControllers[i].ClassName
@@ -167,10 +200,12 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
globallyblocked = existingClass.GloballyBlocked
// Check if the current namespace is blocked.
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
currentControllers[i].Availability = false
// Check if the current namespace is blocked if ingressAvailabilityPerNamespace is set to true
if ingressAvailabilityPerNamespace {
for _, ns := range existingClass.BlockedNamespaces {
if namespace == ns {
currentControllers[i].Availability = false
}
}
}
}
@@ -197,6 +232,7 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
}
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
@@ -236,39 +272,55 @@ func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter
}
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
controllers := cli.GetIngressControllers()
controllers, err := cli.GetIngressControllers()
if err != nil {
return httperror.InternalServerError(
"Unable to get ingress controllers",
err,
)
}
// Add none controller if "AllowNone" is set for endpoint.
if endpoint.Kubernetes.Configuration.AllowNoneIngressClass {
controllers = append(controllers, models.K8sIngressController{
Name: "none",
ClassName: "none",
Type: "custom",
})
}
var updatedClasses []portainer.KubernetesIngressClassConfig
for i := range controllers {
// Set existing class data. So that we don't accidentally overwrite it
// with blank data that isn't in the payload.
for ii := range existingClasses {
if controllers[i].ClassName == existingClasses[ii].Name {
controllers[i].Availability = !existingClasses[ii].GloballyBlocked
controllers[i].Availability = true
controllers[i].New = true
var updatedClass portainer.KubernetesIngressClassConfig
updatedClass.Name = controllers[i].ClassName
updatedClass.Type = controllers[i].Type
// Check if the controller is already known.
for _, existingClass := range existingClasses {
if controllers[i].ClassName != existingClass.Name {
continue
}
controllers[i].New = false
controllers[i].Availability = !existingClass.GloballyBlocked
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
}
updatedClasses = append(updatedClasses, updatedClass)
}
for _, p := range payload {
for i := range controllers {
// Now set new payload data
if p.ClassName == controllers[i].ClassName {
controllers[i].Availability = p.Availability
if updatedClasses[i].Name == p.ClassName {
updatedClasses[i].GloballyBlocked = !p.Availability
}
}
}
// Update the database to match the list of found + modified controllers.
// This includes pruning out controllers which no longer exist.
var newClasses []portainer.KubernetesIngressClassConfig
for _, controller := range controllers {
var class portainer.KubernetesIngressClassConfig
class.Name = controller.ClassName
class.Type = controller.Type
class.GloballyBlocked = !controller.Availability
class.BlockedNamespaces = []string{}
newClasses = append(newClasses, class)
}
endpoint.Kubernetes.Configuration.IngressClasses = newClasses
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.dataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
endpoint,
@@ -327,7 +379,6 @@ PayloadLoop:
for _, p := range payload {
for _, existingClass := range existingClasses {
if p.ClassName != existingClass.Name {
updatedClasses = append(updatedClasses, existingClass)
continue
}
var updatedClass portainer.KubernetesIngressClassConfig
@@ -345,7 +396,7 @@ PayloadLoop:
}
}
updatedClasses = append(updatedClasses, existingClass)
updatedClasses = append(updatedClasses, updatedClass)
continue PayloadLoop
}
@@ -356,7 +407,7 @@ PayloadLoop:
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
for _, ns := range updatedClass.BlockedNamespaces {
if namespace == ns {
updatedClasses = append(updatedClasses, existingClass)
updatedClasses = append(updatedClasses, updatedClass)
continue PayloadLoop
}
}
@@ -368,6 +419,24 @@ PayloadLoop:
}
}
// At this point it's possible we had an existing class which was globally
// blocked and thus not included in the payload. As a result it is not yet
// part of updatedClasses, but we MUST include it or we would remove the
// global block.
for _, existingClass := range existingClasses {
var found bool
for _, updatedClass := range updatedClasses {
if existingClass.Name == updatedClass.Name {
found = true
}
}
if !found {
updatedClasses = append(updatedClasses, existingClass)
}
}
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
err = handler.dataStore.Endpoint().UpdateEndpoint(
portainer.EndpointID(endpointID),
@@ -391,11 +460,28 @@ func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Re
)
}
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
ingresses, err := cli.GetIngresses(namespace)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to retrieve ingresses",
err,
)
}
@@ -421,11 +507,28 @@ func (handler *Handler) createKubernetesIngress(w http.ResponseWriter, r *http.R
)
}
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
err = cli.CreateIngress(namespace, payload)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to retrieve the ingress",
err,
)
}
@@ -433,10 +536,26 @@ func (handler *Handler) createKubernetesIngress(w http.ResponseWriter, r *http.R
}
func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
var payload models.K8sIngressDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
@@ -444,7 +563,7 @@ func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http
err = cli.DeleteIngresses(payload)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to delete ingresses",
err,
)
}
@@ -469,11 +588,28 @@ func (handler *Handler) updateKubernetesIngress(w http.ResponseWriter, r *http.R
)
}
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
err = cli.UpdateIngress(namespace, payload)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to update the ingress",
err,
)
}

View File

@@ -2,6 +2,7 @@ package kubernetes
import (
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@@ -10,12 +11,28 @@ import (
)
func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
namespaces, err := cli.GetNamespaces()
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to retrieve namespaces",
err,
)
}
@@ -24,10 +41,26 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
}
func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
var payload models.K8sNamespaceDetails
err := request.DecodeAndValidateJSONPayload(r, &payload)
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest(
"Invalid request payload",
@@ -38,7 +71,7 @@ func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http
err = cli.CreateNamespace(payload)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to create namespace",
err,
)
}
@@ -46,7 +79,23 @@ func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http
}
func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
if err != nil {
@@ -59,7 +108,7 @@ func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *htt
err = cli.DeleteNamespace(namespace)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to delete namespace",
err,
)
}
@@ -68,10 +117,26 @@ func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *htt
}
func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
var payload models.K8sNamespaceDetails
err := request.DecodeAndValidateJSONPayload(r, &payload)
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusBadRequest,
@@ -82,11 +147,7 @@ func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http
err = cli.UpdateNamespace(payload)
if err != nil {
return &httperror.HandlerError{
StatusCode: http.StatusInternalServerError,
Message: "Unable to retrieve nodes limits",
Err: err,
}
return httperror.InternalServerError("Unable to update namespace", err)
}
return nil
}

View File

@@ -2,6 +2,7 @@ package kubernetes
import (
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
@@ -18,7 +19,24 @@ func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Req
)
}
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
services, err := cli.GetServices(namespace)
if err != nil {
return httperror.InternalServerError(
@@ -48,11 +66,28 @@ func (handler *Handler) createKubernetesService(w http.ResponseWriter, r *http.R
)
}
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
err = cli.CreateService(namespace, payload)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to create sercice",
err,
)
}
@@ -60,10 +95,26 @@ func (handler *Handler) createKubernetesService(w http.ResponseWriter, r *http.R
}
func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
var payload models.K8sServiceDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest(
"Invalid request payload",
@@ -74,7 +125,7 @@ func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.
err = cli.DeleteServices(payload)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to delete service",
err,
)
}
@@ -99,11 +150,27 @@ func (handler *Handler) updateKubernetesService(w http.ResponseWriter, r *http.R
)
}
cli := handler.KubernetesClient
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
cli, ok := handler.kubernetesClientFactory.GetProxyKubeClient(
strconv.Itoa(endpointID), r.Header.Get("Authorization"),
)
if !ok {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
nil,
)
}
err = cli.UpdateService(namespace, payload)
if err != nil {
return httperror.InternalServerError(
"Unable to retrieve nodes limits",
"Unable to update service",
err,
)
}

View File

@@ -57,15 +57,6 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
endpointURL.Scheme = "https"
}
snapshot, err := factory.dataStore.Snapshot().Snapshot(endpoint.ID)
if err != nil {
return nil, err
}
if snapshot.Docker != nil {
endpoint.Snapshots = []portainer.DockerSnapshot{*snapshot.Docker}
}
transportParameters := &docker.TransportParameters{
Endpoint: endpoint,
DataStore: factory.dataStore,

View File

@@ -29,9 +29,13 @@ type postDockerfileRequest struct {
func buildOperation(request *http.Request) error {
contentTypeHeader := request.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentTypeHeader)
if err != nil {
return err
mediaType := ""
if contentTypeHeader != "" {
var err error
mediaType, _, err = mime.ParseMediaType(contentTypeHeader)
if err != nil {
return err
}
}
var buffer []byte
@@ -49,7 +53,8 @@ func buildOperation(request *http.Request) error {
case "application/json":
var req postDockerfileRequest
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
err := json.NewDecoder(request.Body).Decode(&req)
if err != nil {
return err
}

View File

@@ -2,15 +2,18 @@ package cli
import (
"fmt"
cmap "github.com/orcaman/concurrent-map"
"net/http"
"strconv"
"sync"
"time"
cmap "github.com/orcaman/concurrent-map"
"github.com/patrickmn/go-cache"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
@@ -24,6 +27,8 @@ type (
signatureService portainer.DigitalSignatureService
instanceID string
endpointClients cmap.ConcurrentMap
endpointProxyClients *cache.Cache
AddrHTTPS string
}
// KubeClient represent a service used to execute Kubernetes operations
@@ -35,14 +40,24 @@ type (
)
// NewClientFactory returns a new instance of a ClientFactory
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore dataservices.DataStore) *ClientFactory {
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore, instanceID, addrHTTPS, userSessionTimeout string) (*ClientFactory, error) {
if userSessionTimeout == "" {
userSessionTimeout = portainer.DefaultUserSessionTimeout
}
timeout, err := time.ParseDuration(userSessionTimeout)
if err != nil {
return nil, err
}
return &ClientFactory{
dataStore: dataStore,
signatureService: signatureService,
reverseTunnelService: reverseTunnelService,
instanceID: instanceID,
endpointClients: cmap.New(),
}
endpointProxyClients: cache.New(timeout, timeout),
AddrHTTPS: addrHTTPS,
}, nil
}
func (factory *ClientFactory) GetInstanceID() (instanceID string) {
@@ -60,7 +75,7 @@ func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (porta
key := strconv.Itoa(int(endpoint.ID))
client, ok := factory.endpointClients.Get(key)
if !ok {
client, err := factory.createKubeClient(endpoint)
client, err := factory.createCachedAdminKubeClient(endpoint)
if err != nil {
return nil, err
}
@@ -72,7 +87,49 @@ func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (porta
return client.(portainer.KubeClient), nil
}
func (factory *ClientFactory) createKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) {
// GetProxyKubeClient retrieves a KubeClient from the cache. You should be
// calling SetProxyKubeClient before first. It is normally, called the
// kubernetes middleware.
func (factory *ClientFactory) GetProxyKubeClient(endpointID, token string) (portainer.KubeClient, bool) {
client, ok := factory.endpointProxyClients.Get(endpointID + "." + token)
if !ok {
return nil, false
}
return client.(portainer.KubeClient), true
}
// SetProxyKubeClient stores a kubeclient in the cache.
func (factory *ClientFactory) SetProxyKubeClient(endpointID, token string, cli portainer.KubeClient) {
factory.endpointProxyClients.Set(endpointID+"."+token, cli, 0)
}
// CreateKubeClientFromKubeConfig creates a KubeClient from a clusterID, and
// Kubernetes config.
func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, kubeConfig []byte) (portainer.KubeClient, error) {
config, err := clientcmd.NewClientConfigFromBytes([]byte(kubeConfig))
if err != nil {
return nil, err
}
cliConfig, err := config.ClientConfig()
if err != nil {
return nil, err
}
cli, err := kubernetes.NewForConfig(cliConfig)
if err != nil {
return nil, err
}
kubecli := &KubeClient{
cli: cli,
instanceID: factory.instanceID,
lock: &sync.Mutex{},
}
return kubecli, nil
}
func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) {
cli, err := factory.CreateClient(endpoint)
if err != nil {
return nil, err
@@ -164,3 +221,127 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
return kubernetes.NewForConfig(config)
}
func (factory *ClientFactory) PostInitMigrateIngresses() error {
endpoints, err := factory.dataStore.Endpoint().Endpoints()
if err != nil {
return err
}
for i := range endpoints {
// Early exit if we do not need to migrate!
if endpoints[i].PostInitMigrations.MigrateIngresses == false {
return nil
}
err := factory.migrateEndpointIngresses(&endpoints[i])
if err != nil {
log.Debug().Err(err).Msg("failure migrating endpoint ingresses")
}
}
return nil
}
func (factory *ClientFactory) migrateEndpointIngresses(e *portainer.Endpoint) error {
// classes is a list of controllers which have been manually added to the
// cluster setup view. These need to all be allowed globally, but then
// blocked in specific namespaces which they were not previously allowed in.
classes := e.Kubernetes.Configuration.IngressClasses
// We need a kube client to gather namespace level permissions. In pre-2.16
// versions of portainer, the namespace level permissions were stored by
// creating an actual ingress rule in the cluster with a particular
// annotation indicating that it's name (the class name) should be allowed.
cli, err := factory.GetKubeClient(e)
if err != nil {
return err
}
detected, err := cli.GetIngressControllers()
if err != nil {
return err
}
// newControllers is a set of all currently detected controllers.
newControllers := make(map[string]struct{})
for _, controller := range detected {
newControllers[controller.ClassName] = struct{}{}
}
namespaces, err := cli.GetNamespaces()
if err != nil {
return err
}
// Set of namespaces, if any, in which "allow none" should be true.
allow := make(map[string]map[string]struct{})
for _, c := range classes {
allow[c.Name] = make(map[string]struct{})
}
allow["none"] = make(map[string]struct{})
for namespace := range namespaces {
// Compare old annotations with currently detected controllers.
ingresses, err := cli.GetIngresses(namespace)
if err != nil {
return fmt.Errorf("failure getting ingresses during migration")
}
for _, ingress := range ingresses {
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
if !ok {
// Skip rules without our old annotation.
continue
}
if _, ok := newControllers[oldController]; ok {
// Skip rules which match a detected controller.
// TODO: Allow this particular controller.
allow[oldController][ingress.Namespace] = struct{}{}
continue
}
allow["none"][ingress.Namespace] = struct{}{}
}
}
// Locally, disable "allow none" for namespaces not inside shouldAllowNone.
var newClasses []portainer.KubernetesIngressClassConfig
for _, c := range classes {
var blocked []string
for namespace := range namespaces {
if _, ok := allow[c.Name][namespace]; ok {
continue
}
blocked = append(blocked, namespace)
}
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
Name: c.Name,
Type: c.Type,
GloballyBlocked: false,
BlockedNamespaces: blocked,
})
}
// Handle "none".
if len(allow["none"]) != 0 {
e.Kubernetes.Configuration.AllowNoneIngressClass = true
var disallowNone []string
for namespace := range namespaces {
if _, ok := allow["none"][namespace]; ok {
continue
}
disallowNone = append(disallowNone, namespace)
}
newClasses = append(newClasses, portainer.KubernetesIngressClassConfig{
Name: "none",
Type: "custom",
GloballyBlocked: false,
BlockedNamespaces: disallowNone,
})
}
e.Kubernetes.Configuration.IngressClasses = newClasses
e.PostInitMigrations.MigrateIngresses = false
return factory.dataStore.Endpoint().UpdateEndpoint(e.ID, e)
}

View File

@@ -5,11 +5,12 @@ import (
"strings"
"github.com/portainer/portainer/api/database/models"
"github.com/rs/zerolog/log"
netv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func (kcl *KubeClient) GetIngressControllers() models.K8sIngressControllers {
func (kcl *KubeClient) GetIngressControllers() (models.K8sIngressControllers, error) {
var controllers []models.K8sIngressController
// We know that each existing class points to a controller so we can start
@@ -17,19 +18,22 @@ func (kcl *KubeClient) GetIngressControllers() models.K8sIngressControllers {
classClient := kcl.cli.NetworkingV1().IngressClasses()
classList, err := classClient.List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil
return nil, err
}
// We want to know which of these controllers is in use.
var ingresses []models.K8sIngressInfo
namespaces, err := kcl.GetNamespaces()
if err != nil {
return nil
return nil, err
}
for namespace := range namespaces {
t, err := kcl.GetIngresses(namespace)
if err != nil {
return nil
// User might not be able to list ingresses in system/not allowed
// namespaces.
log.Debug().Err(err).Msg("failed to list ingresses for the current user, skipped sending ingress")
continue
}
ingresses = append(ingresses, t...)
}
@@ -58,7 +62,7 @@ func (kcl *KubeClient) GetIngressControllers() models.K8sIngressControllers {
}
controllers = append(controllers, controller)
}
return controllers
return controllers, nil
}
// GetIngresses gets all the ingresses for a given namespace in a k8s endpoint.
@@ -86,11 +90,11 @@ func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo,
var infos []models.K8sIngressInfo
for _, ingress := range ingressList.Items {
ingressClass := ingress.Spec.IngressClassName
var info models.K8sIngressInfo
info.Name = ingress.Name
info.UID = string(ingress.UID)
info.Namespace = namespace
ingressClass := ingress.Spec.IngressClassName
info.ClassName = ""
if ingressClass != nil {
info.ClassName = *ingressClass
@@ -109,6 +113,10 @@ func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo,
// Gather list of paths and hosts.
hosts := make(map[string]struct{})
for _, r := range ingress.Spec.Rules {
// We collect all exiting hosts in a map to avoid duplicates.
// Then, later convert it to a slice for the frontend.
hosts[r.Host] = struct{}{}
if r.HTTP == nil {
continue
}
@@ -120,12 +128,10 @@ func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo,
path.IngressName = info.Name
path.Host = r.Host
// We collect all exiting hosts in a map to avoid duplicates.
// Then, later convert it to a slice for the frontend.
hosts[r.Host] = struct{}{}
path.Path = p.Path
path.PathType = string(*p.PathType)
if p.PathType != nil {
path.PathType = string(*p.PathType)
}
path.ServiceName = p.Backend.Service.Name
path.Port = int(p.Backend.Service.Port.Number)
info.Paths = append(info.Paths, path)
@@ -150,7 +156,9 @@ func (kcl *KubeClient) CreateIngress(namespace string, info models.K8sIngressInf
ingress.Name = info.Name
ingress.Namespace = info.Namespace
ingress.Spec.IngressClassName = &info.ClassName
if info.ClassName != "" {
ingress.Spec.IngressClassName = &info.ClassName
}
ingress.Annotations = info.Annotations
// Store TLS information.
@@ -220,7 +228,9 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
ingress.Name = info.Name
ingress.Namespace = info.Namespace
ingress.Spec.IngressClassName = &info.ClassName
if info.ClassName != "" {
ingress.Spec.IngressClassName = &info.ClassName
}
ingress.Annotations = info.Annotations
// Store TLS information.

View File

@@ -85,7 +85,7 @@ func Test_GenerateYAML(t *testing.T) {
t.Errorf("generateYamlConfig failed; err=%s", err)
}
if compareYAMLStrings(yaml, ryt.wantYAML) != 0 {
if compareYAMLStrings(string(yaml), ryt.wantYAML) != 0 {
t.Errorf("generateYamlConfig failed;\ngot=\n%s\nwant=\n%s", yaml, ryt.wantYAML)
}
})

View File

@@ -25,6 +25,11 @@ func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule {
Resources: []string{"namespaces", "pods", "nodes"},
APIGroups: []string{"metrics.k8s.io"},
},
{
Verbs: []string{"list"},
Resources: []string{"ingressclasses"},
APIGroups: []string{"networking.k8s.io"},
},
}
}

View File

@@ -106,7 +106,7 @@ func (service *kubeClusterAccessService) GetData(hostURL string, endpointID port
baseURL = fmt.Sprintf("/%s/", strings.Trim(baseURL, "/"))
}
log.Info().
log.Debug().
Str("host_URL", hostURL).
Str("HTTPS_bind_address", service.httpsBindAddr).
Str("base_URL", baseURL).

View File

@@ -130,6 +130,7 @@ type (
MaxBatchDelay *time.Duration
SecretKeyName *string
LogLevel *string
LogMode *string
}
// CustomTemplateVariableDefinition
@@ -351,6 +352,9 @@ type (
// Whether the device has been trusted or not by the user
UserTrusted bool
// Whether we need to run any "post init migrations".
PostInitMigrations EndpointPostInitMigrations `json:"PostInitMigrations"`
Edge struct {
// Whether the device has been started in edge async mode
AsyncMode bool
@@ -452,6 +456,11 @@ type (
EdgeStacks map[EdgeStackID]bool
}
// EndpointPostInitMigrations
EndpointPostInitMigrations struct {
MigrateIngresses bool `json:"MigrateIngresses"`
}
// Extension represents a deprecated Portainer extension
Extension struct {
// Extension Identifier
@@ -554,6 +563,7 @@ type (
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"`
IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace"`
AllowNoneIngressClass bool `json:"AllowNoneIngressClass"`
}
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration
@@ -1357,7 +1367,7 @@ type (
GetNamespaces() (map[string]K8sNamespaceInfo, error)
DeleteNamespace(namespace string) error
GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error)
GetIngressControllers() models.K8sIngressControllers
GetIngressControllers() (models.K8sIngressControllers, error)
CreateIngress(namespace string, info models.K8sIngressInfo) error
UpdateIngress(namespace string, info models.K8sIngressInfo) error
GetIngresses(namespace string) ([]models.K8sIngressInfo, error)

View File

@@ -86,7 +86,7 @@
<div class="row" style="height: 54%">
<div class="col-sm-12" style="height: 100%">
<pre ng-class="{ wrap_lines: $ctrl.state.wrapLines }" class="log_viewer" scroll-glue="$ctrl.state.autoScroll" force-glue>
<div ng-repeat="log in $ctrl.state.filteredLogs = ($ctrl.data | filter:{ 'line': $ctrl.state.search }) track by $index" class="line" ng-if="log.line"><p class="inner_line" ng-click="$ctrl.selectLine(log.line)" ng-class="{ 'line_selected': $ctrl.state.selectedLines.indexOf(log.line) > -1 }"><span ng-repeat="span in log.spans track by $index" ng-style="{ 'color': span.foregroundColor, 'background-color': span.backgroundColor, 'font-weight': span.fontWeight }">{{ span.text }}</span></p></div>
<div ng-repeat="log in $ctrl.state.filteredLogs = ($ctrl.data | filter:{ 'line': $ctrl.state.search }) track by $index" class="line" ng-if="log.line"><p class="inner_line" ng-click="$ctrl.selectLine(log.line)" ng-class="{ 'line_selected': $ctrl.state.selectedLines.indexOf(log.line) > -1 }"><span ng-repeat="span in log.spans track by $index" ng-style="{ 'color': span.fgColor, 'background-color': span.bgColor, 'font-weight': span.fontWeight }">{{ span.text }}</span></p></div>
<div ng-if="!$ctrl.state.filteredLogs.length" class="line"><p class="inner_line">No log line matching the '{{ $ctrl.state.search }}' filter</p></div>
<div ng-if="$ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0].line" class="line"><p class="inner_line">No logs available</p></div>
</pre>

View File

@@ -1,6 +1,7 @@
import moment from 'moment';
import _ from 'lodash-es';
import { NEW_LINE_BREAKER } from '@/constants';
import { concatLogsToString } from '@/docker/helpers/logHelper';
angular.module('portainer.docker').controller('LogViewerController', [
'$scope',
@@ -74,9 +75,8 @@ angular.module('portainer.docker').controller('LogViewerController', [
};
this.downloadLogs = function () {
// To make the feature of downloading container logs working both on Windows and Linux,
// we need to use correct new line breakers on corresponding OS.
const data = new Blob([_.reduce(this.state.filteredLogs, (acc, log) => acc + log.line + NEW_LINE_BREAKER, '')]);
const logsAsString = concatLogsToString(this.state.filteredLogs);
const data = new Blob([logsAsString]);
FileSaver.saveAs(data, this.resourceName + '_logs.txt');
};
},

View File

@@ -1,223 +0,0 @@
import tokenize from '@nxmix/tokenize-ansi';
import x256 from 'x256';
import { takeRight, without } from 'lodash';
import { format } from 'date-fns';
const FOREGROUND_COLORS_BY_ANSI = {
black: x256.colors[0],
red: x256.colors[1],
green: x256.colors[2],
yellow: x256.colors[3],
blue: x256.colors[4],
magenta: x256.colors[5],
cyan: x256.colors[6],
white: x256.colors[7],
brightBlack: x256.colors[8],
brightRed: x256.colors[9],
brightGreen: x256.colors[10],
brightYellow: x256.colors[11],
brightBlue: x256.colors[12],
brightMagenta: x256.colors[13],
brightCyan: x256.colors[14],
brightWhite: x256.colors[15],
};
const BACKGROUND_COLORS_BY_ANSI = {
bgBlack: x256.colors[0],
bgRed: x256.colors[1],
bgGreen: x256.colors[2],
bgYellow: x256.colors[3],
bgBlue: x256.colors[4],
bgMagenta: x256.colors[5],
bgCyan: x256.colors[6],
bgWhite: x256.colors[7],
bgBrightBlack: x256.colors[8],
bgBrightRed: x256.colors[9],
bgBrightGreen: x256.colors[10],
bgBrightYellow: x256.colors[11],
bgBrightBlue: x256.colors[12],
bgBrightMagenta: x256.colors[13],
bgBrightCyan: x256.colors[14],
bgBrightWhite: x256.colors[15],
};
const TIMESTAMP_LENGTH = 31; // 30 for timestamp + 1 for trailing space
angular.module('portainer.docker').factory('LogHelper', [
function LogHelperFactory() {
'use strict';
var helper = {};
function stripHeaders(logs) {
logs = logs.substring(8);
logs = logs.replace(/\r?\n(.{8})/g, '\n');
return logs;
}
function stripEscapeCodes(logs) {
return logs.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
}
function cssColorFromRgb(rgb) {
const [r, g, b] = rgb;
return `rgb(${r}, ${g}, ${b})`;
}
function extendedColorForToken(token) {
const colorMode = token[1];
if (colorMode === 2) {
return cssColorFromRgb(token.slice(2));
}
if (colorMode === 5 && x256.colors[token[2]]) {
return cssColorFromRgb(x256.colors[token[2]]);
}
return '';
}
// Return an array with each log including a line and styled spans for each entry.
// If the stripHeaders param is specified, it will strip the 8 first characters of each line.
// withTimestamps param is needed to find the start of JSON for Zerolog logs parsing
helper.formatLogs = function (logs, { stripHeaders: skipHeaders, withTimestamps }) {
if (skipHeaders) {
logs = stripHeaders(logs);
}
const tokens = tokenize(logs);
const formattedLogs = [];
let foregroundColor = null;
let backgroundColor = null;
let line = '';
let spans = [];
for (const token of tokens) {
const type = token[0];
if (FOREGROUND_COLORS_BY_ANSI[type]) {
foregroundColor = cssColorFromRgb(FOREGROUND_COLORS_BY_ANSI[type]);
} else if (type === 'moreColor') {
foregroundColor = extendedColorForToken(token);
} else if (type === 'fgDefault') {
foregroundColor = null;
} else if (BACKGROUND_COLORS_BY_ANSI[type]) {
backgroundColor = cssColorFromRgb(BACKGROUND_COLORS_BY_ANSI[type]);
} else if (type === 'bgMoreColor') {
backgroundColor = extendedColorForToken(token);
} else if (type === 'bgDefault') {
backgroundColor = null;
} else if (type === 'reset') {
foregroundColor = null;
backgroundColor = null;
} else if (type === 'text') {
const tokenLines = token[1].split('\n');
for (let i = 0; i < tokenLines.length; i++) {
if (i !== 0) {
formattedLogs.push({ line, spans });
line = '';
spans = [];
}
const text = stripEscapeCodes(tokenLines[i]);
if ((!withTimestamps && text.startsWith('{')) || (withTimestamps && text.substring(TIMESTAMP_LENGTH).startsWith('{'))) {
line += JSONToFormattedLine(text, spans, withTimestamps);
} else {
spans.push({ foregroundColor, backgroundColor, text });
line += text;
}
}
}
}
if (line) {
formattedLogs.push({ line, spans });
}
return formattedLogs;
};
return helper;
},
]);
const JSONColors = {
Grey: 'var(--text-log-viewer-color-json-grey)',
Magenta: 'var(--text-log-viewer-color-json-magenta)',
Yellow: 'var(--text-log-viewer-color-json-yellow)',
Green: 'var(--text-log-viewer-color-json-green)',
Red: 'var(--text-log-viewer-color-json-red)',
Blue: 'var(--text-log-viewer-color-json-blue)',
};
const spaceSpan = { text: ' ' };
function logLevelToSpan(level) {
switch (level) {
case 'debug':
return { foregroundColor: JSONColors.Grey, text: 'DBG', fontWeight: 'bold' };
case 'info':
return { foregroundColor: JSONColors.Green, text: 'INF', fontWeight: 'bold' };
case 'warn':
return { foregroundColor: JSONColors.Yellow, text: 'WRN', fontWeight: 'bold' };
case 'error':
return { foregroundColor: JSONColors.Red, text: 'ERR', fontWeight: 'bold' };
default:
return { text: level };
}
}
function JSONToFormattedLine(rawText, spans, withTimestamps) {
const text = withTimestamps ? rawText.substring(TIMESTAMP_LENGTH) : rawText;
const json = JSON.parse(text);
const { level, caller, message, time } = json;
let line = '';
if (withTimestamps) {
const timestamp = rawText.substring(0, TIMESTAMP_LENGTH);
spans.push({ text: timestamp });
line += `${timestamp}`;
}
if (time) {
const date = format(new Date(time * 1000), 'Y/MM/dd hh:mmaa');
spans.push({ foregroundColor: JSONColors.Grey, text: date }, spaceSpan);
line += `${date} `;
}
if (level) {
const levelSpan = logLevelToSpan(level);
spans.push(levelSpan, spaceSpan);
line += `${levelSpan.text} `;
}
if (caller) {
const trimmedCaller = takeRight(caller.split('/'), 2).join('/');
spans.push({ foregroundColor: JSONColors.Magenta, text: trimmedCaller, fontWeight: 'bold' }, spaceSpan);
spans.push({ foregroundColor: JSONColors.Blue, text: '>' }, spaceSpan);
line += `${trimmedCaller} > `;
}
const keys = without(Object.keys(json), 'time', 'level', 'caller', 'message');
if (message) {
spans.push({ foregroundColor: JSONColors.Magenta, text: `${message}` }, spaceSpan);
line += `${message} `;
if (keys.length) {
spans.push({ foregroundColor: JSONColors.Magenta, text: `|` }, spaceSpan);
line += '| ';
}
}
keys.forEach((key) => {
const value = json[key];
spans.push({ foregroundColor: JSONColors.Blue, text: `${key}=` });
spans.push({ foregroundColor: key === 'error' ? JSONColors.Red : JSONColors.Magenta, text: value });
spans.push(spaceSpan);
line += `${key}=${value} `;
});
return line;
}

View File

@@ -0,0 +1,63 @@
// original code comes from https://www.npmjs.com/package/x256
// only picking the used parts as there is no type definition
// package is unmaintained and repository doesn't exist anymore
// colors scraped from
// http://www.calmar.ws/vim/256-xterm-24bit-rgb-color-chart.html
// %s/ *\d\+ \+#\([^ ]\+\)/\1\r/g
import rawColors from './rawColors.json';
export type RGBColor = [number, number, number];
export type TextColor = string | undefined;
function hexToRGB(hex: string): RGBColor {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return [r, g, b];
}
export const colors = rawColors.map(hexToRGB);
export const FOREGROUND_COLORS_BY_ANSI: {
[k: string]: RGBColor;
} = {
black: colors[0],
red: colors[1],
green: colors[2],
yellow: colors[3],
blue: colors[4],
magenta: colors[5],
cyan: colors[6],
white: colors[7],
brightBlack: colors[8],
brightRed: colors[9],
brightGreen: colors[10],
brightYellow: colors[11],
brightBlue: colors[12],
brightMagenta: colors[13],
brightCyan: colors[14],
brightWhite: colors[15],
};
export const BACKGROUND_COLORS_BY_ANSI: {
[k: string]: RGBColor;
} = {
bgBlack: colors[0],
bgRed: colors[1],
bgGreen: colors[2],
bgYellow: colors[3],
bgBlue: colors[4],
bgMagenta: colors[5],
bgCyan: colors[6],
bgWhite: colors[7],
bgBrightBlack: colors[8],
bgBrightRed: colors[9],
bgBrightGreen: colors[10],
bgBrightYellow: colors[11],
bgBrightBlue: colors[12],
bgBrightMagenta: colors[13],
bgBrightCyan: colors[14],
bgBrightWhite: colors[15],
};

View File

@@ -0,0 +1,7 @@
export {
type RGBColor,
type TextColor,
colors,
FOREGROUND_COLORS_BY_ANSI,
BACKGROUND_COLORS_BY_ANSI,
} from './colors';

View File

@@ -0,0 +1,258 @@
[
"000000",
"800000",
"008000",
"808000",
"000080",
"800080",
"008080",
"c0c0c0",
"808080",
"ff0000",
"00ff00",
"ffff00",
"0000ff",
"ff00ff",
"00ffff",
"ffffff",
"000000",
"00005f",
"000087",
"0000af",
"0000d7",
"0000ff",
"005f00",
"005f5f",
"005f87",
"005faf",
"005fd7",
"005fff",
"008700",
"00875f",
"008787",
"0087af",
"0087d7",
"0087ff",
"00af00",
"00af5f",
"00af87",
"00afaf",
"00afd7",
"00afff",
"00d700",
"00d75f",
"00d787",
"00d7af",
"00d7d7",
"00d7ff",
"00ff00",
"00ff5f",
"00ff87",
"00ffaf",
"00ffd7",
"00ffff",
"5f0000",
"5f005f",
"5f0087",
"5f00af",
"5f00d7",
"5f00ff",
"5f5f00",
"5f5f5f",
"5f5f87",
"5f5faf",
"5f5fd7",
"5f5fff",
"5f8700",
"5f875f",
"5f8787",
"5f87af",
"5f87d7",
"5f87ff",
"5faf00",
"5faf5f",
"5faf87",
"5fafaf",
"5fafd7",
"5fafff",
"5fd700",
"5fd75f",
"5fd787",
"5fd7af",
"5fd7d7",
"5fd7ff",
"5fff00",
"5fff5f",
"5fff87",
"5fffaf",
"5fffd7",
"5fffff",
"870000",
"87005f",
"870087",
"8700af",
"8700d7",
"8700ff",
"875f00",
"875f5f",
"875f87",
"875faf",
"875fd7",
"875fff",
"878700",
"87875f",
"878787",
"8787af",
"8787d7",
"8787ff",
"87af00",
"87af5f",
"87af87",
"87afaf",
"87afd7",
"87afff",
"87d700",
"87d75f",
"87d787",
"87d7af",
"87d7d7",
"87d7ff",
"87ff00",
"87ff5f",
"87ff87",
"87ffaf",
"87ffd7",
"87ffff",
"af0000",
"af005f",
"af0087",
"af00af",
"af00d7",
"af00ff",
"af5f00",
"af5f5f",
"af5f87",
"af5faf",
"af5fd7",
"af5fff",
"af8700",
"af875f",
"af8787",
"af87af",
"af87d7",
"af87ff",
"afaf00",
"afaf5f",
"afaf87",
"afafaf",
"afafd7",
"afafff",
"afd700",
"afd75f",
"afd787",
"afd7af",
"afd7d7",
"afd7ff",
"afff00",
"afff5f",
"afff87",
"afffaf",
"afffd7",
"afffff",
"d70000",
"d7005f",
"d70087",
"d700af",
"d700d7",
"d700ff",
"d75f00",
"d75f5f",
"d75f87",
"d75faf",
"d75fd7",
"d75fff",
"d78700",
"d7875f",
"d78787",
"d787af",
"d787d7",
"d787ff",
"d7af00",
"d7af5f",
"d7af87",
"d7afaf",
"d7afd7",
"d7afff",
"d7d700",
"d7d75f",
"d7d787",
"d7d7af",
"d7d7d7",
"d7d7ff",
"d7ff00",
"d7ff5f",
"d7ff87",
"d7ffaf",
"d7ffd7",
"d7ffff",
"ff0000",
"ff005f",
"ff0087",
"ff00af",
"ff00d7",
"ff00ff",
"ff5f00",
"ff5f5f",
"ff5f87",
"ff5faf",
"ff5fd7",
"ff5fff",
"ff8700",
"ff875f",
"ff8787",
"ff87af",
"ff87d7",
"ff87ff",
"ffaf00",
"ffaf5f",
"ffaf87",
"ffafaf",
"ffafd7",
"ffafff",
"ffd700",
"ffd75f",
"ffd787",
"ffd7af",
"ffd7d7",
"ffd7ff",
"ffff00",
"ffff5f",
"ffff87",
"ffffaf",
"ffffd7",
"ffffff",
"080808",
"121212",
"1c1c1c",
"262626",
"303030",
"3a3a3a",
"444444",
"4e4e4e",
"585858",
"606060",
"666666",
"767676",
"808080",
"8a8a8a",
"949494",
"9e9e9e",
"a8a8a8",
"b2b2b2",
"bcbcbc",
"c6c6c6",
"d0d0d0",
"dadada",
"e4e4e4",
"eeeeee"
]

View File

@@ -0,0 +1,15 @@
import { NEW_LINE_BREAKER } from '@/constants';
import { FormattedLine } from './types';
type FormatFunc = (line: FormattedLine) => string;
export function concatLogsToString(
logs: FormattedLine[],
formatFunc: FormatFunc = (line) => line.line
) {
return logs.reduce(
(acc, formattedLine) => acc + formatFunc(formattedLine) + NEW_LINE_BREAKER,
''
);
}

View File

@@ -0,0 +1,55 @@
import { without } from 'lodash';
import { FormattedLine, Span, JSONLogs, TIMESTAMP_LENGTH } from './types';
import {
formatCaller,
formatKeyValuePair,
formatLevel,
formatMessage,
formatStackTrace,
formatTime,
} from './formatters';
function removeKnownKeys(keys: string[]) {
return without(keys, 'time', 'level', 'caller', 'message', 'stack_trace');
}
export function formatJSONLine(
rawText: string,
withTimestamps?: boolean
): FormattedLine[] {
const spans: Span[] = [];
const lines: FormattedLine[] = [];
let line = '';
const text = withTimestamps ? rawText.substring(TIMESTAMP_LENGTH) : rawText;
const json: JSONLogs = JSON.parse(text);
const { time, level, caller, message, stack_trace: stackTrace } = json;
const keys = removeKnownKeys(Object.keys(json));
if (withTimestamps) {
const timestamp = rawText.substring(0, TIMESTAMP_LENGTH);
spans.push({ text: timestamp });
line += `${timestamp}`;
}
line += formatTime(time, spans, line);
line += formatLevel(level, spans, line);
line += formatCaller(caller, spans, line);
line += formatMessage(message, spans, line, !!keys.length);
keys.forEach((key, idx) => {
line += formatKeyValuePair(
key,
json[key],
spans,
line,
idx === keys.length - 1
);
});
lines.push({ line, spans });
formatStackTrace(stackTrace, lines);
return lines;
}

View File

@@ -0,0 +1,141 @@
import tokenize from '@nxmix/tokenize-ansi';
import { FontWeight } from 'xterm';
import {
colors,
BACKGROUND_COLORS_BY_ANSI,
FOREGROUND_COLORS_BY_ANSI,
RGBColor,
} from './colors';
import { formatJSONLine } from './formatJSONLogs';
import { formatZerologLogs, ZerologRegex } from './formatZerologLogs';
import { Token, Span, TIMESTAMP_LENGTH, FormattedLine } from './types';
type FormatOptions = {
stripHeaders?: boolean;
withTimestamps?: boolean;
splitter?: string;
};
const defaultOptions: FormatOptions = {
splitter: '\n',
};
export function formatLogs(
rawLogs: string,
{
stripHeaders,
withTimestamps,
splitter = '\n',
}: FormatOptions = defaultOptions
) {
let logs = rawLogs;
if (stripHeaders) {
logs = stripHeadersFunc(logs);
}
if (logs.includes('\\n')) {
logs = JSON.parse(logs);
}
const tokens: Token[][] = tokenize(logs);
const formattedLogs: FormattedLine[] = [];
let fgColor: string | undefined;
let bgColor: string | undefined;
let fontWeight: FontWeight | undefined;
let line = '';
let spans: Span[] = [];
tokens.forEach((token) => {
const [type] = token;
const fgAnsi = FOREGROUND_COLORS_BY_ANSI[type];
const bgAnsi = BACKGROUND_COLORS_BY_ANSI[type];
if (fgAnsi) {
fgColor = cssColorFromRgb(fgAnsi);
} else if (type === 'moreColor') {
fgColor = extendedColorForToken(token);
} else if (type === 'fgDefault') {
fgColor = undefined;
} else if (bgAnsi) {
bgColor = cssColorFromRgb(bgAnsi);
} else if (type === 'bgMoreColor') {
bgColor = extendedColorForToken(token);
} else if (type === 'bgDefault') {
bgColor = undefined;
} else if (type === 'reset') {
fgColor = undefined;
bgColor = undefined;
fontWeight = undefined;
} else if (type === 'bold') {
fontWeight = 'bold';
} else if (type === 'normal') {
fontWeight = 'normal';
} else if (type === 'text') {
const tokenLines = (token[1] as string).split(splitter);
tokenLines.forEach((tokenLine, idx) => {
if (idx && line) {
formattedLogs.push({ line, spans });
line = '';
spans = [];
}
const text = stripEscapeCodes(tokenLine);
if (
(!withTimestamps && text.startsWith('{')) ||
(withTimestamps && text.substring(TIMESTAMP_LENGTH).startsWith('{'))
) {
const lines = formatJSONLine(text, withTimestamps);
formattedLogs.push(...lines);
} else if (ZerologRegex.test(text)) {
const lines = formatZerologLogs(text, withTimestamps);
formattedLogs.push(...lines);
} else {
spans.push({ fgColor, bgColor, text, fontWeight });
line += text;
}
});
}
});
if (line) {
formattedLogs.push({ line, spans });
}
return formattedLogs;
}
function stripHeadersFunc(logs: string) {
return logs.substring(8).replace(/\r?\n(.{8})/g, '\n');
}
function stripEscapeCodes(logs: string) {
return logs.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
''
);
}
function cssColorFromRgb(rgb: RGBColor) {
const [r, g, b] = rgb;
return `rgb(${r}, ${g}, ${b})`;
}
// assuming types based on original JS implementation
// there is not much type definitions for the tokenize library
function extendedColorForToken(token: Token[]) {
const [, colorMode, colorRef] = token as [undefined, number, number];
if (colorMode === 2) {
return cssColorFromRgb(token.slice(2) as RGBColor);
}
if (colorMode === 5 && colors[colorRef]) {
return cssColorFromRgb(colors[colorRef]);
}
return '';
}

View File

@@ -0,0 +1,119 @@
import {
formatCaller,
formatKeyValuePair,
formatLevel,
formatMessage,
formatStackTrace,
formatTime,
} from './formatters';
import {
FormattedLine,
JSONStackTrace,
Level,
Span,
TIMESTAMP_LENGTH,
} from './types';
const dateRegex = /(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}[AP]M) /; // "2022/02/01 04:30AM "
const levelRegex = /(\w{3}) /; // "INF " or "ERR "
const callerRegex = /(.+?.go:\d+) /; // "path/to/file.go:line "
const chevRegex = /> /; // "> "
const messageAndPairsRegex = /(.*)/; // include the rest of the string in a separate group
const keyRegex = /(\S+=)/g; // ""
export const ZerologRegex = concatRegex(
dateRegex,
levelRegex,
callerRegex,
chevRegex,
messageAndPairsRegex
);
function concatRegex(...regs: RegExp[]) {
const flags = Array.from(
new Set(
regs
.map((r) => r.flags)
.join('')
.split('')
)
).join('');
const source = regs.map((r) => r.source).join('');
return new RegExp(source, flags);
}
type Pair = {
key: string;
value: string;
};
export function formatZerologLogs(rawText: string, withTimestamps?: boolean) {
const spans: Span[] = [];
const lines: FormattedLine[] = [];
let line = '';
const text = withTimestamps ? rawText.substring(TIMESTAMP_LENGTH) : rawText;
const [, date, level, caller, messageAndPairs] =
text.match(ZerologRegex) || [];
const [message, pairs] = extractPairs(messageAndPairs);
line += formatTime(date, spans, line);
line += formatLevel(level as Level, spans, line);
line += formatCaller(caller, spans, line);
line += formatMessage(message, spans, line, !!pairs.length);
let stackTrace: JSONStackTrace | undefined;
const stackTraceIndex = pairs.findIndex((p) => p.key === 'stack_trace');
if (stackTraceIndex !== -1) {
stackTrace = JSON.parse(pairs[stackTraceIndex].value);
pairs.splice(stackTraceIndex);
}
pairs.forEach(({ key, value }, idx) => {
line += formatKeyValuePair(
key,
value,
spans,
line,
idx === pairs.length - 1
);
});
lines.push({ line, spans });
formatStackTrace(stackTrace, lines);
return lines;
}
function extractPairs(messageAndPairs: string): [string, Pair[]] {
const pairs: Pair[] = [];
let [message, rawPairs] = messageAndPairs.split('|');
if (!messageAndPairs.includes('|') && !rawPairs) {
rawPairs = message;
message = '';
}
message = message.trim();
rawPairs = rawPairs.trim();
const matches = [...rawPairs.matchAll(keyRegex)];
matches.forEach((m, idx) => {
const rawKey = m[0];
const key = rawKey.slice(0, -1);
const start = m.index || 0;
const end = idx !== matches.length - 1 ? matches[idx + 1].index : undefined;
const value = (
end
? rawPairs.slice(start + rawKey.length, end)
: rawPairs.slice(start + rawKey.length)
).trim();
pairs.push({ key, value });
});
return [message, pairs];
}

View File

@@ -0,0 +1,154 @@
import { format } from 'date-fns';
import { takeRight } from 'lodash';
import { Span, Level, Colors, JSONStackTrace, FormattedLine } from './types';
const spaceSpan: Span = { text: ' ' };
function logLevelToSpan(level: Level): Span {
switch (level) {
case 'debug':
case 'DBG':
return {
fgColor: Colors.Grey,
text: 'DBG',
fontWeight: 'bold',
};
case 'info':
case 'INF':
return {
fgColor: Colors.Green,
text: 'INF',
fontWeight: 'bold',
};
case 'warn':
case 'WRN':
return {
fgColor: Colors.Yellow,
text: 'WRN',
fontWeight: 'bold',
};
case 'error':
case 'ERR':
return {
fgColor: Colors.Red,
text: 'ERR',
fontWeight: 'bold',
};
default:
return { text: level };
}
}
export function formatTime(
time: number | string | undefined,
spans: Span[],
line: string
) {
let nl = line;
if (time) {
let date = '';
if (typeof time === 'number') {
date = format(new Date(time * 1000), 'Y/MM/dd hh:mmaa');
} else {
date = time;
}
spans.push({ fgColor: Colors.Grey, text: date }, spaceSpan);
nl += `${date} `;
}
return nl;
}
export function formatLevel(
level: Level | undefined,
spans: Span[],
line: string
) {
let nl = line;
if (level) {
const levelSpan = logLevelToSpan(level);
spans.push(levelSpan, spaceSpan);
nl += `${levelSpan.text} `;
}
return nl;
}
export function formatCaller(
caller: string | undefined,
spans: Span[],
line: string
) {
let nl = line;
if (caller) {
const trim = takeRight(caller.split('/'), 2).join('/');
spans.push(
{ fgColor: Colors.Magenta, text: trim, fontWeight: 'bold' },
spaceSpan
);
spans.push({ fgColor: Colors.Blue, text: '>' }, spaceSpan);
nl += `${trim} > `;
}
return nl;
}
export function formatMessage(
message: string,
spans: Span[],
line: string,
hasKeys: boolean
) {
let nl = line;
if (message) {
spans.push({ fgColor: Colors.Magenta, text: `${message}` }, spaceSpan);
nl += `${message} `;
if (hasKeys) {
spans.push({ fgColor: Colors.Magenta, text: `|` }, spaceSpan);
nl += '| ';
}
}
return nl;
}
export function formatKeyValuePair(
key: string,
value: unknown,
spans: Span[],
line: string,
isLastKey: boolean
) {
let nl = line;
spans.push(
{ fgColor: Colors.Blue, text: `${key}=` },
{
fgColor: key === 'error' || key === 'ERR' ? Colors.Red : Colors.Magenta,
text: value as string,
}
);
if (!isLastKey) spans.push(spaceSpan);
nl += `${key}=${value}${!isLastKey ? ' ' : ''}`;
return nl;
}
export function formatStackTrace(
stackTrace: JSONStackTrace | undefined,
lines: FormattedLine[]
) {
if (stackTrace) {
stackTrace.forEach(({ func, line: lineNumber, source }) => {
const line = ` at ${func} (${source}:${lineNumber})`;
const spans: Span[] = [
spaceSpan,
spaceSpan,
spaceSpan,
spaceSpan,
{ text: 'at ', fgColor: Colors.Grey },
{ text: func, fgColor: Colors.Red },
{ text: `(${source}:${lineNumber})`, fgColor: Colors.Grey },
];
lines.push({ line, spans });
});
}
}

View File

@@ -0,0 +1,2 @@
export { formatLogs } from './formatLogs';
export { concatLogsToString } from './concatLogsToString';

View File

@@ -0,0 +1,53 @@
import { FontWeight } from 'xterm';
import { type TextColor } from './colors';
export type Token = string | number;
export type Level =
| 'debug'
| 'info'
| 'warn'
| 'error'
| 'DBG'
| 'INF'
| 'WRN'
| 'ERR';
export type JSONStackTrace = {
func: string;
line: string;
source: string;
}[];
export type JSONLogs = {
[k: string]: unknown;
time: number;
level: Level;
caller: string;
message: string;
stack_trace?: JSONStackTrace;
};
export type Span = {
fgColor?: TextColor;
bgColor?: TextColor;
text: string;
fontWeight?: FontWeight;
};
export type FormattedLine = {
spans: Span[];
line: string;
};
export const TIMESTAMP_LENGTH = 31; // 30 for timestamp + 1 for trailing space
export const Colors = {
Grey: 'var(--text-log-viewer-color-json-grey)',
Magenta: 'var(--text-log-viewer-color-json-magenta)',
Yellow: 'var(--text-log-viewer-color-json-yellow)',
Green: 'var(--text-log-viewer-color-json-green)',
Red: 'var(--text-log-viewer-color-json-red)',
Blue: 'var(--text-log-viewer-color-json-blue)',
};

View File

@@ -10,11 +10,12 @@ import {
stopContainer,
} from '@/react/docker/containers/containers.service';
import { ContainerDetailsViewModel, ContainerStatsViewModel, ContainerViewModel } from '../models/container';
import { formatLogs } from '../helpers/logHelper';
angular.module('portainer.docker').factory('ContainerService', ContainerServiceFactory);
/* @ngInject */
function ContainerServiceFactory($q, Container, LogHelper, $timeout, EndpointProvider) {
function ContainerServiceFactory($q, Container, $timeout, EndpointProvider) {
const service = {
killContainer: withEndpointId(killContainer),
pauseContainer: withEndpointId(pauseContainer),
@@ -159,7 +160,7 @@ function ContainerServiceFactory($q, Container, LogHelper, $timeout, EndpointPro
Container.logs(parameters)
.$promise.then(function success(data) {
var logs = LogHelper.formatLogs(data.logs, { stripHeaders, withTimestamps: !!timestamps });
var logs = formatLogs(data.logs, { stripHeaders, withTimestamps: !!timestamps });
deferred.resolve(logs);
})
.catch(function error(err) {

View File

@@ -1,13 +1,10 @@
import { formatLogs } from '../helpers/logHelper';
import { ServiceViewModel } from '../models/service';
angular.module('portainer.docker').factory('ServiceService', [
'$q',
'Service',
'ServiceHelper',
'TaskService',
'ResourceControlService',
'LogHelper',
function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, ResourceControlService, LogHelper) {
function ServiceServiceFactory($q, Service) {
'use strict';
var service = {};
@@ -88,7 +85,7 @@ angular.module('portainer.docker').factory('ServiceService', [
Service.logs(parameters)
.$promise.then(function success(data) {
var logs = LogHelper.formatLogs(data.logs, { stripHeaders: true, withTimestamps: !!timestamps });
var logs = formatLogs(data.logs, { stripHeaders: true, withTimestamps: !!timestamps });
deferred.resolve(logs);
})
.catch(function error(err) {

View File

@@ -1,10 +1,10 @@
import { formatLogs } from '../helpers/logHelper';
import { TaskViewModel } from '../models/task';
angular.module('portainer.docker').factory('TaskService', [
'$q',
'Task',
'LogHelper',
function TaskServiceFactory($q, Task, LogHelper) {
function TaskServiceFactory($q, Task) {
'use strict';
var service = {};
@@ -54,7 +54,7 @@ angular.module('portainer.docker').factory('TaskService', [
Task.logs(parameters)
.$promise.then(function success(data) {
var logs = LogHelper.formatLogs(data.logs, { stripHeaders: true, withTimestamps: !!timestamps });
var logs = formatLogs(data.logs, { stripHeaders: true, withTimestamps: !!timestamps });
deferred.resolve(logs);
})
.catch(function error(err) {

View File

@@ -183,10 +183,12 @@
<div ng-show="state.BuildType === 'url'">
<div class="col-sm-12 form-section-title"> URL </div>
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<span class="col-sm-12 small vertical-center">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
Specify the URL to a Dockerfile, a tarball or a public Git repository (suffixed by <b>.git</b>). When using a Git repository URL, build contexts can be
specified as in the <a href="https://docs.docker.com/engine/reference/commandline/build/#git-repositories">Docker documentation.</a>
<span class="text-muted"
>Specify the URL to a Dockerfile, a tarball or a public Git repository (suffixed by <b>.git</b>). When using a Git repository URL, build contexts can be
specified as in the <a href="https://docs.docker.com/engine/reference/commandline/build/#git-repositories">Docker documentation.</a></span
>
</span>
</div>
<div class="form-group">

View File

@@ -1,5 +1,5 @@
import _ from 'lodash-es';
import { KubernetesServicePort, KubernetesIngressServiceRoute } from 'Kubernetes/models/service/models';
import { KubernetesServicePort } from 'Kubernetes/models/service/models';
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models/constants';
@@ -18,34 +18,17 @@ export default class KubeServicesItemViewController {
port.port = '';
port.targetPort = '';
port.protocol = 'TCP';
if (this.ingressType) {
const route = new KubernetesIngressServiceRoute();
route.ServiceName = this.serviceName;
if (this.serviceType === KubernetesApplicationPublishingTypes.CLUSTER_IP && this.originalIngresses.length > 0) {
if (!route.IngressName) {
route.IngressName = this.originalIngresses[0].Name;
}
if (!route.Host) {
route.Host = this.originalIngresses[0].Hosts[0];
}
}
port.ingress = route;
port.Ingress = true;
}
this.servicePorts.push(port);
this.service.Ports.push(port);
}
removePort(index) {
this.servicePorts.splice(index, 1);
this.service.Ports.splice(index, 1);
}
servicePort(index) {
const targetPort = this.servicePorts[index].targetPort;
this.servicePorts[index].port = targetPort;
const targetPort = this.service.Ports[index].targetPort;
this.service.Ports[index].port = targetPort;
this.onChangeServicePort();
}
isAdmin() {
@@ -54,7 +37,7 @@ export default class KubeServicesItemViewController {
onChangeContainerPort() {
const state = this.state.duplicates.targetPort;
const source = _.map(this.servicePorts, (sp) => sp.targetPort);
const source = _.map(this.service.Ports, (sp) => sp.targetPort);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
@@ -62,22 +45,41 @@ export default class KubeServicesItemViewController {
onChangeServicePort() {
const state = this.state.duplicates.servicePort;
const source = _.map(this.servicePorts, (sp) => sp.port);
const source = _.map(this.service.Ports, (sp) => sp.port);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
this.service.servicePortError = state.hasRefs;
}
onChangeNodePort() {
const state = this.state.duplicates.nodePort;
const source = _.map(this.servicePorts, (sp) => sp.nodePort);
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
// create a list of all the node ports (number[]) in the cluster and current form
const clusterNodePortsWithoutCurrentService = this.nodePortServices
.filter((npService) => npService.Name !== this.service.Name)
.map((npService) => npService.Ports)
.flat()
.map((npServicePorts) => npServicePorts.NodePort);
const formNodePortsWithoutCurrentService = this.formServices
.filter((formService) => formService.Type === KubernetesApplicationPublishingTypes.NODE_PORT && formService.Name !== this.service.Name)
.map((formService) => formService.Ports)
.flat()
.map((formServicePorts) => formServicePorts.nodePort);
const serviceNodePorts = this.service.Ports.map((sp) => sp.nodePort);
// getDuplicates cares about the index, so put the serviceNodePorts at the start
const allNodePortsWithoutCurrentService = [...clusterNodePortsWithoutCurrentService, ...formNodePortsWithoutCurrentService];
const duplicates = KubernetesFormValidationHelper.getDuplicateNodePorts(serviceNodePorts, allNodePortsWithoutCurrentService);
state.refs = duplicates;
state.hasRefs = Object.keys(duplicates).length > 0;
this.service.nodePortError = state.hasRefs;
}
$onInit() {
if (this.servicePorts.length === 0) {
if (this.service.Ports.length === 0) {
this.addPort();
}

View File

@@ -1,11 +1,11 @@
<ng-form name="serviceForm">
<div ng-if="$ctrl.isAdmin()" class="small" ng-show="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<div ng-if="$ctrl.isAdmin()" class="small" ng-show="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<p class="text-warning pt-2 vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> No Load balancer is available in this cluster, click
<a class="hyperlink" ui-sref="kubernetes.cluster.setup">here</a> to configure load balancer.
</p>
</div>
<div ng-if="!$ctrl.isAdmin()" class="small" ng-show="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<div ng-if="!$ctrl.isAdmin()" class="small" ng-show="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled">
<p class="text-warning pt-2 vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> No Load balancer is available in this cluster, contact your administrator.
</p>
@@ -13,9 +13,9 @@
<div
ng-if="
($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && $ctrl.loadbalancerEnabled) ||
$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP ||
$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT
($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && $ctrl.loadbalancerEnabled) ||
$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP ||
$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT
"
>
<div ng-show="!$ctrl.multiItemDisable" class="mt-5 mb-5 vertical-center">
@@ -24,7 +24,7 @@
<pr-icon icon="'plus'" mode="'alt'" size="'sm'" feather="true"></pr-icon> publish a new port
</span>
</div>
<div ng-repeat="servicePort in $ctrl.servicePorts" class="mt-5 service-form row">
<div ng-repeat="servicePort in $ctrl.service.Ports" class="mt-5 service-form row">
<div class="form-group !mx-0 !pl-0 col-sm-3">
<div class="input-group input-group-sm">
<span class="input-group-addon required">Container port</span>
@@ -36,9 +36,11 @@
placeholder="80"
ng-min="1"
ng-max="65535"
min="1"
max="65535"
ng-change="$ctrl.servicePort($index)"
required
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-change="$ctrl.onChangeContainerPort()"
data-cy="k8sAppCreate-containerPort_{{ $index }}"
/>
@@ -70,8 +72,10 @@
placeholder="80"
ng-min="1"
ng-max="65535"
min="1"
max="65535"
required
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-disabled="$ctrl.originalIngresses.length === 0 || ($ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled)"
ng-change="$ctrl.onChangeServicePort()"
data-cy="k8sAppCreate-servicePort_{{ $index }}"
/>
@@ -94,7 +98,7 @@
</span>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT">
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.NODE_PORT">
<div class="input-group input-group-sm">
<span class="input-group-addon required">Nodeport</span>
<input
@@ -105,6 +109,8 @@
placeholder="30080"
ng-min="30000"
ng-max="32767"
min="30000"
max="32767"
required
ng-change="$ctrl.onChangeNodePort()"
data-cy="k8sAppCreate-nodeportPort_{{ $index }}"
@@ -123,12 +129,15 @@
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Nodeport number must be inside the range 30000-32767 or blank for system
allocated.</p
>
<div class="mt-1 text-warning" ng-if="$ctrl.state.duplicates.nodePort.refs[$index] !== undefined">
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This node port is already used.
</div>
</div>
</div>
</span>
</div>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER">
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER">
<div class="input-group input-group-sm">
<span class="input-group-addon">Loadbalancer port</span>
<input
@@ -139,89 +148,16 @@
placeholder="80"
ng-min="1"
ng-max="65535"
min="1"
max="65535"
required
ng-disabled="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled"
ng-disabled="$ctrl.service.Type === $ctrl.KubernetesApplicationPublishingTypes.LOAD_BALANCER && !$ctrl.loadbalancerEnabled"
data-cy="k8sAppCreate-loadbalancerPort_{{ $index }}"
/>
</div>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType">
<div class="input-group input-group-sm">
<span class="input-group-addon">Ingress</span>
<select
class="form-control"
name="ingress_port_{{ $index }}"
ng-model="servicePort.ingress.IngressName"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-options="ingress.Name as ingress.Name for ingress in $ctrl.originalIngresses"
data-cy="k8sAppCreate-ingressPort_{{ $index }}"
>
<option selected disabled hidden value="">Select an ingress</option>
</select>
</div>
<span>
<div class="small mt-5 text-warning">
<div ng-messages="serviceForm['ingress_port_'+$index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Ingress selection is required.</p>
</div>
</div>
</span>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType">
<div class="input-group input-group-sm">
<span class="input-group-addon">Hostname</span>
<select
class="form-control"
name="hostname_port_{{ $index }}"
ng-model="servicePort.ingress.Host"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-options="host as host for host in ($ctrl.originalIngresses | filter:{ Name: servicePort.ingress.IngressName })[0].Hosts"
data-cy="k8sAppCreate-hostnamePort_{{ $index }}"
>
<option selected disabled hidden value="">Select a hostname</option>
</select>
</div>
<span>
<div class="small mt-1 text-warning">
<div ng-messages="serviceForm['hostname_port_'+$index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Hostname is required.</p>
</div>
</div>
</span>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-3 clear-both" ng-if="$ctrl.serviceType === $ctrl.KubernetesApplicationPublishingTypes.CLUSTER_IP && $ctrl.ingressType">
<div class="input-group input-group-sm">
<span class="input-group-addon required">Route</span>
<input
class="form-control"
name="ingress_route_{{ $index }}"
ng-model="servicePort.ingress.Path"
placeholder="route"
required
ng-disabled="$ctrl.originalIngresses.length === 0"
ng-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
data-cy="k8sAppCreate-route_{{ $index }}"
/>
</div>
<span>
<div class="small mt-1 text-warning">
<div ng-messages="serviceForm['ingress_route_'+$index].$error">
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Route is required.</p>
<p class="vertical-center" ng-message="pattern"
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This field must consist of alphanumeric characters or the special characters: '-', '_'
or '/'. It must start and end with an alphanumeric character (e.g. 'my-route', or 'route-123').</p
>
</div>
</div>
</span>
</div>
<div class="form-group !mx-0 !pl-0 col-sm-2">
<div class="form-group !mx-0 !pl-0 col-sm-3">
<div class="input-group input-group-sm">
<div class="btn-group btn-group-sm">
<label
@@ -244,7 +180,7 @@
>
</div>
<button
ng-disabled="$ctrl.servicePorts.length === 1"
ng-disabled="$ctrl.service.Ports.length === 1"
ng-show="!$ctrl.multiItemDisable"
class="btn btn-sm btn-dangerlight btn-only-icon"
type="button"

View File

@@ -5,15 +5,10 @@ angular.module('portainer.kubernetes').component('kubeServicesItemView', {
templateUrl: './kube-services-item.html',
controller,
bindings: {
serviceType: '<',
servicePorts: '=',
serviceRoutes: '=',
ingressType: '<',
originalIngresses: '<',
nodePortServices: '<',
formServices: '<',
service: '=',
isEdit: '<',
serviceName: '<',
multiItemDisable: '<',
serviceIndex: '<',
loadbalancerEnabled: '<',
},
});

View File

@@ -1,5 +1,7 @@
import { KubernetesService, KubernetesServicePort, KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import { KubernetesApplicationPublishingTypes } from 'Kubernetes/models/application/models/constants';
import { getServices } from 'Kubernetes/react/views/networks/services/service';
import { notifyError } from '@/portainer/services/notifications';
export default class KubeServicesViewController {
/* @ngInject */
@@ -7,6 +9,7 @@ export default class KubeServicesViewController {
this.$async = $async;
this.EndpointProvider = EndpointProvider;
this.Authentication = Authentication;
this.asyncOnInit = this.asyncOnInit.bind(this);
}
addEntry(service) {
@@ -74,6 +77,21 @@ export default class KubeServicesViewController {
return 'fa fa-project-diagram';
}
}
async asyncOnInit() {
try {
// get all nodeport services in the cluster, to validate unique nodeports in the form
const allSettledServices = await Promise.allSettled(this.namespaces.map((namespace) => getServices(this.state.endpointId, namespace)));
const allServices = allSettledServices
.filter((settledService) => settledService.status === 'fulfilled' && settledService.value)
.map((fulfilledService) => fulfilledService.value)
.flat();
this.nodePortServices = allServices.filter((service) => service.Type === 'NodePort');
} catch (error) {
notifyError('Failure', error, 'Failed getting services');
}
}
$onInit() {
this.state = {
serviceType: [
@@ -93,5 +111,6 @@ export default class KubeServicesViewController {
selected: KubernetesApplicationPublishingTypes.CLUSTER_IP,
endpointId: this.EndpointProvider.endpointID(),
};
return this.$async(this.asyncOnInit);
}
}

View File

@@ -9,7 +9,7 @@
</div>
<div class="form-group">
<div class="col-sm-12 form-inline">
<div class="col-sm-5" style="padding-left: 0px">
<div class="col-sm-6" style="padding-left: 0px">
<select
class="form-control"
ng-model="$ctrl.state.selected"
@@ -36,7 +36,7 @@
<div class="form-group">
<div class="col-sm-12 form-inline" style="margin-top: 20px" ng-repeat="service in $ctrl.formValues.Services">
<div ng-if="!$ctrl.formValues.Services[$index].Ingress">
<div>
<div class="text-muted vertical-center">
<pr-icon ng-if="$ctrl.serviceType(service.Type) === 'ClusterIP'" icon="'list'" feather="true"></pr-icon>
<pr-icon ng-if="$ctrl.serviceType(service.Type) === 'LoadBalancer'" icon="'svg-dataflow'"></pr-icon>
@@ -44,10 +44,9 @@
{{ $ctrl.serviceType(service.Type) }}
</div>
<kube-services-item-view
service-routes="$ctrl.formValues.Services[$index].IngressRoute"
ingress-type="$ctrl.formValues.Services[$index].Ingress"
service-type="$ctrl.formValues.Services[$index].Type"
service-ports="$ctrl.formValues.Services[$index].Ports"
node-port-services="$ctrl.nodePortServices"
form-services="$ctrl.formValues.Services"
service="$ctrl.formValues.Services[$index]"
is-edit="$ctrl.isEdit"
loadbalancer-enabled="$ctrl.loadbalancerEnabled"
></kube-services-item-view>

View File

@@ -7,6 +7,7 @@ angular.module('portainer.kubernetes').component('kubeServicesView', {
bindings: {
formValues: '=',
isEdit: '<',
namespaces: '<',
loadbalancerEnabled: '<',
},
});

View File

@@ -27,10 +27,7 @@
</button>
<button
ng-if="
!(
($ctrl.isDockerConfig || $ctrl.formValues.Type.name === $ctrl.KubernetesSecretTypes.TLS.name || $ctrl.formValues.Type === $ctrl.KubernetesSecretTypes.TLS.value) &&
$ctrl.formValues.Kind === $ctrl.KubernetesConfigurationKinds.SECRET
)
!(($ctrl.isDockerConfig || $ctrl.formValues.Type === $ctrl.KubernetesSecretTypeOptions.TLS.value) && $ctrl.formValues.Kind === $ctrl.KubernetesConfigurationKinds.SECRET)
"
type="button"
class="btn btn-sm btn-default ml-0"
@@ -50,10 +47,7 @@
<pr-icon icon="'upload'" feather="true" class="vertical-center"></pr-icon> Upload docker config file
</button>
<button
ng-if="
($ctrl.formValues.Type.name === $ctrl.KubernetesSecretTypes.TLS.name || $ctrl.formValues.Type === $ctrl.KubernetesSecretTypes.TLS.value) &&
$ctrl.formValues.Kind === $ctrl.KubernetesConfigurationKinds.SECRET
"
ng-if="$ctrl.formValues.Type === $ctrl.KubernetesSecretTypeOptions.TLS.value && $ctrl.formValues.Kind === $ctrl.KubernetesConfigurationKinds.SECRET"
type="button"
class="btn btn-sm btn-default ml-0"
ngf-select="$ctrl.addEntryFromFile($file)"

View File

@@ -6,7 +6,7 @@ import { Base64 } from 'js-base64';
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesConfigurationKinds, KubernetesSecretTypes } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationKinds, KubernetesSecretTypeOptions } from 'Kubernetes/models/configuration/models';
class KubernetesConfigurationDataController {
/* @ngInject */
@@ -20,7 +20,7 @@ class KubernetesConfigurationDataController {
this.showSimpleMode = this.showSimpleMode.bind(this);
this.showAdvancedMode = this.showAdvancedMode.bind(this);
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
this.KubernetesSecretTypes = KubernetesSecretTypes;
this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions;
}
onChangeKey(entry) {
@@ -41,27 +41,26 @@ class KubernetesConfigurationDataController {
// logic for setting required keys for new entries, based on the secret type
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
const newDataIndex = this.formValues.Data.length - 1;
const typeValue = typeof this.formValues.Type === 'string' ? this.formValues.Type : this.formValues.Type.value;
switch (typeValue) {
case this.KubernetesSecretTypes.DOCKERCFG.value:
switch (this.formValues.Type) {
case this.KubernetesSecretTypeOptions.DOCKERCFG.value:
this.addMissingKeys(['dockercfg'], newDataIndex);
break;
case this.KubernetesSecretTypes.DOCKERCONFIGJSON.value:
case this.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value:
this.addMissingKeys(['.dockerconfigjson'], newDataIndex);
break;
case this.KubernetesSecretTypes.BASICAUTH.value:
case this.KubernetesSecretTypeOptions.BASICAUTH.value:
// only add a required key if there is no required key out of username and password
if (!this.formValues.Data.some((entry) => entry.Key === 'username' || entry.Key === 'password')) {
this.addMissingKeys(['username', 'password'], newDataIndex);
}
break;
case this.KubernetesSecretTypes.SSHAUTH.value:
case this.KubernetesSecretTypeOptions.SSHAUTH.value:
this.addMissingKeys(['ssh-privatekey'], newDataIndex);
break;
case this.KubernetesSecretTypes.TLS.value:
case this.KubernetesSecretTypeOptions.TLS.value:
this.addMissingKeys(['tls.crt', 'tls.key'], newDataIndex);
break;
case this.KubernetesSecretTypes.BOOTSTRAPTOKEN.value:
case this.KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value:
this.addMissingKeys(['token-id', 'token-secret'], newDataIndex);
break;
default:
@@ -84,29 +83,28 @@ class KubernetesConfigurationDataController {
isRequiredKey(key) {
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
const secretTypeValue = typeof this.formValues.Type === 'string' ? this.formValues.Type : this.formValues.Type.value;
switch (secretTypeValue) {
case this.KubernetesSecretTypes.DOCKERCONFIGJSON.value:
switch (this.formValues.Type) {
case this.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value:
if (key === '.dockerconfigjson') {
return true;
}
break;
case this.KubernetesSecretTypes.DOCKERCFG.value:
case this.KubernetesSecretTypeOptions.DOCKERCFG.value:
if (key === '.dockercfg') {
return true;
}
break;
case this.KubernetesSecretTypes.SSHAUTH.value:
case this.KubernetesSecretTypeOptions.SSHAUTH.value:
if (key === 'ssh-privatekey') {
return true;
}
break;
case this.KubernetesSecretTypes.TLS.value:
case this.KubernetesSecretTypeOptions.TLS.value:
if (key === 'tls.crt' || key === 'tls.key') {
return true;
}
break;
case this.KubernetesSecretTypes.BOOTSTRAPTOKEN.value:
case this.KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value:
if (key === 'token-id' || key === 'token-secret') {
return true;
}
@@ -168,14 +166,14 @@ class KubernetesConfigurationDataController {
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
if (this.isDockerConfig) {
if (this.formValues.Type.name === this.KubernetesSecretTypes.DOCKERCFG.name) {
if (this.formValues.Type === this.KubernetesSecretTypeOptions.DOCKERCFG.value) {
entry.Key = '.dockercfg';
} else {
entry.Key = '.dockerconfigjson';
}
}
if (this.formValues.Type.name === this.KubernetesSecretTypes.TLS.name) {
if (this.formValues.Type === this.KubernetesSecretTypeOptions.TLS.value) {
const isCrt = entry.Value.indexOf('BEGIN CERTIFICATE') !== -1;
if (isCrt) {
entry.Key = 'tls.crt';
@@ -200,9 +198,8 @@ class KubernetesConfigurationDataController {
isEntryRequired() {
if (this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET) {
const typeValue = typeof this.formValues.Type === 'string' ? this.formValues.Type : this.formValues.Type.value;
if (this.formValues.Data.length === 1) {
if (typeValue !== this.KubernetesSecretTypes.SERVICEACCOUNTTOKEN.value) {
if (this.formValues.Type !== this.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) {
return true;
}
}

View File

@@ -17,6 +17,9 @@ class KubernetesConfigurationConverter {
res.ConfigurationOwner = secret.ConfigurationOwner;
res.IsRegistrySecret = secret.IsRegistrySecret;
res.SecretType = secret.SecretType;
if (secret.Annotations) {
res.ServiceAccountName = secret.Annotations['kubernetes.io/service-account.name'];
}
return res;
}

View File

@@ -4,13 +4,13 @@ import { KubernetesApplicationSecret } from 'Kubernetes/models/secret/models';
import { KubernetesPortainerConfigurationDataAnnotation } from 'Kubernetes/models/configuration/models';
import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesSecretTypes } from 'Kubernetes/models/configuration/models';
import { KubernetesSecretTypeOptions } from 'Kubernetes/models/configuration/models';
class KubernetesSecretConverter {
static createPayload(secret) {
const res = new KubernetesSecretCreatePayload();
res.metadata.name = secret.Name;
res.metadata.namespace = secret.Namespace;
res.type = secret.Type.value;
res.type = secret.Type;
const configurationOwner = _.truncate(secret.ConfigurationOwner, { length: 63, omission: '' });
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner;
@@ -53,6 +53,11 @@ class KubernetesSecretConverter {
if (annotation !== '') {
res.metadata.annotations[KubernetesPortainerConfigurationDataAnnotation] = annotation;
}
_.forEach(secret.Annotations, (entry) => {
res.metadata.annotations[entry.name] = entry.value;
});
return res;
}
@@ -64,6 +69,7 @@ class KubernetesSecretConverter {
res.Type = payload.type;
res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.CreationDate = payload.metadata.creationTimestamp;
res.Annotations = payload.metadata.annotations;
res.IsRegistrySecret = payload.metadata.annotations && !!payload.metadata.annotations['portainer.io/registry.id'];
@@ -96,14 +102,11 @@ class KubernetesSecretConverter {
res.ConfigurationOwner = formValues.ConfigurationOwner;
res.Data = formValues.Data;
switch (formValues.Type) {
case KubernetesSecretTypes.CUSTOM:
res.Type.value = formValues.customType;
break;
case KubernetesSecretTypes.SERVICEACCOUNTTOKEN:
res.Annotations = [{ name: 'kubernetes.io/service-account.name', value: formValues.ServiceAccountName }];
break;
if (formValues.Type === KubernetesSecretTypeOptions.CUSTOM.value) {
res.Type = formValues.customType;
}
if (formValues.Type === KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) {
res.Annotations = [{ name: 'kubernetes.io/service-account.name', value: formValues.ServiceAccountName }];
}
return res;
}

View File

@@ -94,12 +94,7 @@ class KubeCreateCustomTemplateViewController {
this.state.actionInProgress = true;
try {
const formValues = { ...this.formValues, Variables: null };
if (this.formValues.Variables.length > 0) {
formValues.Variables = JSON.stringify(this.formValues.Variables);
}
const customTemplate = await this.createCustomTemplateByMethod(method, formValues);
const customTemplate = await this.createCustomTemplateByMethod(method, this.formValues);
const accessControlData = this.formValues.AccessControlData;
const userDetails = this.Authentication.getUserDetails();

View File

@@ -308,13 +308,17 @@ class KubernetesApplicationHelper {
svcport.targetPort = port.targetPort;
app.Ingresses.value.forEach((ingress) => {
const ingressMatched = _.find(ingress.Paths, { ServiceName: service.metadata.name });
if (ingressMatched) {
const ingressNameMatched = ingress.Paths.find((ingPath) => ingPath.ServiceName === service.metadata.name);
const ingressPortMatched = ingress.Paths.find((ingPath) => ingPath.Port === port.port);
// only add ingress info to the port if the ingress serviceport matches the port in the service
if (ingressPortMatched) {
svcport.ingress = {
IngressName: ingressMatched.IngressName,
Host: ingressMatched.Host,
Path: ingressMatched.Path,
IngressName: ingressPortMatched.IngressName,
Host: ingressPortMatched.Host,
Path: ingressPortMatched.Path,
};
}
if (ingressNameMatched) {
svc.Ingress = true;
}
});

View File

@@ -13,14 +13,24 @@ class KubernetesFormValidationHelper {
}
static getDuplicates(names) {
const groupped = _.groupBy(names);
const grouped = _.groupBy(names);
const res = {};
_.forEach(names, (name, index) => {
if (name && groupped[name].length > 1) {
if (name && grouped[name].length > 1) {
res[index] = name;
}
});
return res;
}
static getDuplicateNodePorts(serviceNodePorts, allOtherNodePorts) {
const res = {};
serviceNodePorts.forEach((sNodePort, index) => {
if (allOtherNodePorts.includes(sNodePort) || serviceNodePorts.filter((snp) => snp === sNodePort).length > 1) {
res[index] = sNodePort;
}
});
return res;
}
}
export default KubernetesFormValidationHelper;

View File

@@ -1,4 +1,4 @@
import { KubernetesConfigurationKinds, KubernetesSecretTypes } from './models';
import { KubernetesConfigurationKinds, KubernetesSecretTypeOptions } from './models';
/**
* KubernetesConfigurationFormValues Model
@@ -13,7 +13,7 @@ const _KubernetesConfigurationFormValues = Object.freeze({
DataYaml: '',
IsSimple: true,
ServiceAccountName: '',
Type: KubernetesSecretTypes.OPAQUE,
Type: KubernetesSecretTypeOptions.OPAQUE.value,
});
export class KubernetesConfigurationFormValues {

View File

@@ -28,7 +28,7 @@ export const KubernetesConfigurationKinds = Object.freeze({
SECRET: 2,
});
export const KubernetesSecretTypes = Object.freeze({
export const KubernetesSecretTypeOptions = Object.freeze({
OPAQUE: { name: 'Opaque', value: 'Opaque' },
SERVICEACCOUNTTOKEN: { name: 'Service account token', value: 'kubernetes.io/service-account-token' },
DOCKERCFG: { name: 'Dockercfg', value: 'kubernetes.io/dockercfg' },

View File

@@ -23,6 +23,8 @@ const _KubernetesService = Object.freeze({
Note: '',
Ingress: false,
Selector: {},
nodePortError: false,
servicePortError: false,
});
export class KubernetesService {

View File

@@ -67,7 +67,7 @@ class KubernetesPodService {
params.container = containerName;
}
const data = await this.KubernetesPods(namespace).logs(params).$promise;
return data.logs.length === 0 ? [] : data.logs.split('\n');
return data.logs;
} catch (err) {
throw new PortainerError('Unable to retrieve pod logs', err);
}

View File

@@ -12,9 +12,11 @@ export const componentsModule = angular
.component(
'ingressClassDatatable',
r2a(IngressClassDatatable, [
'onChangeAvailability',
'onChangeControllers',
'description',
'ingressControllers',
'allowNoneIngressClass',
'isLoading',
'noIngressControllerLabel',
'view',
])

View File

@@ -14,7 +14,7 @@ import { PageHeader } from '@@/PageHeader';
import { Option } from '@@/form-components/Input/Select';
import { Button } from '@@/buttons';
import { Ingress } from '../types';
import { Ingress, IngressController } from '../types';
import {
useCreateIngress,
useIngresses,
@@ -66,7 +66,8 @@ export function CreateIngressView() {
(servicesResults.isLoading &&
configResults.isLoading &&
namespacesResults.isLoading &&
ingressesResults.isLoading) ||
ingressesResults.isLoading &&
ingressControllersResults.isLoading) ||
(isEdit && !ingressRule.IngressName);
const [ingressNames, ingresses, ruleCounterByNamespace, hostWithTLS] =
@@ -137,22 +138,28 @@ export function CreateIngressView() {
{ label: 'Select a service', value: '' },
...(servicesOptions || []),
];
const servicePorts = clusterIpServices
? Object.fromEntries(
clusterIpServices?.map((service) => [
service.Name,
service.Ports.map((port) => ({
label: String(port.Port),
value: String(port.Port),
})),
])
)
: {};
const servicePorts = useMemo(
() =>
clusterIpServices
? Object.fromEntries(
clusterIpServices?.map((service) => [
service.Name,
service.Ports.map((port) => ({
label: String(port.Port),
value: String(port.Port),
})),
])
)
: {},
[clusterIpServices]
);
const existingIngressClass = useMemo(
() =>
ingressControllersResults.data?.find(
(i) => i.ClassName === ingressRule.IngressClassName
(i) =>
i.ClassName === ingressRule.IngressClassName ||
(i.Type === 'custom' && ingressRule.IngressClassName === '')
),
[ingressControllersResults.data, ingressRule.IngressClassName]
);
@@ -166,11 +173,17 @@ export function CreateIngressView() {
})) || []),
];
if (!existingIngressClass && ingressRule.IngressClassName) {
if (
(!existingIngressClass ||
(existingIngressClass && !existingIngressClass.Availability)) &&
ingressRule.IngressClassName &&
!ingressControllersResults.isLoading
) {
const optionLabel = !ingressRule.IngressType
? `${ingressRule.IngressClassName} - NOT FOUND`
: `${ingressRule.IngressClassName} - DISALLOWED`;
ingressClassOptions.push({
label: !ingressRule.IngressType
? `${ingressRule.IngressClassName} - NOT FOUND`
: `${ingressRule.IngressClassName} - DISALLOWED`,
label: optionLabel,
value: ingressRule.IngressClassName,
});
}
@@ -180,29 +193,41 @@ export function CreateIngressView() {
config.SecretType === 'kubernetes.io/tls' &&
config.Namespace === namespace
);
const tlsOptions: Option<string>[] = [
{ label: 'No TLS', value: '' },
...(matchedConfigs?.map((config) => ({
label: config.Name,
value: config.Name,
})) || []),
];
const tlsOptions: Option<string>[] = useMemo(
() => [
{ label: 'No TLS', value: '' },
...(matchedConfigs?.map((config) => ({
label: config.Name,
value: config.Name,
})) || []),
],
[matchedConfigs]
);
useEffect(() => {
if (!!params.name && ingressesResults.data && !ingressRule.IngressName) {
if (
!!params.name &&
ingressesResults.data &&
!ingressRule.IngressName &&
!ingressControllersResults.isLoading &&
!ingressControllersResults.isLoading
) {
// if it is an edit screen, prepare the rule from the ingress
const ing = ingressesResults.data?.find(
(ing) => ing.Name === params.name && ing.Namespace === params.namespace
);
if (ing) {
const type = ingressControllersResults.data?.find(
(c) => c.ClassName === ing.ClassName
(c) =>
c.ClassName === ing.ClassName ||
(c.Type === 'custom' && !ing.ClassName)
)?.Type;
const r = prepareRuleFromIngress(ing);
r.IngressType = type;
const r = prepareRuleFromIngress(ing, type);
r.IngressType = type || r.IngressType;
setIngressRule(r);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
params.name,
ingressesResults.data,
@@ -211,15 +236,55 @@ export function CreateIngressView() {
params.namespace,
]);
useEffect(() => {
// for each host, if the tls selection doesn't exist as an option, change it to the first option
if (ingressRule?.Hosts?.length) {
ingressRule.Hosts.forEach((host, hIndex) => {
const secret = host.Secret || '';
const tlsOptionVals = tlsOptions.map((o) => o.value);
if (tlsOptions?.length && !tlsOptionVals?.includes(secret)) {
handleTLSChange(hIndex, tlsOptionVals[0]);
}
});
}
}, [tlsOptions, ingressRule.Hosts]);
useEffect(() => {
// for each path in each host, if the service port doesn't exist as an option, change it to the first option
if (ingressRule?.Hosts?.length) {
ingressRule.Hosts.forEach((host, hIndex) => {
host?.Paths?.forEach((path, pIndex) => {
const serviceName = path.ServiceName;
const currentServicePorts = servicePorts[serviceName]?.map(
(p) => p.value
);
if (
currentServicePorts?.length &&
!currentServicePorts?.includes(String(path.ServicePort))
) {
handlePathChange(
hIndex,
pIndex,
'ServicePort',
currentServicePorts[0]
);
}
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ingressRule, servicePorts]);
useEffect(() => {
if (namespace.length > 0) {
validate(
ingressRule,
ingressNames || [],
servicesOptions || [],
!!existingIngressClass
existingIngressClass
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
ingressRule,
namespace,
@@ -289,7 +354,7 @@ export function CreateIngressView() {
ingressRule: Rule,
ingressNames: string[],
serviceOptions: Option<string>[],
existingIngressClass: boolean
existingIngressClass?: IngressController
) {
const errors: Record<string, ReactNode> = {};
const rule = { ...ingressRule };
@@ -320,7 +385,12 @@ export function CreateIngressView() {
'No ingress class is currently set for this ingress - use of the Portainer UI requires one to be set.';
}
if (isEdit && !existingIngressClass && ingressRule.IngressClassName) {
if (
isEdit &&
(!existingIngressClass ||
(existingIngressClass && !existingIngressClass.Availability)) &&
ingressRule.IngressClassName
) {
if (!rule.IngressType) {
errors.className =
'Currently set to an ingress class that cannot be found in the cluster - you must select a valid class.';
@@ -572,7 +642,7 @@ export function CreateIngressView() {
setIngressRule(rule);
}
function addNewAnnotation(type?: 'rewrite' | 'regex') {
function addNewAnnotation(type?: 'rewrite' | 'regex' | 'ingressClass') {
const rule = { ...ingressRule };
const annotation: Annotation = {
@@ -580,13 +650,21 @@ export function CreateIngressView() {
Value: '',
ID: uuidv4(),
};
if (type === 'rewrite') {
annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target';
annotation.Value = '/$1';
}
if (type === 'regex') {
annotation.Key = 'nginx.ingress.kubernetes.io/use-regex';
annotation.Value = 'true';
switch (type) {
case 'rewrite':
annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target';
annotation.Value = '/$1';
break;
case 'regex':
annotation.Key = 'nginx.ingress.kubernetes.io/use-regex';
annotation.Value = 'true';
break;
case 'ingressClass':
annotation.Key = 'kubernetes.io/ingress.class';
annotation.Value = '';
break;
default:
break;
}
rule.Annotations = rule.Annotations || [];
rule.Annotations?.push(annotation);
@@ -626,10 +704,13 @@ export function CreateIngressView() {
function handleCreateIngressRules() {
const rule = { ...ingressRule };
const classNameToSend =
rule.IngressClassName === 'none' ? '' : rule.IngressClassName;
const ingress: Ingress = {
Namespace: namespace,
Name: rule.IngressName,
ClassName: rule.IngressClassName,
ClassName: classNameToSend,
Hosts: rule.Hosts.map((host) => host.Host),
Paths: preparePaths(rule.IngressName, rule.Hosts),
TLS: prepareTLS(rule.Hosts),

View File

@@ -47,7 +47,7 @@ interface Props {
addNewIngressHost: (noHost?: boolean) => void;
addNewIngressRoute: (hostIndex: number) => void;
addNewAnnotation: (type?: 'rewrite' | 'regex') => void;
addNewAnnotation: (type?: 'rewrite' | 'regex' | 'ingressClass') => void;
handleNamespaceChange: (val: string) => void;
handleHostChange: (hostIndex: number, val: string) => void;
@@ -102,8 +102,9 @@ export function IngressForm({
}
const hasNoHostRule = rule.Hosts?.some((host) => host.NoHost);
const placeholderAnnotation =
PlaceholderAnnotations[rule.IngressType || 'other'];
const pathTypes = PathTypes[rule.IngressType || 'other'];
PlaceholderAnnotations[rule.IngressType || 'other'] ||
PlaceholderAnnotations.other;
const pathTypes = PathTypes[rule.IngressType || 'other'] || PathTypes.other;
return (
<Widget>
@@ -169,7 +170,7 @@ export function IngressForm({
</div>
</div>
<div className="form-group" key={rule.IngressClassName}>
<div className="form-group" key={ingressClassOptions.toString()}>
<label
className="control-label text-muted col-sm-3 col-lg-2 required"
htmlFor="ingress_class"
@@ -248,9 +249,10 @@ export function IngressForm({
onClick={() => addNewAnnotation('rewrite')}
icon={Plus}
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
data-cy="add-rewrite-annotation"
>
{' '}
add rewrite annotation
Add rewrite annotation
</Button>
<Button
@@ -258,11 +260,23 @@ export function IngressForm({
onClick={() => addNewAnnotation('regex')}
icon={Plus}
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
data-cy="add-regex-annotation"
>
add regular expression annotation
Add regular expression annotation
</Button>
</>
)}
{rule.IngressType === 'custom' && (
<Button
className="btn btn-sm btn-light mb-2 ml-2"
onClick={() => addNewAnnotation('ingressClass')}
icon={Plus}
data-cy="add-ingress-class-annotation"
>
Add kubernetes.io/ingress.class annotation
</Button>
)}
</div>
<div className="col-sm-12 px-0 text-muted">Rules</div>
@@ -289,7 +303,8 @@ export function IngressForm({
)}
<Button
className="btn btn-sm btn-dangerlight ml-2"
className="btn btn-sm ml-2"
color="dangerlight"
type="button"
data-cy={`k8sAppCreate-rmHostButton_${hostIndex}`}
onClick={() => removeIngressHost(hostIndex)}
@@ -533,7 +548,8 @@ export function IngressForm({
<div className="form-group !pl-0 col-sm-1 !m-0">
<Button
className="btn btn-sm btn-dangerlight btn-only-icon !ml-0 vertical-center"
className="btn btn-sm btn-only-icon !ml-0 vertical-center"
color="dangerlight"
type="button"
data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`}
onClick={() => removeIngressRoute(hostIndex, pathIndex)}

View File

@@ -1,6 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
import { Annotation } from '@/kubernetes/react/views/networks/ingresses/components/annotations/types';
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
import { TLS, Ingress } from '../types';
@@ -62,7 +63,7 @@ export function prepareRuleHostsFromIngress(ing: Ingress) {
h.Host = host;
h.Secret = getSecretByHost(host, ing.TLS);
h.Paths = [];
ing.Paths.forEach((path) => {
ing.Paths?.forEach((path) => {
if (path.Host === host) {
h.Paths.push({
Route: path.Path,
@@ -99,12 +100,15 @@ export function getAnnotationsForEdit(
return result;
}
export function prepareRuleFromIngress(ing: Ingress): Rule {
export function prepareRuleFromIngress(
ing: Ingress,
type?: SupportedIngControllerTypes
): Rule {
return {
Key: uuidv4(),
IngressName: ing.Name,
Namespace: ing.Namespace,
IngressClassName: ing.ClassName,
IngressClassName: type === 'custom' ? 'none' : ing.ClassName,
Hosts: prepareRuleHostsFromIngress(ing) || [],
Annotations: ing.Annotations ? getAnnotationsForEdit(ing.Annotations) : [],
IngressType: ing.Type,

View File

@@ -3,7 +3,7 @@ import { useRouter } from '@uirouter/react';
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { Authorized } from '@/portainer/hooks/useUser';
import { useAuthorizations, Authorized } from '@/portainer/hooks/useUser';
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
import { Datatable } from '@@/datatables';
@@ -29,7 +29,10 @@ export function IngressDataTable() {
const environmentId = useEnvironmentId();
const nsResult = useNamespaces(environmentId);
const result = useIngresses(environmentId, Object.keys(nsResult?.data || {}));
const ingressesQuery = useIngresses(
environmentId,
Object.keys(nsResult?.data || {})
);
const settings = useStore();
@@ -40,11 +43,11 @@ export function IngressDataTable() {
return (
<Datatable
dataset={result.data || []}
dataset={ingressesQuery.data || []}
storageKey="ingressClassesNameSpace"
columns={columns}
settingsStore={settings}
isLoading={result.isLoading}
isLoading={ingressesQuery.isLoading}
emptyContentLabel="No supported ingresses found"
titleOptions={{
icon: 'svg-route',
@@ -52,6 +55,7 @@ export function IngressDataTable() {
}}
getRowId={(row) => row.Name + row.Type + row.Namespace}
renderTableActions={tableActions}
disableSelect={useCheckboxes()}
/>
);
@@ -77,7 +81,7 @@ export function IngressDataTable() {
</Button>
</Authorized>
<Authorized authorizations="K8sIngressesAdd">
<Authorized authorizations="K8sIngressesW">
<Link to="kubernetes.ingresses.create" className="space-left">
<Button
icon={Plus}
@@ -88,7 +92,7 @@ export function IngressDataTable() {
</Button>
</Link>
</Authorized>
<Authorized authorizations="K8sApplicationsW">
<Authorized authorizations="K8sIngressesW">
<Link to="kubernetes.deploy" className="space-left">
<Button icon={Plus} className="btn-wrapper">
Create from manifest
@@ -99,6 +103,10 @@ export function IngressDataTable() {
);
}
function useCheckboxes() {
return !useAuthorizations(['K8sIngressesW']);
}
async function handleRemoveClick(ingresses: SelectedIngress[]) {
const confirmed = await confirmDeletionAsync(
'Are you sure you want to delete the selected ingresses?'

View File

@@ -1,6 +1,7 @@
import { CellProps, Column } from 'react-table';
import { Icon } from '@@/Icon';
import { Badge } from '@@/Badge';
import { Ingress, TLS, Path } from '../../types';
@@ -31,11 +32,17 @@ export const ingressRules: Column<Ingress> = {
const isHttp = isHTTP(row.original.TLS || [], path.Host);
return (
<div key={`${path.Host}${path.Path}${path.ServiceName}:${path.Port}`}>
{link(path.Host, path.Path, isHttp)}
<span className="px-2">
<span className="flex px-2 flex-nowrap items-center gap-1">
{link(path.Host, path.Path, isHttp)}
<Icon icon="arrow-right" feather />
{`${path.ServiceName}:${path.Port}`}
{!path.HasService && (
<Badge type="warn" className="ml-1 gap-1">
<Icon icon="alert-triangle" feather />
Service doesn&apos;t exist
</Badge>
)}
</span>
{`${path.ServiceName}:${path.Port}`}
</div>
);
});

View File

@@ -1,5 +1,7 @@
import { CellProps, Column } from 'react-table';
import { Authorized } from '@/portainer/hooks/useUser';
import { Link } from '@@/Link';
import { Ingress } from '../../types';
@@ -8,17 +10,22 @@ export const name: Column<Ingress> = {
Header: 'Name',
accessor: 'Name',
Cell: ({ row }: CellProps<Ingress>) => (
<Link
to="kubernetes.ingresses.edit"
params={{
uid: row.original.UID,
namespace: row.original.Namespace,
name: row.original.Name,
}}
title={row.original.Name}
<Authorized
authorizations="K8sIngressesW"
childrenUnauthorized={row.original.Name}
>
{row.original.Name}
</Link>
<Link
to="kubernetes.ingresses.edit"
params={{
uid: row.original.UID,
namespace: row.original.Namespace,
name: row.original.Name,
}}
title={row.original.Name}
>
{row.original.Name}
</Link>
</Authorized>
),
id: 'name',
disableFilters: true,

View File

@@ -7,6 +7,8 @@ import {
withInvalidate,
} from '@/react-tools/react-query';
import { getServices } from '../services/service';
import {
getIngresses,
getIngress,
@@ -65,11 +67,46 @@ export function useIngresses(
'ingress',
],
async () => {
const ingresses = await Promise.all(
const settledIngressesPromise = await Promise.allSettled(
namespaces.map((namespace) => getIngresses(environmentId, namespace))
);
const ingresses = settledIngressesPromise
.filter(isFulfilled)
?.map((i) => i.value);
// flatten the array and remove empty ingresses
const filteredIngresses = ingresses.flat().filter((ing) => ing);
// get all services in only the namespaces that the ingresses are in to find missing services
const uniqueNamespacesWithIngress = [
...new Set(filteredIngresses.map((ing) => ing?.Namespace)),
];
const settledServicesPromise = await Promise.allSettled(
uniqueNamespacesWithIngress.map((ns) => getServices(environmentId, ns))
);
const services = settledServicesPromise
.filter(isFulfilled)
?.map((s) => s.value)
.flat();
// check if each ingress path service has a service that still exists
filteredIngresses.forEach((ing, iIndex) => {
const servicesInNamespace = services?.filter(
(service) => service?.Namespace === ing?.Namespace
);
const serviceNamesInNamespace = servicesInNamespace?.map(
(service) => service.Name
);
ing.Paths?.forEach((path, pIndex) => {
if (
!serviceNamesInNamespace?.includes(path.ServiceName) &&
filteredIngresses[iIndex].Paths
) {
filteredIngresses[iIndex].Paths[pIndex].HasService = false;
} else {
filteredIngresses[iIndex].Paths[pIndex].HasService = true;
}
});
});
return filteredIngresses;
},
{
@@ -152,7 +189,14 @@ export function useIngressControllers(
},
{
enabled: !!namespace,
cacheTime: 0,
...withError('Unable to get ingress controllers'),
}
);
}
function isFulfilled<T>(
input: PromiseSettledResult<T>
): input is PromiseFulfilledResult<T> {
return input.status === 'fulfilled';
}

View File

@@ -2,6 +2,7 @@ import {
PaginationTableSettings,
SortableTableSettings,
} from '@/react/components/datatables/types';
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
export interface TableSettings
extends SortableTableSettings,
@@ -14,6 +15,7 @@ export interface Path {
Port: number;
Path: string;
PathType: string;
HasService?: boolean;
}
export interface TLS {
@@ -41,6 +43,6 @@ export interface IngressController {
Name: string;
ClassName: string;
Availability: string;
Type: string;
Type: SupportedIngControllerTypes;
New: boolean;
}

View File

@@ -13,6 +13,7 @@ import KubernetesServiceHelper from 'Kubernetes/helpers/serviceHelper';
import { KubernetesHorizontalPodAutoScalerHelper } from 'Kubernetes/horizontal-pod-auto-scaler/helper';
import { KubernetesHorizontalPodAutoScalerConverter } from 'Kubernetes/horizontal-pod-auto-scaler/converter';
import KubernetesPodConverter from 'Kubernetes/pod/converter';
import { notifyError } from '@/portainer/services/notifications';
class KubernetesApplicationService {
/* #region CONSTRUCTOR */
@@ -209,11 +210,15 @@ class KubernetesApplicationService {
*/
async createAsync(formValues) {
// formValues -> Application
let [app, headlessService, services, claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues);
let [app, headlessService, services, , claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues);
if (services) {
services.forEach(async (service) => {
await this.KubernetesServiceService.create(service);
try {
await this.KubernetesServiceService.create(service);
} catch (error) {
notifyError('Unable to create service', error);
}
});
}
@@ -221,7 +226,11 @@ class KubernetesApplicationService {
if (app instanceof KubernetesStatefulSet) {
app.VolumeClaims = claims;
headlessService = await this.KubernetesServiceService.create(headlessService);
try {
headlessService = await this.KubernetesServiceService.create(headlessService);
} catch (error) {
notifyError('Unable to create service', error);
}
app.ServiceName = headlessService.metadata.name;
} else {
const claimPromises = _.map(claims, (item) => {
@@ -276,7 +285,11 @@ class KubernetesApplicationService {
}
if (newApp instanceof KubernetesStatefulSet) {
await this.KubernetesServiceService.patch(oldHeadlessService, newHeadlessService);
try {
await this.KubernetesServiceService.patch(oldHeadlessService, newHeadlessService);
} catch (error) {
notifyError('Unable to update service', error);
}
} else {
const claimPromises = _.map(newClaims, (newClaim) => {
if (!newClaim.PreviousName && !newClaim.Id) {
@@ -294,7 +307,11 @@ class KubernetesApplicationService {
// Create services
if (oldServices.length === 0 && newServices.length !== 0) {
newServices.forEach(async (service) => {
await this.KubernetesServiceService.create(service);
try {
await this.KubernetesServiceService.create(service);
} catch (error) {
notifyError('Unable to create service', error);
}
});
}
@@ -315,9 +332,17 @@ class KubernetesApplicationService {
newServices.forEach(async (newService) => {
const oldServiceMatched = _.find(oldServices, { Name: newService.Name });
if (oldServiceMatched) {
await this.KubernetesServiceService.patch(oldServiceMatched, newService);
try {
await this.KubernetesServiceService.patch(oldServiceMatched, newService);
} catch (error) {
notifyError('Unable to update service', error);
}
} else {
await this.KubernetesServiceService.create(newService);
try {
await this.KubernetesServiceService.create(newService);
} catch (error) {
notifyError('Unable to create service', error);
}
}
});
}

View File

@@ -1291,7 +1291,12 @@
</div>
<!-- kubernetes services options -->
<kube-services-view form-values="ctrl.formValues" is-edit="ctrl.state.isEdit" loadbalancer-enabled="ctrl.publishViaLoadBalancerEnabled()"></kube-services-view>
<kube-services-view
form-values="ctrl.formValues"
is-edit="ctrl.state.isEdit"
namespaces="ctrl.allNamespaces"
loadbalancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
></kube-services-view>
<!-- kubernetes services options -->
<!-- summary -->
@@ -1349,7 +1354,7 @@
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid()"
ng-disabled="!kubernetesApplicationCreationForm.$valid || ctrl.isDeployUpdateButtonDisabled() || !ctrl.imageValidityIsValid() || ctrl.hasPortErrors()"
ng-click="ctrl.deployApplication()"
button-spinner="ctrl.state.actionInProgress"
data-cy="k8sAppCreate-deployButton"

View File

@@ -32,6 +32,8 @@ import KubernetesApplicationHelper from 'Kubernetes/helpers/application/index';
import KubernetesVolumeHelper from 'Kubernetes/helpers/volumeHelper';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { KubernetesNodeHelper } from 'Kubernetes/node/helper';
import { updateIngress, getIngresses } from '@/kubernetes/react/views/networks/ingresses/service';
import { confirmUpdateAppIngress } from '@/portainer/services/modal.service/prompt';
class KubernetesCreateApplicationController {
/* #region CONSTRUCTOR */
@@ -144,6 +146,8 @@ class KubernetesCreateApplicationController {
this.setPullImageValidity = this.setPullImageValidity.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onServicePublishChange = this.onServicePublishChange.bind(this);
this.checkIngressesToUpdate = this.checkIngressesToUpdate.bind(this);
this.confirmUpdateApplicationAsync = this.confirmUpdateApplicationAsync.bind(this);
}
/* #endregion */
@@ -676,6 +680,11 @@ class KubernetesCreateApplicationController {
return this.formValues.DeploymentType === this.ApplicationDeploymentTypes.GLOBAL ? this.nodeNumber : this.formValues.ReplicaCount;
}
hasPortErrors() {
const portError = this.formValues.Services.some((service) => service.nodePortError || service.servicePortError);
return portError;
}
resourceReservationsOverflow() {
const instances = this.effectiveInstances();
const cpu = this.formValues.CpuLimit;
@@ -1015,7 +1024,16 @@ class KubernetesCreateApplicationController {
}
}
async updateApplicationAsync() {
async updateApplicationAsync(ingressesToUpdate, rulePlural) {
if (ingressesToUpdate.length) {
try {
await Promise.all(ingressesToUpdate.map((ing) => updateIngress(this.endpoint.Id, ing)));
this.Notifications.success('Success', `Ingress ${rulePlural} successfully updated`);
} catch (error) {
this.Notifications.error('Failure', error, 'Unable to update ingress');
}
}
try {
this.state.actionInProgress = true;
await this.KubernetesApplicationService.patch(this.savedFormValues, this.formValues);
@@ -1028,13 +1046,100 @@ class KubernetesCreateApplicationController {
}
}
deployApplication() {
if (this.state.isEdit) {
this.ModalService.confirmUpdate('Updating the application may cause a service interruption. Do you wish to continue?', (confirmed) => {
if (confirmed) {
return this.$async(this.updateApplicationAsync);
async confirmUpdateApplicationAsync() {
const [ingressesToUpdate, servicePortsToUpdate] = await this.checkIngressesToUpdate();
// if there is an ingressesToUpdate, then show a warning modal with asking if they want to update the ingresses
if (ingressesToUpdate.length) {
const rulePlural = ingressesToUpdate.length > 1 ? 'rules' : 'rule';
const noMatchSentence =
servicePortsToUpdate.length > 1
? `Service ports in this application no longer match the ingress ${rulePlural}.`
: `A service port in this application no longer matches the ingress ${rulePlural} which may break ingress rule paths.`;
const message = `
<ul class="ml-3">
<li>Updating the application may cause a service interruption.</li>
<li>${noMatchSentence}</li>
</ul>
`;
const inputLabel = `Update ingress ${rulePlural} to match the service port changes`;
confirmUpdateAppIngress(`Are you sure?`, message, inputLabel, (value) => {
if (value === null) {
return;
}
if (value.length === 0) {
return this.$async(this.updateApplicationAsync, [], '');
}
if (value[0] === '1') {
return this.$async(this.updateApplicationAsync, ingressesToUpdate, rulePlural);
}
});
} else {
this.ModalService.confirmUpdate('Updating the application may cause a service interruption. Do you wish to continue?', (confirmed) => {
if (confirmed) {
return this.$async(this.updateApplicationAsync, [], '');
}
});
}
}
// check if service ports with ingresses have changed and allow the user to update the ingress to the new port values with a modal
async checkIngressesToUpdate() {
let ingressesToUpdate = [];
let servicePortsToUpdate = [];
const fullIngresses = await getIngresses(this.endpoint.Id, this.formValues.ResourcePool.Namespace.Name);
this.formValues.Services.forEach((updatedService) => {
const oldServiceIndex = this.oldFormValues.Services.findIndex((oldService) => oldService.Name === updatedService.Name);
const numberOfPortsInOldService = this.oldFormValues.Services[oldServiceIndex] && this.oldFormValues.Services[oldServiceIndex].Ports.length;
// if the service has an ingress and there is the same number of ports or more in the updated service
if (updatedService.Ingress && numberOfPortsInOldService && numberOfPortsInOldService <= updatedService.Ports.length) {
const updatedOldPorts = updatedService.Ports.slice(0, numberOfPortsInOldService);
const ingressesForService = fullIngresses.filter((ing) => {
const ingServiceNames = ing.Paths.map((path) => path.ServiceName);
if (ingServiceNames.includes(updatedService.Name)) {
return true;
}
});
ingressesForService.forEach((ingressForService) => {
updatedOldPorts.forEach((servicePort, pIndex) => {
if (servicePort.ingress) {
// if there isn't a ingress path that has a matching service name and port
const doesIngressPathMatchServicePort = ingressForService.Paths.find((ingPath) => ingPath.ServiceName === updatedService.Name && ingPath.Port === servicePort.port);
if (!doesIngressPathMatchServicePort) {
// then find the ingress path index to update by looking for the matching port in the old form values
const oldServicePort = this.oldFormValues.Services[oldServiceIndex].Ports[pIndex].port;
const newServicePort = servicePort.port;
const ingressPathIndex = ingressForService.Paths.findIndex((ingPath) => {
return ingPath.ServiceName === updatedService.Name && ingPath.Port === oldServicePort;
});
if (ingressPathIndex !== -1) {
// if the ingress to update isn't in the ingressesToUpdate list
const ingressUpdateIndex = ingressesToUpdate.findIndex((ing) => ing.Name === ingressForService.Name);
if (ingressUpdateIndex === -1) {
// then add it to the list with the new port
const ingressToUpdate = angular.copy(ingressForService);
ingressToUpdate.Paths[ingressPathIndex].Port = newServicePort;
ingressesToUpdate.push(ingressToUpdate);
} else {
// if the ingress is already in the list, then update the path with the new port
ingressesToUpdate[ingressUpdateIndex].Paths[ingressPathIndex].Port = newServicePort;
}
if (!servicePortsToUpdate.includes(newServicePort)) {
servicePortsToUpdate.push(newServicePort);
}
}
}
}
});
});
}
});
return [ingressesToUpdate, servicePortsToUpdate];
}
deployApplication() {
if (this.state.isEdit) {
return this.$async(this.confirmUpdateApplicationAsync);
} else {
return this.$async(this.deployApplicationAsync);
}
@@ -1087,6 +1192,7 @@ class KubernetesCreateApplicationController {
const nonSystemNamespaces = _.filter(resourcePools, (resourcePool) => !KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name));
this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name);
this.resourcePools = _.sortBy(nonSystemNamespaces, ({ Namespace }) => (Namespace.Name === 'default' ? 0 : 1));
this.formValues.ResourcePool = this.resourcePools[0];
@@ -1154,6 +1260,8 @@ class KubernetesCreateApplicationController {
this.formValues.IsPublishingService = this.formValues.PublishedPorts.length > 0;
this.oldFormValues = angular.copy(this.formValues);
this.updateNamespaceLimits();
this.updateSliders();
} catch (err) {

View File

@@ -284,6 +284,7 @@
<!-- table -->
<kubernetes-application-services-table
services="ctrl.application.Services"
namespaces="ctrl.allNamespaces"
application="ctrl.application"
public-url="ctrl.state.publicUrl"
></kubernetes-application-services-table>

View File

@@ -108,6 +108,7 @@ class KubernetesApplicationController {
Notifications,
LocalStorage,
ModalService,
KubernetesResourcePoolService,
KubernetesApplicationService,
KubernetesEventService,
KubernetesStackService,
@@ -121,6 +122,7 @@ class KubernetesApplicationController {
this.Notifications = Notifications;
this.LocalStorage = LocalStorage;
this.ModalService = ModalService;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.StackService = StackService;
this.KubernetesApplicationService = KubernetesApplicationService;
@@ -376,6 +378,9 @@ class KubernetesApplicationController {
SelectedRevision: undefined,
};
const resourcePools = await this.KubernetesResourcePoolService.get();
this.allNamespaces = resourcePools.map(({ Namespace }) => Namespace.Name);
await this.getApplication();
await this.getEvents();
this.updateApplicationKindText();

View File

@@ -11,7 +11,9 @@
</tr>
<tr ng-repeat="ingress in $ctrl.applicationIngress">
<td
><a ui-sref="kubernetes.ingresses.edit({ name: ingress.IngressName, namespace: $ctrl.application.ResourcePool })">{{ ingress.IngressName }}</a></td
><a authorization="K8sIngressesW" ui-sref="kubernetes.ingresses.edit({ name: ingress.IngressName, namespace: $ctrl.application.ResourcePool })">{{
ingress.IngressName
}}</a></td
>
<td>{{ ingress.ServiceName }}</td>
<td>{{ ingress.Host }}</td>

View File

@@ -77,9 +77,10 @@
<div class="row">
<div class="col-sm-12 h-[max(400px,calc(100vh-380px))]">
<pre
class="log_viewer widget"
><div ng-repeat="line in ctrl.state.filteredLogs = (ctrl.applicationLogs | filter:ctrl.state.search) track by $index" class="line" ng-if="line"><p class="inner_line">{{ line }}</p></div><div ng-if="ctrl.applicationLogs.length && !ctrl.state.filteredLogs.length" class="line"><p class="inner_line">No log line matching the '{{ ctrl.state.search }}' filter</p></div><div ng-if="ctrl.applicationLogs.length === 0" class="line"><p class="inner_line">No logs available</p></div></pre>
<pre class="log_viewer widget">
<div ng-repeat="log in ctrl.state.filteredLogs = (ctrl.applicationLogs | filter:{ 'line': ctrl.state.search }) track by $index" class="line" ng-if="log.line"><p class="inner_line"><span ng-repeat="span in log.spans track by $index" ng-style="{ 'color': span.fgColor, 'background-color': span.bgColor, 'font-weight': span.fontWeight }">{{ span.text }}</span></p></div>
<div ng-if="ctrl.applicationLogs.length && !ctrl.state.filteredLogs.length" class="line"><p class="inner_line">No log line matching the '{{ ctrl.state.search }}' filter</p></div>
<div ng-if="ctrl.applicationLogs.length === 0" class="line"><p class="inner_line">No logs available</p></div></pre>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import angular from 'angular';
import _ from 'lodash-es';
import { concatLogsToString, formatLogs } from '@/docker/helpers/logHelper';
class KubernetesApplicationLogsController {
/* @ngInject */
@@ -39,13 +40,15 @@ class KubernetesApplicationLogsController {
}
downloadLogs() {
const data = new this.Blob([_.reduce(this.applicationLogs, (acc, log) => acc + '\n' + log, '')]);
const logsAsString = concatLogsToString(this.applicationLogs);
const data = new this.Blob([logsAsString]);
this.FileSaver.saveAs(data, this.podName + '_logs.txt');
}
async getApplicationLogsAsync() {
try {
this.applicationLogs = await this.KubernetesPodService.logs(this.application.ResourcePool, this.podName, this.containerName);
const rawLogs = await this.KubernetesPodService.logs(this.application.ResourcePool, this.podName, this.containerName);
this.applicationLogs = formatLogs(rawLogs);
} catch (err) {
this.stopRepeater();
this.Notifications.error('Failure', err, 'Unable to retrieve application logs');
@@ -70,13 +73,8 @@ class KubernetesApplicationLogsController {
this.containerName = containerName;
try {
const [application, applicationLogs] = await Promise.all([
this.KubernetesApplicationService.get(namespace, applicationName),
this.KubernetesPodService.logs(namespace, podName, containerName),
]);
this.application = application;
this.applicationLogs = applicationLogs;
this.application = await this.KubernetesApplicationService.get(namespace, applicationName);
await this.getApplicationLogsAsync();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve application logs');
} finally {

View File

@@ -132,13 +132,13 @@
class="form-control"
id="configuration_data_type"
ng-model="ctrl.formValues.Type"
ng-options="value.name for (name, value) in ctrl.KubernetesSecretTypes"
ng-options="value.value as value.name for (name, value) in ctrl.KubernetesSecretTypeOptions"
ng-change="ctrl.onSecretTypeChange()"
></select>
<div class="col-sm-3 col-lg-2"></div>
</div>
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypes.SERVICEACCOUNTTOKEN" class="col-sm-12 small text-warning vertical-center pt-5">
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value" class="col-sm-12 small text-warning vertical-center pt-5">
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon>
<span
>You should only create a service account token Secret object if you can't use the TokenRequest API to obtain a token, and the security exposure of persisting
@@ -147,19 +147,19 @@
kubernetes documentation.</span
>
</div>
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypes.DOCKERCFG" class="col-sm-12 small text-muted vertical-center pt-5">
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.DOCKERCFG.value" class="col-sm-12 small text-muted vertical-center pt-5">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
<span>Ensure the Secret data field contains a <code>.dockercfg</code> key whose value is content of a legacy <code>~/.dockercfg</code> file.</span>
</div>
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypes.DOCKERCONFIGJSON" class="col-sm-12 small text-muted vertical-center pt-5">
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value" class="col-sm-12 small text-muted vertical-center pt-5">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
<span>Ensure the Secret data field contains a <code>.dockerconfigjson</code> key whose value is content of a <code>~/.docker/config.json</code> file.</span>
</div>
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypes.TLS" class="col-sm-12 small text-muted vertical-center pt-5">
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.TLS.value" class="col-sm-12 small text-muted vertical-center pt-5">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
<span>Ensure the Secret data field contains a <code>tls.key</code> key and a <code>tls.crt</code> key.</span>
</div>
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypes.BOOTSTRAPTOKEN" class="col-sm-12 small text-muted vertical-center pt-5">
<div ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value" class="col-sm-12 small text-muted vertical-center pt-5">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
<span
>Ensure the Secret data field contains a <code>token-id</code> key and a <code>token-secret</code> key. See
@@ -168,7 +168,7 @@
>
</div>
</div>
<div class="form-group" ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypes.CUSTOM">
<div class="form-group" ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.CUSTOM.value">
<label for="configuration_data_customtype" class="col-sm-3 col-lg-2 control-label text-left required">Custom Type</label>
<div class="col-sm-8 col-lg-9">
<input
@@ -193,7 +193,7 @@
</div>
</div>
</div>
<div class="form-group" ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypes.SERVICEACCOUNTTOKEN">
<div class="form-group" ng-if="ctrl.formValues.Type === ctrl.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value">
<label for="service_account" class="col-sm-3 col-lg-2 control-label text-left required">Service Account</label>
<div class="col-sm-8 col-lg-9">
<select

View File

@@ -1,7 +1,7 @@
import angular from 'angular';
import _ from 'lodash-es';
import { KubernetesConfigurationFormValues, KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesConfigurationKinds, KubernetesSecretTypes } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationKinds, KubernetesSecretTypeOptions } from 'Kubernetes/models/configuration/models';
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { getServiceAccounts } from 'Kubernetes/rest/serviceAccount';
@@ -21,7 +21,7 @@ class KubernetesCreateConfigurationController {
this.KubernetesConfigurationService = KubernetesConfigurationService;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
this.KubernetesSecretTypes = KubernetesSecretTypes;
this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions;
this.onInit = this.onInit.bind(this);
this.createConfigurationAsync = this.createConfigurationAsync.bind(this);
@@ -66,41 +66,41 @@ class KubernetesCreateConfigurationController {
}
onSecretTypeChange() {
switch (this.formValues.Type.value) {
case KubernetesSecretTypes.OPAQUE.value:
case KubernetesSecretTypes.CUSTOM.value:
switch (this.formValues.Type) {
case KubernetesSecretTypeOptions.OPAQUE.value:
case KubernetesSecretTypeOptions.CUSTOM.value:
this.formValues.Data = this.formValues.Data.filter((entry) => entry.Value !== '');
if (this.formValues.Data.length === 0) {
this.addRequiredKeysToForm(['']);
}
this.state.isDockerConfig = false;
break;
case KubernetesSecretTypes.SERVICEACCOUNTTOKEN.value:
case KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value:
// data isn't required for service account tokens, so remove the data fields if they are empty
this.addRequiredKeysToForm([]);
this.state.isDockerConfig = false;
break;
case KubernetesSecretTypes.DOCKERCONFIGJSON.value:
case KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value:
this.addRequiredKeysToForm(['.dockerconfigjson']);
this.state.isDockerConfig = true;
break;
case KubernetesSecretTypes.DOCKERCFG.value:
case KubernetesSecretTypeOptions.DOCKERCFG.value:
this.addRequiredKeysToForm(['.dockercfg']);
this.state.isDockerConfig = true;
break;
case KubernetesSecretTypes.BASICAUTH.value:
case KubernetesSecretTypeOptions.BASICAUTH.value:
this.addRequiredKeysToForm(['username', 'password']);
this.state.isDockerConfig = false;
break;
case KubernetesSecretTypes.SSHAUTH.value:
case KubernetesSecretTypeOptions.SSHAUTH.value:
this.addRequiredKeysToForm(['ssh-privatekey']);
this.state.isDockerConfig = false;
break;
case KubernetesSecretTypes.TLS.value:
case KubernetesSecretTypeOptions.TLS.value:
this.addRequiredKeysToForm(['tls.crt', 'tls.key']);
this.state.isDockerConfig = false;
break;
case KubernetesSecretTypes.BOOTSTRAPTOKEN.value:
case KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value:
this.addRequiredKeysToForm(['token-id', 'token-secret']);
this.state.isDockerConfig = false;
break;

View File

@@ -2,7 +2,7 @@ import angular from 'angular';
import _ from 'lodash-es';
import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesConfigurationKinds, KubernetesSecretTypes } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationKinds, KubernetesSecretTypeOptions } from 'Kubernetes/models/configuration/models';
import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelper';
import KubernetesConfigurationConverter from 'Kubernetes/converters/configuration';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
@@ -39,7 +39,7 @@ class KubernetesConfigurationController {
this.KubernetesApplicationService = KubernetesApplicationService;
this.KubernetesEventService = KubernetesEventService;
this.KubernetesConfigurationKinds = KubernetesConfigurationKinds;
this.KubernetesSecretTypes = KubernetesSecretTypes;
this.KubernetesSecretTypeOptions = KubernetesSecretTypeOptions;
this.KubernetesConfigMapService = KubernetesConfigMapService;
this.KubernetesSecretService = KubernetesSecretService;
@@ -147,6 +147,7 @@ class KubernetesConfigurationController {
if (secret.status === 'fulfilled') {
this.configuration = KubernetesConfigurationConverter.secretToConfiguration(secret.value);
this.formValues.Data = secret.value.Data;
// this.formValues.ServiceAccountName = secret.value.ServiceAccountName;
} else {
this.configuration = KubernetesConfigurationConverter.configMapToConfiguration(configMap.value);
this.formValues.Data = configMap.value.Data;
@@ -276,19 +277,23 @@ class KubernetesConfigurationController {
// after loading the configuration, check if it is a docker config secret type
if (
this.formValues.Kind === this.KubernetesConfigurationKinds.SECRET &&
(this.formValues.Type === this.KubernetesSecretTypes.DOCKERCONFIGJSON.value || this.formValues.Type === this.KubernetesSecretTypes.DOCKERCFG.value)
(this.formValues.Type === this.KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value || this.formValues.Type === this.KubernetesSecretTypeOptions.DOCKERCFG.value)
) {
this.state.isDockerConfig = true;
}
// convert the secret type to a human readable value
if (this.formValues.Type) {
const secretTypeValues = Object.values(this.KubernetesSecretTypes);
const secretTypeValues = Object.values(this.KubernetesSecretTypeOptions);
const secretType = secretTypeValues.find((secretType) => secretType.value === this.formValues.Type);
this.secretTypeName = secretType ? secretType.name : this.formValues.Type;
} else {
this.secretTypeName = '';
}
if (this.formValues.Type === this.KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value) {
this.formValues.ServiceAccountName = configuration.ServiceAccountName;
}
this.tagUsedDataKeys();
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to load view data');

View File

@@ -1,4 +1,4 @@
import { KubernetesSecretTypes } from '@/kubernetes/models/configuration/models';
import { KubernetesSecretTypeOptions } from '@/kubernetes/models/configuration/models';
import { KubernetesConfigurationKinds } from '@/kubernetes/models/configuration/models';
export function isConfigurationFormValid(alreadyExist, isDataValid, formValues) {
@@ -9,36 +9,35 @@ export function isConfigurationFormValid(alreadyExist, isDataValid, formValues)
if (formValues.IsSimple) {
if (formValues.Kind === KubernetesConfigurationKinds.SECRET) {
let isSecretDataValid = true;
const secretTypeValue = typeof formValues.Type === 'string' ? formValues.Type : formValues.Type.value;
switch (secretTypeValue) {
case KubernetesSecretTypes.SERVICEACCOUNTTOKEN.value:
switch (formValues.Type) {
case KubernetesSecretTypeOptions.SERVICEACCOUNTTOKEN.value:
// data isn't required for service account tokens
isFormValid = uniqueCheck && formValues.ResourcePool;
return [isFormValid, ''];
case KubernetesSecretTypes.DOCKERCFG.value:
case KubernetesSecretTypeOptions.DOCKERCFG.value:
// needs to contain a .dockercfg key
isSecretDataValid = formValues.Data.some((entry) => entry.Key === '.dockercfg');
secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a .dockercfg key is required.';
break;
case KubernetesSecretTypes.DOCKERCONFIGJSON.value:
case KubernetesSecretTypeOptions.DOCKERCONFIGJSON.value:
// needs to contain a .dockerconfigjson key
isSecretDataValid = formValues.Data.some((entry) => entry.Key === '.dockerconfigjson');
secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a .dockerconfigjson key. is required.';
break;
case KubernetesSecretTypes.BASICAUTH.value:
case KubernetesSecretTypeOptions.BASICAUTH.value:
isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'username' || entry.Key === 'password');
secretWarningMessage = isSecretDataValid ? '' : 'A data entry with a username or password key is required.';
break;
case KubernetesSecretTypes.SSHAUTH.value:
case KubernetesSecretTypeOptions.SSHAUTH.value:
isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'ssh-privatekey');
secretWarningMessage = isSecretDataValid ? '' : `A data entry with a 'ssh-privatekey' key is required.`;
break;
case KubernetesSecretTypes.TLS.value:
case KubernetesSecretTypeOptions.TLS.value:
isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'tls.crt') && formValues.Data.some((entry) => entry.Key === 'tls.key');
secretWarningMessage = isSecretDataValid ? '' : `Data entries containing a 'tls.crt' key and a 'tls.key' key are required.`;
break;
case KubernetesSecretTypes.BOOTSTRAPTOKEN.value:
case KubernetesSecretTypeOptions.BOOTSTRAPTOKEN.value:
isSecretDataValid = formValues.Data.some((entry) => entry.Key === 'token-id') && formValues.Data.some((entry) => entry.Key === 'token-secret');
secretWarningMessage = isSecretDataValid ? '' : `Data entries containing a 'token-id' key and a 'token-secret' key are required.`;
break;

View File

@@ -42,25 +42,55 @@
</div>
<ingress-class-datatable
on-change-availability="(ctrl.onChangeAvailability)"
on-change-controllers="(ctrl.onChangeControllers)"
allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass"
ingress-controllers="ctrl.originalIngressControllers"
is-loading="ctrl.isIngressControllersLoading"
description="'Enabling ingress controllers in your cluster allows them to be available in the Portainer UI for users to publish applications over HTTP/HTTPS. A controller must have a class name for it to be included here.'"
no-ingress-controller-label="'No supported ingress controllers found.'"
view="'cluster'"
></ingress-class-datatable>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
<label htmlFor="foldingButtonIngControllerSettings" class="col-sm-12 form-section-title cursor-pointer flex items-center">
<button
id="foldingButtonIngControllerSettings"
type="button"
class="border-0 mx-2 bg-transparent inline-flex justify-center items-center w-2 !ml-0"
ng-click="ctrl.toggleAdvancedIngSettings()"
>
<pr-icon ng-if="!ctrl.state.isIngToggleSectionExpanded" feather="true" icon="'chevron-right'"></pr-icon>
<pr-icon ng-if="ctrl.state.isIngToggleSectionExpanded" feather="true" icon="'chevron-down'"></pr-icon>
</button>
More settings
</label>
<div ng-if="ctrl.state.isIngToggleSectionExpanded" class="ml-4">
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.AllowNoneIngressClass"
name="'allowNoIngressClass'"
label="'Allow ingress class to be set to &quot;none&quot;'"
tooltip="'This allows users setting up ingresses to select &quot;none&quot; as the ingress class.'"
on-change="(ctrl.onToggleAllowNoneIngressClass)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<por-switch-field
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
name="'ingressAvailabilityPerNamespace'"
label="'Configure ingress controller availability per namespace'"
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
switch-class="'col-sm-8'"
>
</por-switch-field>
</div>
</div>
</div>
@@ -202,7 +232,7 @@
</tr>
<tr ng-repeat="class in ctrl.StorageClasses">
<td>
<div class="flex-row vertical-center">
<div class="flex flex-row items-center h-full">
<label class="switch mr-2 mb-0">
<input type="checkbox" ng-model="class.selected" /><span class="slider round" data-cy="kubeSetup-storageToggle{{ class.Name }}"></span>
</label>
@@ -218,7 +248,7 @@
></storage-access-mode-selector>
</td>
<td>
<div style="margin: 5px">
<div class="flex flex-row items-center h-full">
<label class="switch mr-2 mb-0"
><input type="checkbox" ng-model="class.AllowVolumeExpansion" /><span
class="slider round"

View File

@@ -47,9 +47,10 @@ class KubernetesConfigureController {
this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT;
this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
this.onChangeAvailability = this.onChangeAvailability.bind(this);
this.onChangeControllers = this.onChangeControllers.bind(this);
this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this);
this.onToggleAllowNoneIngressClass = this.onToggleAllowNoneIngressClass.bind(this);
this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this);
}
/* #endregion */
@@ -71,7 +72,7 @@ class KubernetesConfigureController {
/* #endregion */
/* #region INGRESS CLASSES UI MANAGEMENT */
onChangeAvailability(controllerClassMap) {
onChangeControllers(controllerClassMap) {
this.ingressControllers = controllerClassMap;
}
@@ -79,6 +80,18 @@ class KubernetesConfigureController {
return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK });
}
toggleAdvancedIngSettings() {
this.$scope.$evalAsync(() => {
this.state.isIngToggleSectionExpanded = !this.state.isIngToggleSectionExpanded;
});
}
onToggleAllowNoneIngressClass() {
this.$scope.$evalAsync(() => {
this.formValues.AllowNoneIngressClass = !this.formValues.AllowNoneIngressClass;
});
}
onToggleIngressAvailabilityPerNamespace() {
this.$scope.$evalAsync(() => {
this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace;
@@ -109,6 +122,7 @@ class KubernetesConfigureController {
endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses;
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace;
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace;
endpoint.Kubernetes.Configuration.AllowNoneIngressClass = this.formValues.AllowNoneIngressClass;
endpoint.ChangeWindow = this.state.autoUpdateSettings;
}
@@ -198,7 +212,7 @@ class KubernetesConfigureController {
this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses);
await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint);
// updateIngressControllerClassMap must be done after updateEndpoint, as a hacky workaround. A better solution: saving ingresscontrollers somewhere else, is being discussed
await updateIngressControllerClassMap(this.state.endpointId, this.ingressControllers);
await updateIngressControllerClassMap(this.state.endpointId, this.ingressControllers || []);
this.state.isSaving = true;
const storagePromises = _.map(storageClasses, (storageClass) => {
const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name });
@@ -224,19 +238,7 @@ class KubernetesConfigureController {
}
configure() {
const toDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true });
if (toDel.length) {
this.ModalService.confirmUpdate(
`Removing ingress controllers may cause applications to be unaccessible. All ingress configurations from affected applications will be removed.<br/><br/>Do you wish to continue?`,
(confirmed) => {
if (confirmed) {
return this.$async(this.configureAsync);
}
}
);
} else {
return this.$async(this.configureAsync);
}
return this.$async(this.configureAsync);
}
/* #endregion */
@@ -268,6 +270,7 @@ class KubernetesConfigureController {
actionInProgress: false,
displayConfigureClassPanel: {},
viewReady: false,
isIngToggleSectionExpanded: false,
endpointId: this.$state.params.endpointId,
duplicates: {
ingressClasses: new KubernetesFormValidationReferences(),
@@ -292,6 +295,7 @@ class KubernetesConfigureController {
IngressAvailabilityPerNamespace: false,
};
this.isIngressControllersLoading = true;
try {
this.availableAccessModes = new KubernetesStorageClassAccessPolicies();
@@ -307,6 +311,9 @@ class KubernetesConfigureController {
if (storage) {
item.selected = true;
item.AccessModes = storage.AccessModes.map((name) => this.availableAccessModes.find((accessMode) => accessMode.Name === name));
} else if (this.availableAccessModes.length) {
// set a default access mode if the storage class is not enabled and there are available access modes
item.AccessModes = [this.availableAccessModes[0]];
}
});
@@ -323,12 +330,14 @@ class KubernetesConfigureController {
return ic;
});
this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace;
this.formValues.AllowNoneIngressClass = this.endpoint.Kubernetes.Configuration.AllowNoneIngressClass;
this.oldFormValues = Object.assign({}, this.formValues);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve environment configuration');
} finally {
this.state.viewReady = true;
this.isIngressControllersLoading = false;
}
window.addEventListener('beforeunload', this.onBeforeOnload);
@@ -359,7 +368,7 @@ class KubernetesConfigureController {
}
uiCanExit() {
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged())) {
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged()) && !this.isIngressControllersLoading) {
return this.ModalService.confirmAsync({
title: 'Are you sure?',
message: 'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?',

View File

@@ -36,7 +36,7 @@ class KubernetesDeployController {
{ ...git, value: KubernetesDeployBuildMethods.GIT },
{ ...editor, value: KubernetesDeployBuildMethods.WEB_EDITOR },
{ ...url, value: KubernetesDeployBuildMethods.URL },
{ ...template, value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
{ ...template, description: 'Use custom template', value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
];
this.state = {

View File

@@ -185,7 +185,7 @@
<div class="col-sm-12 form-section-title"> Networking </div>
<ingress-class-datatable
ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
on-change-availability="($ctrl.onChangeIngressControllerAvailability)"
on-change-controllers="($ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="$ctrl.ingressControllers"
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"

View File

@@ -109,7 +109,7 @@ class KubernetesCreateResourcePoolController {
this.checkDefaults();
this.formValues.Owner = this.Authentication.getUserDetails().username;
await this.KubernetesResourcePoolService.create(this.formValues);
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers, this.formValues.Name);
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers || [], this.formValues.Name);
this.Notifications.success('Namespace successfully created', this.formValues.Name);
this.$state.go('kubernetes.resourcePools');
} catch (err) {

View File

@@ -161,7 +161,7 @@
<div class="col-sm-12 form-section-title"> Networking </div>
<ingress-class-datatable
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
on-change-availability="(ctrl.onChangeIngressControllerAvailability)"
on-change-controllers="(ctrl.onChangeIngressControllerAvailability)"
ingress-controllers="ctrl.ingressControllers"
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"

View File

@@ -144,7 +144,7 @@ class KubernetesResourcePoolController {
try {
this.checkDefaults();
await this.KubernetesResourcePoolService.patch(oldFormValues, newFormValues);
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers, this.formValues.Name);
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers || [], this.formValues.Name);
this.Notifications.success('Namespace successfully updated', this.pool.Namespace.Name);
this.$state.reload(this.$state.current);
} catch (err) {

View File

@@ -69,9 +69,11 @@ ctrl.state.transition.name,
<div class="row">
<div class="col-sm-12 h-[max(400px,calc(100vh-380px))]">
<pre
class="log_viewer"
><div ng-repeat="line in ctrl.state.filteredLogs = (ctrl.stackLogs | filter:ctrl.state.search) track by $index" class="line" ng-if="line"><p class="inner_line"><span ng-style="{'color': line.Color, 'font-weight': 'bold'};">{{ line.AppName }}</span> {{ line.Line }}</p></div><div ng-if="ctrl.stackLogs.length && !ctrl.state.filteredLogs.length" class="line"><p class="inner_line">No log line matching the '{{ ctrl.state.search }}' filter</p></div><div ng-if="ctrl.stackLogs.length === 0" class="line"><p class="inner_line">No logs available</p></div></pre>
<pre class="log_viewer">
<div ng-repeat="log in ctrl.state.filteredLogs = (ctrl.stackLogs | filter:{ 'line': ctrl.state.search }) track by $index" class="line" ng-if="log.line"><p class="inner_line"><span ng-style="{'color': log.appColor, 'font-weight': 'bold'};">{{ log.appName }}</span> <span ng-repeat="span in log.spans track by $index" ng-style="{ 'color': span.fgColor, 'background-color': span.bgColor, 'font-weight': span.fontWeight }">{{ span.text }}</span></p></div>
<div ng-if="ctrl.stackLogs.length && !ctrl.state.filteredLogs.length" class="line"><p class="inner_line">No log line matching the '{{ ctrl.state.search }}' filter</p></div>
<div ng-if="ctrl.stackLogs.length === 0" class="line"><p class="inner_line">No logs available</p></div>
</pre>
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import _ from 'lodash-es';
import { filter, flatMap, map } from 'lodash';
import angular from 'angular';
import $allSettled from 'Portainer/services/allSettled';
import { concatLogsToString, formatLogs } from '@/docker/helpers/logHelper';
const colors = ['red', 'orange', 'lime', 'green', 'darkgreen', 'cyan', 'turquoise', 'teal', 'deepskyblue', 'blue', 'darkblue', 'slateblue', 'magenta', 'darkviolet'];
@@ -58,7 +59,7 @@ class KubernetesStackLogsController {
Pods: [],
};
const promises = _.flatMap(_.map(app.Pods, (pod) => _.map(pod.Containers, (container) => this.generateLogsPromise(pod, container))));
const promises = flatMap(map(app.Pods, (pod) => map(pod.Containers, (container) => this.generateLogsPromise(pod, container))));
const result = await $allSettled(promises);
res.Pods = result.fulfilled;
return res;
@@ -67,21 +68,12 @@ class KubernetesStackLogsController {
async getStackLogsAsync() {
try {
const applications = await this.KubernetesApplicationService.get(this.state.transition.namespace);
const filteredApplications = _.filter(applications, (app) => app.StackName === this.state.transition.name);
const logsPromises = _.map(filteredApplications, this.generateAppPromise);
const filteredApplications = filter(applications, (app) => app.StackName === this.state.transition.name);
const logsPromises = map(filteredApplications, this.generateAppPromise);
const data = await Promise.all(logsPromises);
const logs = _.flatMap(data, (app, index) => {
return _.flatMap(app.Pods, (pod) => {
return _.map(pod.Logs, (line) => {
const res = {
Color: colors[index % colors.length],
Line: line,
AppName: pod.Pod.Name,
};
return res;
});
});
});
const logs = flatMap(data, (app, index) =>
flatMap(app.Pods, (pod) => formatLogs(pod.Logs).map((line) => ({ ...line, appColor: colors[index % colors.length], appName: pod.Pod.Name })))
);
this.stackLogs = logs;
} catch (err) {
this.stopRepeater();
@@ -90,7 +82,8 @@ class KubernetesStackLogsController {
}
downloadLogs() {
const data = new this.Blob([(this.dataLogs = _.reduce(this.stackLogs, (acc, log) => acc + '\n' + log.AppName + ' ' + log.Line, ''))]);
const logsAsString = concatLogsToString(this.state.filteredLogs, (line) => `${line.appName} ${line.line}`);
const data = new this.Blob([logsAsString]);
this.FileSaver.saveAs(data, this.state.transition.name + '_logs.txt');
}

View File

@@ -8,7 +8,7 @@ export default function (formValues) {
if (formValues.Kind === KubernetesConfigurationKinds.CONFIGMAP) {
return [{ action, kind: KubernetesResourceTypes.CONFIGMAP, name: formValues.Name }];
} else if (formValues.Kind === KubernetesConfigurationKinds.SECRET) {
let type = typeof formValues.Type === 'string' ? formValues.Type : formValues.Type.name;
let type = formValues.Type;
if (formValues.customType) {
type = formValues.customType;
}

View File

@@ -71,19 +71,16 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
})
.then(function success(data) {
ctrl.availableUsers = _.orderBy(data.availableUsers, 'Username', 'asc');
var availableTeams = _.orderBy(data.availableTeams, 'Name', 'asc');
ctrl.availableTeams = availableTeams;
if (!isAdmin && availableTeams.length === 1) {
ctrl.formData.AuthorizedTeams = availableTeams;
ctrl.availableTeams = _.orderBy(data.availableTeams, 'Name', 'asc');
if (!isAdmin && ctrl.availableTeams.length === 1) {
ctrl.formData.AuthorizedTeams = ctrl.availableTeams;
}
return $q.when(ctrl.resourceControl && ResourceControlService.retrieveOwnershipDetails(ctrl.resourceControl));
})
.then(function success(data) {
if (data) {
var authorizedUsers = data.authorizedUsers;
var authorizedTeams = data.authorizedTeams;
const authorizedTeams = !isAdmin && ctrl.availableTeams.length === 1 ? ctrl.availableTeams : data.authorizedTeams;
const authorizedUsers = !isAdmin && authorizedTeams.length === 1 ? [] : data.authorizedUsers;
setOwnership(ctrl.resourceControl, isAdmin);
setAuthorizedUsersAndTeams(authorizedUsers, authorizedTeams);
}

View File

@@ -1,11 +1,14 @@
<ng-form name="pathForm">
<div class="form-group">
<span class="col-sm-12 text-muted small vertical-center">
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon> Indicate the path to the {{ $ctrl.deployMethod == 'compose' ? 'Compose' : 'Manifest' }} file from the root
of your repository. To enable rebuilding of an image if already present on Docker standalone environments, include<code>pull_policy: build</code>in your compose file as per<a
href="https://docs.docker.com/compose/compose-file/#pull_policy"
>Docker documentation</a
>.
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
<span
>Indicate the path to the {{ $ctrl.deployMethod == 'compose' ? 'Compose' : 'Manifest' }} file from the root of your repository.
<span ng-if="$ctrl.isDockerStandalone">
To enable rebuilding of an image if already present on Docker standalone environments, include<code>pull_policy: build</code>in your compose file as per
<a href="https://docs.docker.com/compose/compose-file/#pull_policy">Docker documentation</a>.</span
></span
>
</span>
</div>
<div class="form-group">

View File

@@ -4,5 +4,6 @@ export const gitFormComposePathField = {
deployMethod: '@',
value: '<',
onChange: '<',
isDockerStandalone: '<',
},
};

View File

@@ -1,10 +1,11 @@
export default class GitFormController {
/* @ngInject */
constructor() {
constructor(StateManager) {
this.onChangeField = this.onChangeField.bind(this);
this.onChangeURL = this.onChangeField('RepositoryURL');
this.onChangeRefName = this.onChangeField('RepositoryReferenceName');
this.onChangeComposePath = this.onChangeField('ComposeFilePathInRepository');
this.isDockerStandalone = StateManager.getState().endpoint.mode.provider === 'DOCKER_STANDALONE';
}
onChangeField(field) {

View File

@@ -10,6 +10,7 @@
value="$ctrl.model.ComposeFilePathInRepository"
on-change="($ctrl.onChangeComposePath)"
deploy-method="{{ $ctrl.deployMethod }}"
is-docker-standalone="$ctrl.isDockerStandalone"
></git-form-compose-path-field>
<git-form-additional-files-panel ng-if="$ctrl.additionalFile" model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-additional-files-panel>

View File

@@ -38,7 +38,6 @@ angular.module('portainer.app').controller('StackDuplicationFormController', [
}
function onChangeEnvironment(endpointId) {
console.log({ endpointId });
return $scope.$evalAsync(() => {
ctrl.formValues.endpointId = endpointId;
});

View File

@@ -70,6 +70,7 @@ export interface KubernetesConfiguration {
RestrictDefaultNamespace?: boolean;
IngressClasses: IngressClass[];
IngressAvailabilityPerNamespace: boolean;
AllowNoneIngressClass: boolean;
}
export interface KubernetesSettings {

View File

@@ -123,6 +123,7 @@ interface AuthorizedProps {
authorizations: string | string[];
environmentId?: EnvironmentId;
adminOnlyCE?: boolean;
childrenUnauthorized?: ReactNode;
}
export function Authorized({
@@ -130,6 +131,7 @@ export function Authorized({
environmentId,
adminOnlyCE = false,
children,
childrenUnauthorized = null,
}: PropsWithChildren<AuthorizedProps>) {
const isAllowed = useAuthorizations(
authorizations,
@@ -137,7 +139,7 @@ export function Authorized({
adminOnlyCE
);
return isAllowed ? <>{children}</> : null;
return isAllowed ? <>{children}</> : <>{childrenUnauthorized}</>;
}
interface UserProviderProps {

View File

@@ -109,7 +109,7 @@ export const componentsModule = angular
.component('boxSelectorBadgeIcon', r2a(BadgeIcon, ['featherIcon', 'icon']))
.component(
'accessControlPanel',
r2a(withReactQuery(withCurrentUser(AccessControlPanel)), [
r2a(withUIRouter(withReactQuery(withCurrentUser(AccessControlPanel))), [
'disableOwnershipChange',
'onUpdateSuccess',
'resourceControl',

View File

@@ -18,6 +18,7 @@ interface InputOption {
interface PromptOptions {
title: string;
message?: string;
inputType?:
| 'text'
| 'textarea'
@@ -45,6 +46,8 @@ export async function promptAsync(options: Omit<PromptOptions, 'callback'>) {
});
}
// the ts-ignore is required because the bootbox typings are not up to date
// remove the ts-ignore when the typings are updated in
export function prompt(options: PromptOptions) {
const box = bootbox.prompt({
title: options.title,
@@ -84,6 +87,32 @@ export function confirmContainerDeletion(
});
}
export function confirmUpdateAppIngress(
title: string,
message: string,
inputText: string,
callback: PromptCallback
) {
prompt({
title: buildTitle(title),
inputType: 'checkbox',
message,
inputOptions: [
{
text: `${inputText}<i></i>`,
value: '1',
},
],
buttons: {
confirm: {
label: 'Update',
className: 'btn-primary',
},
},
callback,
});
}
export function selectRegistry(options: PromptOptions) {
prompt(options);
}

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