Compare commits
43 Commits
yd-develop
...
release/2.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97e00cf8b3 | ||
|
|
5ab77e07c3 | ||
|
|
71216bd585 | ||
|
|
d3d4361850 | ||
|
|
1b237151a9 | ||
|
|
0ddf31f3e2 | ||
|
|
abd513801f | ||
|
|
354eb8c3c0 | ||
|
|
539e7fe422 | ||
|
|
11e42f54ba | ||
|
|
db1d56621e | ||
|
|
4f173d0fd2 | ||
|
|
60183be3fe | ||
|
|
ae467a9754 | ||
|
|
e298df78b2 | ||
|
|
d27df1d4cd | ||
|
|
7343a2ebd7 | ||
|
|
71d2473070 | ||
|
|
a308cf4ae4 | ||
|
|
9c04c253e7 | ||
|
|
446e1822bf | ||
|
|
4a27aa12bc | ||
|
|
a9973c8d53 | ||
|
|
677d61b855 | ||
|
|
b0938875dc | ||
|
|
1e1cb3784c | ||
|
|
f1e7417e33 | ||
|
|
c45d41f55f | ||
|
|
e02238365a | ||
|
|
6e1e9e9341 | ||
|
|
eb0cfdd2c1 | ||
|
|
44f74a7441 | ||
|
|
4ee064eeee | ||
|
|
da0661a1bd | ||
|
|
19c2bc12de | ||
|
|
bc6a43d88b | ||
|
|
6d6632d4e2 | ||
|
|
a32b004fb3 | ||
|
|
ba441da519 | ||
|
|
07f8abe2f3 | ||
|
|
071962de2d | ||
|
|
04fd2a2b44 | ||
|
|
70d89e9a24 |
@@ -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/)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -111,6 +111,9 @@ func (m *Migrator) Migrate() error {
|
||||
|
||||
// Portainer 2.16
|
||||
newMigration(70, m.migrateDBVersionToDB70),
|
||||
|
||||
// Portainer 2.16.1
|
||||
newMigration(71, m.migrateDBVersionToDB71),
|
||||
}
|
||||
|
||||
var lastDbVersion int
|
||||
|
||||
@@ -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 {
|
||||
|
||||
36
api/datastore/migrator/migrate_dbversion71.go
Normal file
36
api/datastore/migrator/migrate_dbversion71.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (m *Migrator) migrateDBVersionToDB71() error {
|
||||
log.Info().Msg("removing orphaned snapshots")
|
||||
|
||||
snapshots, err := m.snapshotService.Snapshots()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range snapshots {
|
||||
_, err := m.endpointService.Endpoint(s.EndpointID)
|
||||
if err == nil {
|
||||
log.Debug().Int("endpoint_id", int(s.EndpointID)).Msg("keeping snapshot")
|
||||
continue
|
||||
} else if err != errors.ErrObjectNotFound {
|
||||
log.Debug().Int("endpoint_id", int(s.EndpointID)).Err(err).Msg("database error")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debug().Int("endpoint_id", int(s.EndpointID)).Msg("removing snapshot")
|
||||
|
||||
err = m.snapshotService.DeleteSnapshot(s.EndpointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -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": {
|
||||
@@ -927,7 +931,7 @@
|
||||
],
|
||||
"version": {
|
||||
"DB_UPDATING": "false",
|
||||
"DB_VERSION": "70",
|
||||
"DB_VERSION": "72",
|
||||
"INSTANCE_ID": "null"
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -49,6 +49,11 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Snapshot().DeleteSnapshot(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to remove the snapshot from the database", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID))
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to remove environment from the database", err)
|
||||
|
||||
@@ -84,7 +84,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.16.0
|
||||
// @version 2.16.2
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
@@ -1441,9 +1451,9 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.16.0"
|
||||
APIVersion = "2.16.2"
|
||||
// DBVersion is the version number of the Portainer database
|
||||
DBVersion = 70
|
||||
DBVersion = 72
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
ComposeSyntaxMaxVersion = "3.9"
|
||||
// AssetsServerURL represents the URL of the Portainer asset server
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
63
app/docker/helpers/logHelper/colors/colors.ts
Normal file
63
app/docker/helpers/logHelper/colors/colors.ts
Normal 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],
|
||||
};
|
||||
7
app/docker/helpers/logHelper/colors/index.ts
Normal file
7
app/docker/helpers/logHelper/colors/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type RGBColor,
|
||||
type TextColor,
|
||||
colors,
|
||||
FOREGROUND_COLORS_BY_ANSI,
|
||||
BACKGROUND_COLORS_BY_ANSI,
|
||||
} from './colors';
|
||||
258
app/docker/helpers/logHelper/colors/rawColors.json
Normal file
258
app/docker/helpers/logHelper/colors/rawColors.json
Normal 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"
|
||||
]
|
||||
15
app/docker/helpers/logHelper/concatLogsToString.ts
Normal file
15
app/docker/helpers/logHelper/concatLogsToString.ts
Normal 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,
|
||||
''
|
||||
);
|
||||
}
|
||||
55
app/docker/helpers/logHelper/formatJSONLogs.ts
Normal file
55
app/docker/helpers/logHelper/formatJSONLogs.ts
Normal 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;
|
||||
}
|
||||
157
app/docker/helpers/logHelper/formatLogs.ts
Normal file
157
app/docker/helpers/logHelper/formatLogs.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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 JSON logs come serialized 2 times, parse them once to unwrap them
|
||||
// for example when retrieving Edge Agent logs on Nomad
|
||||
if (logs.startsWith('"')) {
|
||||
try {
|
||||
logs = JSON.parse(logs);
|
||||
} catch (error) {
|
||||
// noop, throw error away if logs cannot be parsed
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
if (
|
||||
(!withTimestamps && text.startsWith('{')) ||
|
||||
(withTimestamps && text.substring(TIMESTAMP_LENGTH).startsWith('{'))
|
||||
) {
|
||||
const lines = formatJSONLine(text, withTimestamps);
|
||||
formattedLogs.push(...lines);
|
||||
} else if (
|
||||
(!withTimestamps && ZerologRegex.test(text)) ||
|
||||
(withTimestamps &&
|
||||
ZerologRegex.test(text.substring(TIMESTAMP_LENGTH)))
|
||||
) {
|
||||
const lines = formatZerologLogs(text, withTimestamps);
|
||||
formattedLogs.push(...lines);
|
||||
} else {
|
||||
spans.push({ fgColor, bgColor, text, fontWeight });
|
||||
line += text;
|
||||
}
|
||||
} catch (error) {
|
||||
// in case parsing fails for whatever reason, push the raw logs and continue
|
||||
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 '';
|
||||
}
|
||||
125
app/docker/helpers/logHelper/formatZerologLogs.ts
Normal file
125
app/docker/helpers/logHelper/formatZerologLogs.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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;
|
||||
|
||||
if (withTimestamps) {
|
||||
const timestamp = rawText.substring(0, TIMESTAMP_LENGTH);
|
||||
spans.push({ text: timestamp });
|
||||
line += `${timestamp} `;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
154
app/docker/helpers/logHelper/formatters.ts
Normal file
154
app/docker/helpers/logHelper/formatters.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
2
app/docker/helpers/logHelper/index.ts
Normal file
2
app/docker/helpers/logHelper/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { formatLogs } from './formatLogs';
|
||||
export { concatLogsToString } from './concatLogsToString';
|
||||
53
app/docker/helpers/logHelper/types.ts
Normal file
53
app/docker/helpers/logHelper/types.ts
Normal 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)',
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -45,8 +45,6 @@ class DockerRegistryAccessController {
|
||||
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
this.Authentication.redirectIfUnauthorized(['PortainerRegistryUpdateAccess']);
|
||||
|
||||
this.registryTo = window.location.hash.match(/#!\/\d+\/docker\/swarm\/registries/) ? 'docker.swarm.registries' : 'docker.host.registries';
|
||||
|
||||
try {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
|
||||
</file-upload-form>
|
||||
|
||||
<git-form ng-if="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
||||
<git-form ng-if="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)" hide-rebuild-info="true"></git-form>
|
||||
|
||||
<!-- template -->
|
||||
<div ng-if="$ctrl.state.Method === 'template'">
|
||||
|
||||
@@ -21,4 +21,10 @@
|
||||
</file-upload-description>
|
||||
</file-upload-form>
|
||||
|
||||
<git-form ng-if="$ctrl.state.Method === 'repository'" deploy-method="kubernetes" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
||||
<git-form
|
||||
ng-if="$ctrl.state.Method === 'repository'"
|
||||
deploy-method="kubernetes"
|
||||
model="$ctrl.formValues"
|
||||
on-change="($ctrl.onChangeFormValues)"
|
||||
hide-rebuild-info="true"
|
||||
></git-form>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@ angular.module('portainer.kubernetes').component('kubeServicesView', {
|
||||
bindings: {
|
||||
formValues: '=',
|
||||
isEdit: '<',
|
||||
namespaces: '<',
|
||||
loadbalancerEnabled: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -23,6 +23,8 @@ const _KubernetesService = Object.freeze({
|
||||
Note: '',
|
||||
Ingress: false,
|
||||
Selector: {},
|
||||
nodePortError: false,
|
||||
servicePortError: false,
|
||||
});
|
||||
|
||||
export class KubernetesService {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ export const componentsModule = angular
|
||||
.component(
|
||||
'ingressClassDatatable',
|
||||
r2a(IngressClassDatatable, [
|
||||
'onChangeAvailability',
|
||||
'onChangeControllers',
|
||||
'description',
|
||||
'ingressControllers',
|
||||
'allowNoneIngressClass',
|
||||
'isLoading',
|
||||
'noIngressControllerLabel',
|
||||
'view',
|
||||
])
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?'
|
||||
|
||||
@@ -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't exist
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
{`${path.ServiceName}:${path.Port}`}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
@@ -1334,7 +1339,12 @@
|
||||
</div>
|
||||
</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
|
||||
namespaces="ctrl.allNamespaces"
|
||||
form-values="ctrl.formValues"
|
||||
is-edit="ctrl.state.isEdit"
|
||||
loadbalancer-enabled="ctrl.publishViaLoadBalancerEnabled()"
|
||||
></kube-services-view>
|
||||
<!-- kubernetes services options -->
|
||||
</div>
|
||||
|
||||
@@ -1349,7 +1359,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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 "none"'"
|
||||
tooltip="'This allows users setting up ingresses to select "none" 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"
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -93,17 +93,18 @@
|
||||
<div class="form-group flex flex-row !mb-0">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left"> Memory limit (MB) </label>
|
||||
<div class="col-xs-6">
|
||||
<slider
|
||||
model="$ctrl.formValues.MemoryLimit"
|
||||
floor="$ctrl.defaults.MemoryLimit"
|
||||
ceil="$ctrl.state.sliderMaxMemory"
|
||||
<por-slider
|
||||
min="$ctrl.defaults.MemoryLimit"
|
||||
max="$ctrl.state.sliderMaxMemory"
|
||||
step="128"
|
||||
ng-if="$ctrl.state.sliderMaxMemory"
|
||||
value="$ctrl.formValues.MemoryLimit"
|
||||
on-change="($ctrl.handleMemoryLimitChange)"
|
||||
visible-tooltip="true"
|
||||
data-cy="k8sNamespaceCreate-memoryLimitSlider"
|
||||
>
|
||||
</slider>
|
||||
></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2 vertical-center">
|
||||
<div class="col-sm-2 vertical-center pt-6">
|
||||
<input
|
||||
name="memory_limit"
|
||||
type="number"
|
||||
@@ -138,16 +139,16 @@
|
||||
<div class="form-group flex flex-row">
|
||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left"> CPU limit </label>
|
||||
<div class="col-xs-8">
|
||||
<slider
|
||||
model="$ctrl.formValues.CpuLimit"
|
||||
floor="$ctrl.defaults.CpuLimit"
|
||||
ceil="$ctrl.state.sliderMaxCpu"
|
||||
<por-slider
|
||||
min="$ctrl.defaults.CpuLimit"
|
||||
max="$ctrl.state.sliderMaxCpu"
|
||||
step="0.1"
|
||||
precision="2"
|
||||
ng-if="$ctrl.state.sliderMaxCpu"
|
||||
value="$ctrl.formValues.CpuLimit"
|
||||
on-change="($ctrl.handleCpuLimitChange)"
|
||||
data-cy="k8sNamespaceCreate-cpuLimitSlider"
|
||||
>
|
||||
</slider>
|
||||
visible-tooltip="true"
|
||||
></por-slider>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
@@ -185,7 +186,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.'"
|
||||
|
||||
@@ -33,6 +33,8 @@ class KubernetesCreateResourcePoolController {
|
||||
this.onToggleResourceQuota = this.onToggleResourceQuota.bind(this);
|
||||
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
|
||||
this.onRegistriesChange = this.onRegistriesChange.bind(this);
|
||||
this.handleMemoryLimitChange = this.handleMemoryLimitChange.bind(this);
|
||||
this.handleCpuLimitChange = this.handleCpuLimitChange.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
@@ -101,6 +103,18 @@ class KubernetesCreateResourcePoolController {
|
||||
}
|
||||
}
|
||||
|
||||
handleMemoryLimitChange(memoryLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.MemoryLimit = memoryLimit;
|
||||
});
|
||||
}
|
||||
|
||||
handleCpuLimitChange(cpuLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.CpuLimit = cpuLimit;
|
||||
});
|
||||
}
|
||||
|
||||
/* #region CREATE NAMESPACE */
|
||||
createResourcePool() {
|
||||
return this.$async(async () => {
|
||||
@@ -109,7 +123,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) {
|
||||
|
||||
@@ -80,15 +80,18 @@
|
||||
<div class="form-group flex">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left vertical-center"> Memory limit (MB) </label>
|
||||
<div class="col-sm-6">
|
||||
<slider
|
||||
model="ctrl.formValues.MemoryLimit"
|
||||
floor="ctrl.ResourceQuotaDefaults.MemoryLimit"
|
||||
ceil="ctrl.state.sliderMaxMemory"
|
||||
<por-slider
|
||||
min="ctrl.ResourceQuotaDefaults.MemoryLimit"
|
||||
max="ctrl.state.sliderMaxMemory"
|
||||
step="128"
|
||||
ng-if="ctrl.state.sliderMaxMemory"
|
||||
></slider>
|
||||
value="ctrl.formValues.MemoryLimit"
|
||||
on-change="(ctrl.handleMemoryLimitChange)"
|
||||
visible-tooltip="true"
|
||||
data-cy="k8sNamespaceEdit-memoryLimitSlider"
|
||||
></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2 vertical-center">
|
||||
<div class="col-sm-2 vertical-center pt-6">
|
||||
<input
|
||||
name="memory_limit"
|
||||
type="number"
|
||||
@@ -97,6 +100,7 @@
|
||||
class="form-control"
|
||||
ng-model="ctrl.formValues.MemoryLimit"
|
||||
id="memory-limit"
|
||||
data-cy="k8sNamespaceEdit-memoryLimitInput"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -117,14 +121,16 @@
|
||||
<div class="form-group">
|
||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px"> CPU limit </label>
|
||||
<div class="col-sm-8">
|
||||
<slider
|
||||
model="ctrl.formValues.CpuLimit"
|
||||
floor="ctrl.ResourceQuotaDefaults.CpuLimit"
|
||||
ceil="ctrl.state.sliderMaxCpu"
|
||||
<por-slider
|
||||
min="ctrl.ResourceQuotaDefaults.CpuLimit"
|
||||
max="ctrl.state.sliderMaxCpu"
|
||||
step="0.1"
|
||||
precision="2"
|
||||
ng-if="ctrl.state.sliderMaxCpu"
|
||||
></slider>
|
||||
value="ctrl.formValues.CpuLimit"
|
||||
on-change="(ctrl.handleCpuLimitChange)"
|
||||
data-cy="k8sNamespaceEdit-cpuLimitSlider"
|
||||
visible-tooltip="true"
|
||||
></por-slider>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
@@ -161,7 +167,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.'"
|
||||
|
||||
@@ -69,6 +69,8 @@ class KubernetesResourcePoolController {
|
||||
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
|
||||
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
|
||||
this.onRegistriesChange = this.onRegistriesChange.bind(this);
|
||||
this.handleMemoryLimitChange = this.handleMemoryLimitChange.bind(this);
|
||||
this.handleCpuLimitChange = this.handleCpuLimitChange.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
@@ -122,6 +124,18 @@ class KubernetesResourcePoolController {
|
||||
}
|
||||
}
|
||||
|
||||
handleMemoryLimitChange(memoryLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.MemoryLimit = memoryLimit;
|
||||
});
|
||||
}
|
||||
|
||||
handleCpuLimitChange(cpuLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.CpuLimit = cpuLimit;
|
||||
});
|
||||
}
|
||||
|
||||
showEditor() {
|
||||
this.state.showEditorTab = true;
|
||||
this.selectTab(2);
|
||||
@@ -144,7 +158,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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -4,5 +4,6 @@ export const gitFormComposePathField = {
|
||||
deployMethod: '@',
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
isDockerStandalone: '<',
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user