Compare commits

..

51 Commits

Author SHA1 Message Date
James Carppe b2f6f43a25 Update template to include lifecycle policy link (#156) 2024-12-16 11:16:17 +13:00
James Carppe 4ad3d70739 Update bug report template for 2.24.0 (#153) 2024-11-20 13:15:56 +13:00
andres-portainer e6a1c29655 fix(compose): fix support for ECR BE-11392 (#151) 2024-11-18 16:42:53 -03:00
Yajith Dayarathna 333dfe1ebf refactor(edge/update): choose images from registry [BE-10964] (#6)
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2024-11-18 14:11:26 +13:00
andres-portainer c59872553a fix(stacks): pass the registry credentials to Compose stacks BE-11388 (#147)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
2024-11-18 08:39:13 +13:00
andres-portainer 1a39370f5b fix(libstack): add missing private registry credentials BE-11388 (#143) 2024-11-15 17:38:55 -03:00
Oscar Zhou bc44056815 fix(swarm): failed to deploy app template [BE-11385] (#138) 2024-11-15 11:53:22 +13:00
andres-portainer 17c92343e0 fix(compose): avoid leftovers in Run() BE-11381 (#129) 2024-11-13 20:24:20 -03:00
andres-portainer cd6935b07a feat(edgestacks): add a retry period to edge stack deployments BE-11155 (#109)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: LP B <xAt0mZ@users.noreply.github.com>
2024-11-13 20:13:30 -03:00
andres-portainer 47d428f3eb fix(libstack): fix compose run BE-11381 (#126) 2024-11-13 14:38:53 -03:00
LP B 2baae7072f fix(edge/stacks): use default namespace when none is specified in manifest (#124) 2024-11-13 16:30:08 +13:00
andres-portainer 2e9e459aa3 fix(libstack): add a different timeout for WaitForStatus BE-11376 (#120) 2024-11-12 19:31:44 -03:00
andres-portainer 7444e2c1c7 fix(compose): provide the project name for proper validation BE-11375 (#118) 2024-11-12 17:18:40 -03:00
Oscar Zhou d6469eb33d fix(libstack): empty project name [BE-11375] (#116) 2024-11-12 10:20:45 -03:00
Ali a2da6f1827 fix(configmap): create portainer configmap if it doesn't exist [r8s-141] (#113) 2024-11-12 18:23:00 +13:00
Oscar Zhou e6508140f8 version: bump version to 2.24.0 (#102) 2024-11-12 12:13:27 +13:00
andres-portainer a7127bc74f feat(libstack): remove the docker-compose binary BE-10801 (#111)
Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
2024-11-11 19:05:56 -03:00
Malcolm Lockyer 55aa0c0c5d fix(ui): kubernetes create from file page - fix template load failed mistake in ce (#112) 2024-11-12 10:46:37 +13:00
Ali d25de4f459 fix(more-resources): address CE review comments [r8s-103] (#110) 2024-11-12 10:41:43 +13:00
Yajith Dayarathna 6d31f4876a fix(more resources): fix porting and functionality [r8s-103] (#8)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2024-11-12 09:55:30 +13:00
Steven Kang e6577ca269 kubernetes: improved the node view [r8s-47] (#108) 2024-11-12 09:42:14 +13:00
Ali 08d77b4333 fix(namespace): handle no accesses found [r8s-141] (#106) 2024-11-12 09:29:55 +13:00
Ali 1ead121c9b fix(apps): for helm uninstall, ignore manual associated resource deletion [r8s-124] (#103) 2024-11-12 09:03:22 +13:00
LP B ad19b4a421 fix(app): relocate Skip TLS switch next to git repo URL field (#107) 2024-11-11 17:16:37 +01:00
LP B 6bc52dd39c feat(edge): kubernetes WaitForStatus support (#85) 2024-11-11 14:02:20 +01:00
Malcolm Lockyer fd2b00bf3b fix(ui): kubernetes create from file page - fix template load failed message style [R8S-68] (#95) 2024-11-11 12:06:56 +13:00
Ali cd8c6d1ce0 fix(apps): don't delete the 'kubernetes' service or duplicate service names [r8s-124] (#90) 2024-11-11 08:26:56 +13:00
Ali e9fc6d5598 refactor(namespace): migrate namespace access view to react [r8s-141] (#87) 2024-11-11 08:17:20 +13:00
Steven Kang 8ed7cd80cb feat(ui): improve Kubernetes node view [r8s-47] (#84) 2024-11-07 14:10:19 +13:00
Malcolm Lockyer 81322664ea fix(ui): kubernetes create from manifest page misalignments and incorrect loading icon [R8S-68] (#88) 2024-11-07 09:04:24 +13:00
Ali 458d722d47 fix(ui): consistent widget padding [r8s-136] (#82) 2024-11-05 14:25:40 +13:00
Malcolm Lockyer 3c0d25f3bd fix(ui): rename create from manifest to create from file [BE-11335] (#86) 2024-11-05 14:10:08 +13:00
Oscar Zhou ca7e4dd66e fix(edge/async): onboarding agent without predefined group cannot be associated [BE-11281] (#83) 2024-11-05 09:32:25 +13:00
Ali c1316532eb fix(apps): update associated resources on deletion [r8s-124] (#75) 2024-11-01 21:03:49 +13:00
Ali d418784346 fix(rbac): revert rbac detection logic [r8s-137] (#81) 2024-11-01 19:28:23 +13:00
andres-portainer 1061601714 feat(activity-log): set descending timestamps as the default sorting order BE-11343 (#66) 2024-10-31 18:07:26 -03:00
andres-portainer 2f3d4a5511 fix(activity-log): fix broken sorting BE-11342 (#65) 2024-10-31 17:25:38 -03:00
LP B 9ea62bda28 fix(app/image-details): export images to tar (#40) 2024-10-31 17:40:01 +01:00
Steven Kang 94b1d446c0 fix(ingresses): load cluster wide ingresses [r8s-78] (#78) 2024-10-31 13:08:09 +13:00
Ali 6c57a00a65 fix(cluster): UI RBAC alert fix [r8s-138] (#72) 2024-10-31 10:12:56 +13:00
Yajith Dayarathna 8808531cd5 update ci trigger paths for portainer-ee - develop (#68) 2024-10-29 12:23:31 +13:00
andres-portainer 966fca950b fix(oauth): add a timeout to getOAuthToken() BE-11283 (#63) 2024-10-28 17:28:22 -03:00
Yajith Dayarathna e528cff615 bump golang version to 1.23.2 (#60) 2024-10-29 09:02:18 +13:00
andres-portainer 1d037f2f1f feat(websocket): improve websocket code sharing BE-11340 (#61) 2024-10-25 11:21:49 -03:00
James Carppe b2d67795b3 Update bug report template for 2.21.4 (#62) 2024-10-25 15:49:31 +13:00
Ali 959c527be7 refactor(apps): migrate applications view to react [r8s-124] (#28) 2024-10-25 12:28:05 +13:00
andres-portainer cc75167437 fix(swarm): fix service updates BE-11219 (#57) 2024-10-23 18:23:24 -03:00
andres-portainer 3114d4b5c5 fix(security): add initial support for HSTS and CSP BE-11311 (#47) 2024-10-21 13:52:11 -03:00
andres-portainer ac293cda1c feat(database): share more database code between CE and EE BE-11303 (#43) 2024-10-18 10:33:10 -03:00
Ali 7b88975bcb fix(applications): scale resource usage by pod count [r8s-127] (#33) 2024-10-16 14:33:45 +13:00
James Carppe da4b2e3a56 Updated bug report template for 2.23.0 (#32) 2024-10-16 09:23:02 +13:00
266 changed files with 5403 additions and 3511 deletions
+6 -4
View File
@@ -11,6 +11,8 @@ body:
The issue tracker is for reporting bugs. If you have an [idea for a new feature](https://github.com/orgs/portainer/discussions/categories/ideas) or a [general question about Portainer](https://github.com/orgs/portainer/discussions/categories/help) please post in our [GitHub Discussions](https://github.com/orgs/portainer/discussions).
You can also ask for help in our [community Slack channel](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA).
Please note that we only provide support for current versions of Portainer. You can find a list of supported versions in our [lifecycle policy](https://docs.portainer.io/start/lifecycle).
**DO NOT FILE ISSUES FOR GENERAL SUPPORT QUESTIONS**.
@@ -90,10 +92,13 @@ body:
- type: dropdown
attributes:
label: Portainer version
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.24.0'
- '2.23.0'
- '2.22.0'
- '2.21.4'
- '2.21.3'
- '2.21.2'
- '2.21.1'
@@ -114,9 +119,6 @@ body:
- '2.18.1'
- '2.17.1'
- '2.17.0'
- '2.16.2'
- '2.16.1'
- '2.16.0'
validations:
required: true
-16
View File
@@ -1,16 +0,0 @@
name: Label Conflicts
on:
push:
branches:
- develop
- 'release/**'
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: mschilde/auto-label-merge-conflicts@master
with:
CONFLICT_LABEL_NAME: 'has conflicts'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MAX_RETRIES: 10
WAIT_MS: 60000
-19
View File
@@ -1,19 +0,0 @@
name: Automatic Rebase
on:
issue_comment:
types: [created]
jobs:
rebase:
name: Rebase
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
runs-on: ubuntu-latest
steps:
- name: Checkout the latest code
uses: actions/checkout@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
- name: Automatic Rebase
uses: cirrus-actions/rebase@1.4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-28
View File
@@ -1,28 +0,0 @@
name: Close Stale Issues
on:
schedule:
- cron: '0 12 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v8
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Issue Config
days-before-issue-stale: 60
days-before-issue-close: 7
stale-issue-label: 'status/stale'
exempt-all-issue-milestones: true # Do not stale issues in a milestone
exempt-issue-labels: kind/enhancement, kind/style, kind/workaround, kind/refactor, bug/need-confirmation, bug/confirmed, status/discuss
stale-issue-message: 'This issue has been marked as stale as it has not had recent activity, it will be closed if no further activity occurs in the next 7 days. If you believe that it has been incorrectly labelled as stale, leave a comment and the label will be removed.'
close-issue-message: 'Since no further activity has appeared on this issue it will be closed. If you believe that it has been incorrectly closed, leave a comment mentioning `portainer/support` and one of our staff will then review the issue. Note - If it is an old bug report, make sure that it is reproduceable in the latest version of Portainer as it may have already been fixed.'
# Pull Request Config
days-before-pr-stale: -1 # Do not stale pull request
days-before-pr-close: -1 # Do not close pull request
+4 -7
View File
@@ -9,7 +9,7 @@ ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
TAG=local
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
SWAG=go run github.com/swaggo/swag/cmd/swag@v1.16.2
GOTESTSUM=go run gotest.tools/gotestsum@latest
# Don't change anything below this line unless you know what you're doing
@@ -64,9 +64,6 @@ clean: ## Remove all build and download artifacts
.PHONY: test test-client test-server
test: test-server test-client ## Run all tests
test-deps: init-dist
./build/download_docker_compose_binary.sh $(PLATFORM) $(ARCH) $(shell jq -r '.dockerCompose' < "./binary-version.json")
test-client: ## Run client tests
yarn test $(ARGS)
@@ -75,11 +72,11 @@ test-server: ## Run server tests
##@ Dev
.PHONY: dev dev-client dev-server
dev: ## Run both the client and server in development mode
dev: ## Run both the client and server in development mode
make dev-server
make dev-client
dev-client: ## Run the client in development mode
dev-client: ## Run the client in development mode
yarn dev
dev-server: build-server ## Run the server in development mode
@@ -119,7 +116,7 @@ dev-extension: build-server build-client ## Run the extension in development mod
##@ Docs
.PHONY: docs-build docs-validate docs-clean docs-validate-clean
docs-build: init-dist ## Build docs
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
cd api && $(SWAG) init -o "../dist/docs" -ot "yaml" -g ./http/handler/handler.go --parseDependency --parseInternal --parseDepth 2 -p pascalcase --markdownFiles ./
docs-validate: docs-build ## Validate docs
yarn swagger2openapi --warnOnly dist/docs/swagger.yaml -o dist/docs/openapi.yaml
+3 -27
View File
@@ -49,7 +49,6 @@ import (
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/libhelm"
"github.com/portainer/portainer/pkg/libstack"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/gofrs/uuid"
@@ -166,26 +165,6 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
}
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
if err != nil {
log.Fatal().Err(err).Msg("failed creating compose manager")
}
return composeWrapper
}
func initSwarmStackManager(
assetsPath string,
configPath string,
signatureService portainer.DigitalSignatureService,
fileService portainer.FileService,
reverseTunnelService portainer.ReverseTunnelService,
dataStore dataservices.DataStore,
) (portainer.SwarmStackManager, error) {
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore)
}
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
}
@@ -433,14 +412,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
dockerConfigPath := fileService.GetDockerConfigPath()
composeDeployer, err := compose.NewComposeDeployer(*flags.Assets, dockerConfigPath)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing compose deployer")
}
composeDeployer := compose.NewComposeDeployer()
composeStackManager := initComposeStackManager(composeDeployer, proxyManager)
composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore)
swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore)
if err != nil {
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
}
+12 -17
View File
@@ -49,8 +49,8 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
backup["__metadata"] = meta
}
err = connection.View(func(tx *bolt.Tx) error {
err = tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
if err := connection.View(func(tx *bolt.Tx) error {
return tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
bucketName := string(name)
var list []any
version := make(map[string]string)
@@ -84,27 +84,22 @@ func (c *DbConnection) ExportJSON(databasePath string, metadata bool) ([]byte, e
return nil
}
if len(list) > 0 {
if bucketName == "ssl" ||
bucketName == "settings" ||
bucketName == "tunnel_server" {
backup[bucketName] = nil
if len(list) > 0 {
backup[bucketName] = list[0]
}
return nil
if bucketName == "ssl" ||
bucketName == "settings" ||
bucketName == "tunnel_server" {
backup[bucketName] = nil
if len(list) > 0 {
backup[bucketName] = list[0]
}
backup[bucketName] = list
return nil
}
backup[bucketName] = list
return nil
})
return err
})
if err != nil {
}); err != nil {
return []byte("{}"), err
}
@@ -1,10 +1,15 @@
{
"api_key": null,
"customtemplates": null,
"dockerhub": [
{
"Authentication": false,
"Username": ""
}
],
"edge_stack": null,
"edgegroups": null,
"edgejobs": null,
"endpoint_groups": [
{
"AuthorizedTeams": null,
@@ -103,6 +108,9 @@
"UserAccessPolicies": {}
}
],
"extension": null,
"helm_user_repository": null,
"pending_actions": null,
"registries": [
{
"Authentication": true,
@@ -602,7 +610,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.23.0",
"KubectlShellImage": "portainer/kubectl-shell:2.24.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -860,6 +868,8 @@
"UpdatedBy": ""
}
],
"tags": null,
"team_membership": null,
"teams": [
{
"Id": 1,
@@ -932,6 +942,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.23.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
"VERSION": "{\"SchemaVersion\":\"2.24.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+6 -3
View File
@@ -31,15 +31,18 @@ type (
// RegistryCredentials holds the credentials for a Docker registry.
// Used only for EE
RegistryCredentials []RegistryCredentials
// PrePullImage is a flag indicating if the agent should pull the image before deploying the stack.
// PrePullImage is a flag indicating if the agent must pull the image before deploying the stack.
// Used only for EE
PrePullImage bool
// RePullImage is a flag indicating if the agent should pull the image if it is already present on the node.
// RePullImage is a flag indicating if the agent must pull the image if it is already present on the node.
// Used only for EE
RePullImage bool
// RetryDeploy is a flag indicating if the agent should retry to deploy the stack if it fails.
// RetryDeploy is a flag indicating if the agent must retry to deploy the stack if it fails.
// Used only for EE
RetryDeploy bool
// RetryPeriod specifies the duration, in seconds, for which the agent should continue attempting to deploy the stack after a failure
// Used only for EE
RetryPeriod int
// EdgeUpdateID is the ID of the edge update related to this stack.
// Used only for EE
EdgeUpdateID int
+69 -15
View File
@@ -9,27 +9,32 @@ import (
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/proxy"
"github.com/portainer/portainer/api/http/proxy/factory"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/libstack"
"github.com/docker/cli/cli/config/types"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// ComposeStackManager is a wrapper for docker-compose binary
type ComposeStackManager struct {
deployer libstack.Deployer
proxyManager *proxy.Manager
dataStore dataservices.DataStore
}
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
// NewComposeStackManager returns a Compose stack manager
func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager, dataStore dataservices.DataStore) *ComposeStackManager {
return &ComposeStackManager{
deployer: deployer,
proxyManager: proxyManager,
}, nil
dataStore: dataStore,
}
}
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
@@ -60,6 +65,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
@@ -90,6 +96,7 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
},
Remove: options.Remove,
Args: options.Args,
@@ -103,14 +110,15 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return err
}
if proxy != nil {
} else if proxy != nil {
defer proxy.Close()
}
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.Options{
WorkingDir: "",
Host: url,
err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{
Options: libstack.Options{
WorkingDir: "",
Host: url,
},
})
return errors.Wrap(err, "failed to remove a stack")
@@ -118,12 +126,11 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
// Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file,
// but does not start containers based on those images.
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error {
url, proxy, err := manager.fetchEndpointProxy(endpoint)
if err != nil {
return err
}
if proxy != nil {
} else if proxy != nil {
defer proxy.Close()
}
@@ -138,6 +145,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S
EnvFilePath: envFilePath,
Host: url,
ProjectName: stack.Name,
Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries),
})
return errors.Wrap(err, "failed to pull images of the stack")
}
@@ -176,16 +184,16 @@ func createEnvFile(stack *portainer.Stack) (string, error) {
// Copy from default .env file
defaultEnvPath := path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env")
if err = copyDefaultEnvFile(envfile, defaultEnvPath); err != nil {
if err := copyDefaultEnvFile(envfile, defaultEnvPath); err != nil {
return "", err
}
// Copy from stack env vars
if err = copyConfigEnvVars(envfile, stack.Env); err != nil {
if err := copyConfigEnvVars(envfile, stack.Env); err != nil {
return "", err
}
return "stack.env", nil
return envFilePath, nil
}
// copyDefaultEnvFile copies the default .env file if it exists to the provided writer
@@ -217,3 +225,49 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error {
}
return nil
}
func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig {
var authConfigs []types.AuthConfig
for _, r := range registries {
ac := types.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.URL,
}
if r.Authentication {
var err error
ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r)
if err != nil {
continue
}
}
authConfigs = append(authConfigs, ac)
}
return authConfigs
}
func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) {
if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
return "", "", err
}
username, password, err := registryutils.GetRegEffectiveCredential(registry)
if err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
}
return username, password, err
}
+2 -9
View File
@@ -42,20 +42,13 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
}
func Test_UpAndDown(t *testing.T) {
testhelpers.IntegrationTest(t)
stack, endpoint := setup(t)
deployer, err := compose.NewComposeDeployer("", "")
if err != nil {
t.Fatal(err)
}
deployer := compose.NewComposeDeployer()
w, err := NewComposeStackManager(deployer, nil)
if err != nil {
t.Fatalf("Failed creating manager: %s", err)
}
w := NewComposeStackManager(deployer, nil, nil)
ctx := context.TODO()
+3 -2
View File
@@ -4,6 +4,7 @@ import (
"io"
"os"
"path"
"path/filepath"
"testing"
portainer "github.com/portainer/portainer/api"
@@ -53,7 +54,7 @@ func Test_createEnvFile(t *testing.T) {
result, _ := createEnvFile(tt.stack)
if tt.expected != "" {
assert.Equal(t, "stack.env", result)
assert.Equal(t, filepath.Join(tt.stack.ProjectPath, "stack.env"), result)
f, _ := os.Open(path.Join(dir, "stack.env"))
content, _ := io.ReadAll(f)
@@ -77,7 +78,7 @@ func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
},
}
result, err := createEnvFile(stack)
assert.Equal(t, "stack.env", result)
assert.Equal(t, filepath.Join(stack.ProjectPath, "stack.env"), result)
assert.NoError(t, err)
assert.FileExists(t, path.Join(dir, "stack.env"))
f, _ := os.Open(path.Join(dir, "stack.env"))
+9 -29
View File
@@ -11,7 +11,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/rs/zerolog/log"
@@ -46,8 +45,7 @@ func NewSwarmStackManager(
dataStore: datastore,
}
err := manager.updateDockerCLIConfiguration(manager.configPath)
if err != nil {
if err := manager.updateDockerCLIConfiguration(manager.configPath); err != nil {
return nil, err
}
@@ -63,33 +61,14 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
for _, registry := range registries {
if registry.Authentication {
err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
username, password, err := getEffectiveRegUsernamePassword(manager.dataStore, &registry)
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to validate registry token. Skip logging with this registry.")
continue
}
username, password, err := registryutils.GetRegEffectiveCredential(&registry)
if err != nil {
log.
Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to get effective credential. Skip logging with this registry.")
continue
}
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
if err != nil {
log.
Warn().
if err := runCommandAndCaptureStdErr(command, registryArgs, nil, ""); err != nil {
log.Warn().
Err(err).
Str("RegistryName", registry.Name).
Msg("Failed to login.")
@@ -155,6 +134,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
@@ -167,8 +147,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor
cmd.Env = append(cmd.Env, env...)
}
err := cmd.Run()
if err != nil {
if err := cmd.Run(); err != nil {
return errors.New(stderr.String())
}
@@ -192,6 +171,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
if err != nil {
return "", nil, err
}
endpointURL = "tcp://" + tunnelAddr
}
@@ -216,6 +196,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, config
func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error {
configFilePath := path.Join(configPath, "config.json")
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
if err != nil {
return err
@@ -246,8 +227,7 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
return make(map[string]any), nil
}
err = json.Unmarshal(raw, &config)
if err != nil {
if err := json.Unmarshal(raw, &config); err != nil {
return nil, err
}
@@ -26,11 +26,10 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
}
var edgeStack *portainer.EdgeStack
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData.ID, r)
return err
})
if err != nil {
}); err != nil {
switch {
case httperrors.IsInvalidPayloadError(err):
return httperror.BadRequest("Invalid payload", err)
@@ -57,17 +57,15 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
}
var payload updateEdgeStackPayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
var stack *portainer.EdgeStack
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
stack, err = handler.updateEdgeStack(tx, portainer.EdgeStackID(stackID), payload)
return err
})
if err != nil {
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
return httpErr
@@ -122,14 +120,12 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
stack.EdgeGroups = groupsIds
if payload.UpdateVersion {
err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds)
if err != nil {
if err := handler.updateStackVersion(stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
return nil, httperror.InternalServerError("Unable to update stack version", err)
}
}
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
if err != nil {
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
@@ -160,8 +156,7 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
delete(relation.EdgeStacks, edgeStackID)
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
}
@@ -181,8 +176,7 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
relation.EdgeStacks[edgeStackID] = true
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
if err != nil {
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
}
}
@@ -88,13 +88,11 @@ func (handler *Handler) updateRelations(w http.ResponseWriter, r *http.Request)
}
if updateRelations {
err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
if err := tx.Endpoint().UpdateEndpoint(endpoint.ID, endpoint); err != nil {
return errors.WithMessage(err, "Unable to update environment")
}
err = handler.updateEdgeRelations(tx, endpoint)
if err != nil {
if err := handler.updateEdgeRelations(tx, endpoint); err != nil {
return errors.WithMessage(err, "Unable to update environment relations")
}
}
@@ -17,7 +17,16 @@ func (handler *Handler) updateEdgeRelations(tx dataservices.DataStoreTx, endpoin
relation, err := tx.EndpointRelation().EndpointRelation(endpoint.ID)
if err != nil {
return errors.WithMessage(err, "Unable to find environment relation inside the database")
if !tx.IsErrObjectNotFound(err) {
return errors.WithMessage(err, "Unable to retrieve environment relation inside the database")
}
relation = &portainer.EndpointRelation{
EndpointID: endpoint.ID,
}
if err := tx.EndpointRelation().Create(relation); err != nil {
return errors.WithMessage(err, "Unable to create environment relation inside the database")
}
}
endpointGroup, err := tx.EndpointGroup().Read(endpoint.GroupID)
+7 -4
View File
@@ -4,6 +4,9 @@ import (
"net/http"
"strings"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/featureflags"
"github.com/gorilla/handlers"
)
@@ -16,8 +19,10 @@ type Handler struct {
// NewHandler creates a handler to serve static files.
func NewHandler(assetPublicPath string, wasInstanceDisabled func() bool) *Handler {
h := &Handler{
Handler: handlers.CompressHandler(
http.FileServer(http.Dir(assetPublicPath)),
Handler: security.MWSecureHeaders(
handlers.CompressHandler(http.FileServer(http.Dir(assetPublicPath))),
featureflags.IsEnabled("hsts"),
featureflags.IsEnabled("csp"),
),
wasInstanceDisabled: wasInstanceDisabled,
}
@@ -53,7 +58,5 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
w.Header().Add("X-XSS-Protection", "1; mode=block")
w.Header().Add("X-Content-Type-Options", "nosniff")
handler.Handler.ServeHTTP(w, r)
}
+1 -1
View File
@@ -83,7 +83,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.23.0
// @version 2.24.0
// @description.markdown api-description.md
// @termsOfService
@@ -3,7 +3,9 @@ package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
@@ -43,3 +45,39 @@ func (handler *Handler) getAllKubernetesClusterRoleBindings(w http.ResponseWrite
return response.JSON(w, clusterrolebindings)
}
// @id DeleteClusterRoleBindings
// @summary Delete cluster role bindings
// @description Delete the provided list of cluster role bindings.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sClusterRoleBindingDeleteRequests true "A list of cluster role bindings to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific cluster role binding."
// @failure 500 "Server error occurred while attempting to delete cluster role bindings."
// @router /kubernetes/{id}/cluster_role_bindings/delete [POST]
func (handler *Handler) deleteClusterRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sClusterRoleBindingDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteClusterRoleBindings(payload)
if err != nil {
return httperror.InternalServerError("Failed to delete cluster role bindings", err)
}
return response.Empty(w)
}
@@ -3,7 +3,9 @@ package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
@@ -43,3 +45,39 @@ func (handler *Handler) getAllKubernetesClusterRoles(w http.ResponseWriter, r *h
return response.JSON(w, clusterroles)
}
// @id DeleteClusterRoles
// @summary Delete cluster roles
// @description Delete the provided list of cluster roles.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sClusterRoleDeleteRequests true "A list of cluster roles to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific cluster role."
// @failure 500 "Server error occurred while attempting to delete cluster roles."
// @router /kubernetes/{id}/cluster_roles/delete [POST]
func (handler *Handler) deleteClusterRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sClusterRoleDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteClusterRoles(payload)
if err != nil {
return httperror.InternalServerError("Failed to delete cluster roles", err)
}
return response.Empty(w)
}
+12 -3
View File
@@ -56,7 +56,9 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet)
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
endpointRouter.Handle("/configmaps", httperror.LoggerHandler(h.GetAllKubernetesConfigMaps)).Methods(http.MethodGet)
endpointRouter.Handle("/configmaps/count", httperror.LoggerHandler(h.getAllKubernetesConfigMapsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/dashboard", httperror.LoggerHandler(h.getKubernetesDashboard)).Methods(http.MethodGet)
@@ -72,15 +74,12 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/ingresses/delete", httperror.LoggerHandler(h.deleteKubernetesIngresses)).Methods(http.MethodPost)
endpointRouter.Handle("/ingresses", httperror.LoggerHandler(h.GetAllKubernetesClusterIngresses)).Methods(http.MethodGet)
endpointRouter.Handle("/ingresses/count", httperror.LoggerHandler(h.getAllKubernetesClusterIngressesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet)
endpointRouter.Handle("/services", httperror.LoggerHandler(h.GetAllKubernetesServices)).Methods(http.MethodGet)
endpointRouter.Handle("/services/count", httperror.LoggerHandler(h.getAllKubernetesServicesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/secrets", httperror.LoggerHandler(h.GetAllKubernetesSecrets)).Methods(http.MethodGet)
endpointRouter.Handle("/secrets/count", httperror.LoggerHandler(h.getAllKubernetesSecretsCount)).Methods(http.MethodGet)
endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost)
endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet)
endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet)
endpointRouter.Handle("/role_bindings", httperror.LoggerHandler(h.getAllKubernetesRoleBindings)).Methods(http.MethodGet)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete)
@@ -89,6 +88,16 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet)
endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet)
endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet)
endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet)
endpointRouter.Handle("/service_accounts/delete", httperror.LoggerHandler(h.deleteKubernetesServiceAccounts)).Methods(http.MethodPost)
endpointRouter.Handle("/roles", httperror.LoggerHandler(h.getAllKubernetesRoles)).Methods(http.MethodGet)
endpointRouter.Handle("/roles/delete", httperror.LoggerHandler(h.deleteRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/role_bindings", httperror.LoggerHandler(h.getAllKubernetesRoleBindings)).Methods(http.MethodGet)
endpointRouter.Handle("/role_bindings/delete", httperror.LoggerHandler(h.deleteRoleBindings)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_roles", httperror.LoggerHandler(h.getAllKubernetesClusterRoles)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_roles/delete", httperror.LoggerHandler(h.deleteClusterRoles)).Methods(http.MethodPost)
endpointRouter.Handle("/cluster_role_bindings", httperror.LoggerHandler(h.getAllKubernetesClusterRoleBindings)).Methods(http.MethodGet)
endpointRouter.Handle("/cluster_role_bindings/delete", httperror.LoggerHandler(h.deleteClusterRoleBindings)).Methods(http.MethodPost)
// namespaces
// in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?)
@@ -3,7 +3,9 @@ package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
@@ -38,3 +40,38 @@ func (handler *Handler) getAllKubernetesRoleBindings(w http.ResponseWriter, r *h
return response.JSON(w, rolebindings)
}
// @id DeleteRoleBindings
// @summary Delete role bindings
// @description Delete the provided list of role bindings.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sRoleBindingDeleteRequests true "A map where the key is the namespace and the value is an array of role bindings to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific role binding."
// @failure 500 "Server error occurred while attempting to delete role bindings."
// @router /kubernetes/{id}/role_bindings/delete [POST]
func (h *Handler) deleteRoleBindings(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sRoleBindingDeleteRequests
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := h.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
if err := cli.DeleteRoleBindings(payload); err != nil {
return httperror.InternalServerError("Failed to delete role bindings", err)
}
return response.Empty(w)
}
+38
View File
@@ -3,7 +3,9 @@ package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
@@ -38,3 +40,39 @@ func (handler *Handler) getAllKubernetesRoles(w http.ResponseWriter, r *http.Req
return response.JSON(w, roles)
}
// @id DeleteRoles
// @summary Delete roles
// @description Delete the provided list of roles.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sRoleDeleteRequests true "A map where the key is the namespace and the value is an array of roles to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific role."
// @failure 500 "Server error occurred while attempting to delete roles."
// @router /kubernetes/{id}/roles/delete [POST]
func (h *Handler) deleteRoles(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sRoleDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := h.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteRoles(payload)
if err != nil {
return httperror.InternalServerError("Failed to delete roles", err)
}
return response.Empty(w)
}
@@ -3,7 +3,9 @@ package kubernetes
import (
"net/http"
models "github.com/portainer/portainer/api/http/models/kubernetes"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
@@ -38,3 +40,39 @@ func (handler *Handler) getAllKubernetesServiceAccounts(w http.ResponseWriter, r
return response.JSON(w, serviceAccounts)
}
// @id DeleteServiceAccounts
// @summary Delete service accounts
// @description Delete the provided list of service accounts.
// @description **Access policy**: Authenticated user.
// @tags kubernetes
// @security ApiKeyAuth || jwt
// @accept json
// @param id path int true "Environment identifier"
// @param payload body models.K8sServiceAccountDeleteRequests true "A map where the key is the namespace and the value is an array of service accounts to delete"
// @success 204 "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific service account."
// @failure 500 "Server error occurred while attempting to delete service accounts."
// @router /kubernetes/{id}/service_accounts/delete [POST]
func (handler *Handler) deleteKubernetesServiceAccounts(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload models.K8sServiceAccountDeleteRequests
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
cli, handlerErr := handler.getProxyKubeClient(r)
if handlerErr != nil {
return handlerErr
}
err = cli.DeleteServiceAccounts(payload)
if err != nil {
return httperror.InternalServerError("Unable to delete service accounts", err)
}
return response.Empty(w)
}
+7 -12
View File
@@ -56,8 +56,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
}
var payload stackMigratePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
@@ -79,8 +78,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
return httperror.InternalServerError("Unable to find an endpoint with the specified identifier inside the database", err)
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access endpoint", err)
}
@@ -156,14 +154,12 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
newName := stack.Name
stack.Name = oldName
err = handler.deleteStack(securityContext.UserID, stack, endpoint)
if err != nil {
if err := handler.deleteStack(securityContext.UserID, stack, endpoint); err != nil {
return httperror.InternalServerError(err.Error(), err)
}
stack.Name = newName
err = handler.DataStore.Stack().Update(stack.ID, stack)
if err != nil {
if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
@@ -210,10 +206,10 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St
}
// Deploy the stack
err = composeDeploymentConfig.Deploy()
if err != nil {
if err := composeDeploymentConfig.Deploy(); err != nil {
return httperror.InternalServerError(err.Error(), err)
}
return nil
}
@@ -237,8 +233,7 @@ func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stac
}
// Deploy the stack
err = swarmDeploymentConfig.Deploy()
if err != nil {
if err := swarmDeploymentConfig.Deploy(); err != nil {
return httperror.InternalServerError(err.Error(), err)
}
@@ -197,17 +197,14 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pul
switch stack.Type {
case portainer.DockerSwarmStack:
prune := false
if stack.Option != nil {
prune = stack.Option.Prune
}
// Create swarm deployment config
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
prune := stack.Option != nil && stack.Option.Prune
deploymentConfiger, err = deployments.CreateSwarmStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, prune, pullImage)
if err != nil {
return httperror.InternalServerError(err.Error(), err)
+3 -14
View File
@@ -5,10 +5,8 @@ import (
"net/http"
"time"
"github.com/portainer/portainer/api/http/security"
"github.com/rs/zerolog/log"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -76,14 +74,6 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request)
}
func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
r.Header.Del("Origin")
if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
@@ -98,14 +88,13 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque
}
defer websocketConn.Close()
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
return hijackAttachStartOperation(websocketConn, params.endpoint, params.ID)
}
func hijackAttachStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
attachID string,
token string,
) error {
conn, err := initDial(endpoint)
if err != nil {
@@ -127,7 +116,7 @@ func hijackAttachStartOperation(
return err
}
return hijackRequest(websocketConn, conn, attachStartRequest, token)
return ws.HijackRequest(websocketConn, conn, attachStartRequest)
}
func createAttachStartRequest(attachID string) (*http.Request, error) {
+3 -13
View File
@@ -5,13 +5,12 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/asaskevich/govalidator"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
@@ -79,14 +78,6 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
}
func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
log.Warn().
Err(err).
Msg("unable to retrieve user details from authentication token")
return err
}
r.Header.Del("Origin")
if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
@@ -102,14 +93,13 @@ func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request
defer websocketConn.Close()
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID, tokenData.Token)
return hijackExecStartOperation(websocketConn, params.endpoint, params.ID)
}
func hijackExecStartOperation(
websocketConn *websocket.Conn,
endpoint *portainer.Endpoint,
execID string,
token string,
) error {
conn, err := initDial(endpoint)
if err != nil {
@@ -121,7 +111,7 @@ func hijackExecStartOperation(
return err
}
return hijackRequest(websocketConn, conn, execStartRequest, token)
return ws.HijackRequest(websocketConn, conn, execStartRequest)
}
func createExecStartRequest(execID string) (*http.Request, error) {
+3 -2
View File
@@ -9,6 +9,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/ws"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -136,8 +137,8 @@ func (handler *Handler) hijackPodExecStartOperation(
// errorChan is used to propagate errors from the go routines to the caller.
errorChan := make(chan error, 1)
go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan)
go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan)
go ws.StreamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan)
go ws.StreamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan)
// StartExecProcess is a blocking operation which streams IO to/from pod;
// this must execute in asynchronously, since the websocketConn could return errors (e.g. client disconnects) before
-70
View File
@@ -1,70 +0,0 @@
package websocket
import (
"io"
"unicode/utf8"
"github.com/gorilla/websocket"
)
const readerBufferSize = 2048
func streamFromWebsocketToWriter(websocketConn *websocket.Conn, writer io.Writer, errorChan chan error) {
for {
_, in, err := websocketConn.ReadMessage()
if err != nil {
errorChan <- err
break
}
_, err = writer.Write(in)
if err != nil {
errorChan <- err
break
}
}
}
func streamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) {
out := make([]byte, readerBufferSize)
for {
n, err := reader.Read(out)
if err != nil {
errorChan <- err
break
}
processedOutput := validString(string(out[:n]))
err = websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput))
if err != nil {
errorChan <- err
break
}
}
}
func validString(s string) string {
if utf8.ValidString(s) {
return s
}
v := make([]rune, 0, len(s))
for i, r := range s {
if r == utf8.RuneError {
_, size := utf8.DecodeRuneInString(s[i:])
if size == 1 {
continue
}
}
v = append(v, r)
}
return string(v)
}
+31 -29
View File
@@ -3,39 +3,41 @@ package kubernetes
import (
"time"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
)
type K8sApplication struct {
ID string `json:"Id"`
Name string `json:"Name"`
Image string `json:"Image"`
Containers []interface{} `json:"Containers,omitempty"`
Services []corev1.Service `json:"Services"`
CreationDate time.Time `json:"CreationDate"`
ApplicationOwner string `json:"ApplicationOwner,omitempty"`
StackName string `json:"StackName,omitempty"`
ResourcePool string `json:"ResourcePool"`
ApplicationType string `json:"ApplicationType"`
Metadata *Metadata `json:"Metadata,omitempty"`
Status string `json:"Status"`
TotalPodsCount int `json:"TotalPodsCount"`
RunningPodsCount int `json:"RunningPodsCount"`
DeploymentType string `json:"DeploymentType"`
Pods []Pod `json:"Pods,omitempty"`
Configurations []Configuration `json:"Configurations,omitempty"`
LoadBalancerIPAddress string `json:"LoadBalancerIPAddress,omitempty"`
PublishedPorts []PublishedPort `json:"PublishedPorts,omitempty"`
Namespace string `json:"Namespace,omitempty"`
UID string `json:"Uid,omitempty"`
StackID string `json:"StackId,omitempty"`
ServiceID string `json:"ServiceId,omitempty"`
ServiceName string `json:"ServiceName,omitempty"`
ServiceType string `json:"ServiceType,omitempty"`
Kind string `json:"Kind,omitempty"`
MatchLabels map[string]string `json:"MatchLabels,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
ID string `json:"Id"`
Name string `json:"Name"`
Image string `json:"Image"`
Containers []interface{} `json:"Containers,omitempty"`
Services []corev1.Service `json:"Services"`
CreationDate time.Time `json:"CreationDate"`
ApplicationOwner string `json:"ApplicationOwner,omitempty"`
StackName string `json:"StackName,omitempty"`
ResourcePool string `json:"ResourcePool"`
ApplicationType string `json:"ApplicationType"`
Metadata *Metadata `json:"Metadata,omitempty"`
Status string `json:"Status"`
TotalPodsCount int `json:"TotalPodsCount"`
RunningPodsCount int `json:"RunningPodsCount"`
DeploymentType string `json:"DeploymentType"`
Pods []Pod `json:"Pods,omitempty"`
Configurations []Configuration `json:"Configurations,omitempty"`
LoadBalancerIPAddress string `json:"LoadBalancerIPAddress,omitempty"`
PublishedPorts []PublishedPort `json:"PublishedPorts,omitempty"`
Namespace string `json:"Namespace,omitempty"`
UID string `json:"Uid,omitempty"`
StackID string `json:"StackId,omitempty"`
ServiceID string `json:"ServiceId,omitempty"`
ServiceName string `json:"ServiceName,omitempty"`
ServiceType string `json:"ServiceType,omitempty"`
Kind string `json:"Kind,omitempty"`
MatchLabels map[string]string `json:"MatchLabels,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
}
type Metadata struct {
@@ -1,16 +1,33 @@
package kubernetes
import (
"errors"
"net/http"
"time"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/types"
)
type (
K8sClusterRoleBinding struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
RoleRef rbacv1.RoleRef `json:"roleRef"`
Subjects []rbacv1.Subject `json:"subjects"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
// K8sRoleBindingDeleteRequests slice of cluster role cluster bindings.
K8sClusterRoleBindingDeleteRequests []string
)
func (r K8sClusterRoleBindingDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
return nil
}
+24 -4
View File
@@ -1,8 +1,28 @@
package kubernetes
import "time"
import (
"errors"
"net/http"
"time"
type K8sClusterRole struct {
Name string `json:"name"`
CreationDate time.Time `json:"creationDate"`
"k8s.io/apimachinery/pkg/types"
)
type (
K8sClusterRole struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
K8sClusterRoleDeleteRequests []string
)
func (r K8sClusterRoleDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
return nil
}
@@ -1,17 +1,38 @@
package kubernetes
import (
"errors"
"net/http"
"time"
rbacv1 "k8s.io/api/rbac/v1"
"k8s.io/apimachinery/pkg/types"
)
type (
K8sRoleBinding struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
RoleRef rbacv1.RoleRef `json:"roleRef"`
Subjects []rbacv1.Subject `json:"subjects"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
// K8sRoleBindingDeleteRequests is a mapping of namespace names to a slice of role bindings.
K8sRoleBindingDeleteRequests map[string][]string
)
func (r K8sRoleBindingDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
for ns := range r {
if len(ns) == 0 {
return errors.New("deletion given with empty namespace")
}
}
return nil
}
+32 -5
View File
@@ -1,9 +1,36 @@
package kubernetes
import "time"
import (
"errors"
"net/http"
"time"
type K8sRole struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
"k8s.io/apimachinery/pkg/types"
)
type (
K8sRole struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
// isSystem is true if prefixed with "system:" or exists in the kube-system namespace
// or is one of the portainer roles
IsSystem bool `json:"isSystem"`
}
// K8sRoleDeleteRequests is a mapping of namespace names to a slice of roles.
K8sRoleDeleteRequests map[string][]string
)
func (r K8sRoleDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
for ns := range r {
if len(ns) == 0 {
return errors.New("deletion given with empty namespace")
}
}
return nil
}
+30 -5
View File
@@ -1,9 +1,34 @@
package kubernetes
import "time"
import (
"errors"
"net/http"
"time"
type K8sServiceAccount struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
"k8s.io/apimachinery/pkg/types"
)
type (
K8sServiceAccount struct {
Name string `json:"name"`
UID types.UID `json:"uid"`
Namespace string `json:"namespace"`
CreationDate time.Time `json:"creationDate"`
IsSystem bool `json:"isSystem"`
}
// K8sServiceAcountDeleteRequests is a mapping of namespace names to a slice of service account names.
K8sServiceAccountDeleteRequests map[string][]string
)
func (r K8sServiceAccountDeleteRequests) Validate(request *http.Request) error {
if len(r) == 0 {
return errors.New("missing deletion request list in payload")
}
for ns := range r {
if len(ns) == 0 {
return errors.New("deletion given with empty namespace")
}
}
return nil
}
+17 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/portainer/portainer/api/apikey"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/pkg/featureflags"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/pkg/errors"
@@ -42,6 +43,8 @@ type (
jwtService portainer.JWTService
apiKeyService apikey.APIKeyService
revokedJWT sync.Map
hsts bool
csp bool
}
// RestrictedRequestContext is a data structure containing information
@@ -68,6 +71,8 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
dataStore: dataStore,
jwtService: jwtService,
apiKeyService: apiKeyService,
hsts: featureflags.IsEnabled("hsts"),
csp: featureflags.IsEnabled("csp"),
}
go b.cleanUpExpiredJWT()
@@ -78,7 +83,7 @@ func NewRequestBouncer(dataStore dataservices.DataStore, jwtService portainer.JW
// PublicAccess defines a security check for public API endpoints.
// No authentication is required to access these endpoints.
func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler {
return mwSecureHeaders(h)
return MWSecureHeaders(h, bouncer.hsts, bouncer.csp)
}
// AdminAccess defines a security check for API endpoints that require an authorization check.
@@ -211,7 +216,7 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler
bouncer.CookieAuthLookup,
bouncer.JWTAuthLookup,
}, h)
h = mwSecureHeaders(h)
h = MWSecureHeaders(h, bouncer.hsts, bouncer.csp)
return h
}
@@ -517,10 +522,17 @@ func extractAPIKey(r *http.Request) (string, bool) {
return "", false
}
// mwSecureHeaders provides secure headers middleware for handlers.
func mwSecureHeaders(next http.Handler) http.Handler {
// MWSecureHeaders provides secure headers middleware for handlers.
func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-XSS-Protection", "1; mode=block")
if hsts {
w.Header().Set("Strict-Transport-Security", "max-age=31536000") // 365 days
}
if csp {
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
+18
View File
@@ -0,0 +1,18 @@
package errorlist
import "errors"
// Combine a slice of errors into a single error
// to use this, generate errors by appending to errorList in a loop, then return combine(errorList)
func Combine(errorList []error) error {
if len(errorList) == 0 {
return nil
}
errorMsg := "Multiple errors occurred:"
for _, err := range errorList {
errorMsg += "\n" + err.Error()
}
return errors.New(errorMsg)
}
+25 -21
View File
@@ -14,47 +14,51 @@ func isRegTokenValid(registry *portainer.Registry) (valid bool) {
return registry.AccessToken != "" && registry.AccessTokenExpiry > time.Now().Unix()
}
func doGetRegToken(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
func doGetRegToken(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
accessToken, expiryAt, err := ecrClient.GetAuthorizationToken()
if err != nil {
return
return err
}
registry.AccessToken = *accessToken
registry.AccessTokenExpiry = expiryAt.Unix()
err = dataStore.Registry().Update(registry.ID, registry)
return
return tx.Registry().Update(registry.ID, registry)
}
func parseRegToken(registry *portainer.Registry) (username, password string, err error) {
ecrClient := ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region)
return ecrClient.ParseAuthorizationToken(registry.AccessToken)
return ecr.NewService(registry.Username, registry.Password, registry.Ecr.Region).
ParseAuthorizationToken(registry.AccessToken)
}
func EnsureRegTokenValid(dataStore dataservices.DataStore, registry *portainer.Registry) (err error) {
if registry.Type == portainer.EcrRegistry {
if isRegTokenValid(registry) {
log.Debug().Msg("current ECR token is still valid")
} else {
err = doGetRegToken(dataStore, registry)
if err != nil {
log.Debug().Msg("refresh ECR token")
}
}
func EnsureRegTokenValid(tx dataservices.DataStoreTx, registry *portainer.Registry) error {
if registry.Type != portainer.EcrRegistry {
return nil
}
return
if isRegTokenValid(registry) {
log.Debug().Msg("current ECR token is still valid")
return nil
}
if err := doGetRegToken(tx, registry); err != nil {
log.Debug().Msg("refresh ECR token")
return err
}
return nil
}
func GetRegEffectiveCredential(registry *portainer.Registry) (username, password string, err error) {
username = registry.Username
password = registry.Password
if registry.Type == portainer.EcrRegistry {
username, password, err = parseRegToken(registry)
} else {
username = registry.Username
password = registry.Password
}
return
}
@@ -6,6 +6,8 @@ import (
portainer "github.com/portainer/portainer/api"
)
var _ portainer.ComposeStackManager = &composeStackManager{}
type composeStackManager struct{}
func NewComposeStackManager() *composeStackManager {
@@ -31,6 +33,6 @@ func (manager *composeStackManager) Down(ctx context.Context, stack *portainer.S
return nil
}
func (manager *composeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
func (manager *composeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, options portainer.ComposeOptions) error {
return nil
}
+51 -26
View File
@@ -6,6 +6,7 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
labels "k8s.io/apimachinery/pkg/labels"
@@ -31,20 +32,20 @@ func (kcl *KubeClient) fetchApplications(namespace, nodeName string, withDepende
}
if !withDependencies {
// TODO: make sure not to fetch services in fetchAllApplicationsListResources from this call
pods, replicaSets, deployments, statefulSets, daemonSets, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
pods, replicaSets, deployments, statefulSets, daemonSets, _, _, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil)
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, nil, nil)
}
pods, replicaSets, deployments, statefulSets, daemonSets, services, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services)
return kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
}
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
@@ -62,20 +63,20 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
}
if !withDependencies {
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets(namespace, podListOptions)
if err != nil {
return nil, err
}
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil)
return kcl.convertPodsToApplications(pods, replicaSets, nil, nil, nil, nil, nil)
}
pods, replicaSets, deployments, statefulSets, daemonSets, services, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas, err := kcl.fetchAllApplicationsListResources(namespace, podListOptions)
if err != nil {
return nil, err
}
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services)
applications, err := kcl.convertPodsToApplications(pods, replicaSets, deployments, statefulSets, daemonSets, services, hpas)
if err != nil {
return nil, err
}
@@ -92,7 +93,7 @@ func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string,
}
// convertPodsToApplications processes pods and converts them to applications, ensuring uniqueness by owner reference.
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service) ([]models.K8sApplication, error) {
func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) ([]models.K8sApplication, error) {
applications := []models.K8sApplication{}
processedOwners := make(map[string]struct{})
@@ -105,7 +106,7 @@ func (kcl *KubeClient) convertPodsToApplications(pods []corev1.Pod, replicaSets
processedOwners[ownerUID] = struct{}{}
}
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, true)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, deployments, statefulSets, daemonSets, services, hpas, true)
if err != nil {
return nil, err
}
@@ -133,7 +134,7 @@ func (kcl *KubeClient) GetApplicationsResource(namespace, node string) (models.K
}
for _, pod := range pods.Items {
podResources := calculateResourceUsage(pod)
podResources := calculatePodResourceUsage(pod)
resource.CPURequest += podResources.CPURequest
resource.CPULimit += podResources.CPULimit
resource.MemoryRequest += podResources.MemoryRequest
@@ -150,7 +151,7 @@ func (kcl *KubeClient) GetApplicationNamesFromConfigMap(configMap models.K8sConf
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -167,7 +168,7 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -180,18 +181,23 @@ func (kcl *KubeClient) GetApplicationNamesFromSecret(secret models.K8sSecret, po
}
// ConvertPodToApplication converts a pod to an application, updating owner references if necessary
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, withResource bool) (*models.K8sApplication, error) {
func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []appsv1.ReplicaSet, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler, withResource bool) (*models.K8sApplication, error) {
if isReplicaSetOwner(pod) {
updateOwnerReferenceToDeployment(&pod, replicaSets)
}
application := createApplication(&pod, deployments, statefulSets, daemonSets, services)
application := createApplication(&pod, deployments, statefulSets, daemonSets, services, hpas)
if application.ID == "" && application.Name == "" {
return nil, nil
}
if withResource {
application.Resource = calculateResourceUsage(pod)
podResources := calculatePodResourceUsage(pod)
// multiply by the number of requested pods in the application (not the running count)
application.Resource.CPURequest = podResources.CPURequest * float64(application.TotalPodsCount)
application.Resource.CPULimit = podResources.CPULimit * float64(application.TotalPodsCount)
application.Resource.MemoryRequest = podResources.MemoryRequest * int64(application.TotalPodsCount)
application.Resource.MemoryLimit = podResources.MemoryLimit * int64(application.TotalPodsCount)
}
return &application, nil
@@ -199,7 +205,7 @@ func (kcl *KubeClient) ConvertPodToApplication(pod corev1.Pod, replicaSets []app
// createApplication creates a K8sApplication object from a pod
// it sets the application name, namespace, kind, image, stack id, stack name, and labels
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service) models.K8sApplication {
func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefulSets []appsv1.StatefulSet, daemonSets []appsv1.DaemonSet, services []corev1.Service, hpas []autoscalingv2.HorizontalPodAutoscaler) models.K8sApplication {
kind := "Pod"
name := pod.Name
@@ -319,7 +325,11 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
}
if application.ID != "" && application.Name != "" && len(services) > 0 {
return updateApplicationWithService(application, services)
updateApplicationWithService(&application, services)
}
if application.ID != "" && application.Name != "" && len(hpas) > 0 {
updateApplicationWithHorizontalPodAutoscaler(&application, hpas)
}
return application
@@ -327,21 +337,36 @@ func createApplication(pod *corev1.Pod, deployments []appsv1.Deployment, statefu
// updateApplicationWithService updates the application with the services that match the application's selector match labels
// and are in the same namespace as the application
func updateApplicationWithService(application models.K8sApplication, services []corev1.Service) models.K8sApplication {
func updateApplicationWithService(application *models.K8sApplication, services []corev1.Service) {
for _, service := range services {
serviceSelector := labels.SelectorFromSet(service.Spec.Selector)
if service.Namespace == application.ResourcePool && serviceSelector.Matches(labels.Set(application.MatchLabels)) {
if service.Namespace == application.ResourcePool && !serviceSelector.Empty() && serviceSelector.Matches(labels.Set(application.MatchLabels)) {
application.ServiceType = string(service.Spec.Type)
application.Services = append(application.Services, service)
}
}
return application
}
// calculateResourceUsage calculates the resource usage for a pod in CPU cores and Bytes
func calculateResourceUsage(pod corev1.Pod) models.K8sApplicationResource {
func updateApplicationWithHorizontalPodAutoscaler(application *models.K8sApplication, hpas []autoscalingv2.HorizontalPodAutoscaler) {
for _, hpa := range hpas {
// Check if HPA is in the same namespace as the application
if hpa.Namespace != application.ResourcePool {
continue
}
// Check if the scale target ref matches the application
scaleTargetRef := hpa.Spec.ScaleTargetRef
if scaleTargetRef.Name == application.Name && scaleTargetRef.Kind == application.Kind {
hpaCopy := hpa // Create a local copy
application.HorizontalPodAutoscaler = &hpaCopy
break
}
}
}
// calculatePodResourceUsage calculates the resource usage for a pod in CPU cores and Bytes
func calculatePodResourceUsage(pod corev1.Pod) models.K8sApplicationResource {
resource := models.K8sApplicationResource{}
for _, container := range pod.Spec.Containers {
// CPU cores as a decimal
@@ -385,7 +410,7 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromConfigMap(configMap
for _, pod := range pods {
if pod.Namespace == configMap.Namespace {
if isPodUsingConfigMap(&pod, configMap.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
@@ -411,7 +436,7 @@ func (kcl *KubeClient) GetApplicationConfigurationOwnersFromSecret(secret models
for _, pod := range pods {
if pod.Namespace == secret.Namespace {
if isPodUsingSecret(&pod, secret.Name) {
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSets, nil, nil, nil, nil, nil, false)
if err != nil {
return nil, err
}
+62 -2
View File
@@ -3,10 +3,14 @@ package cli
import (
"context"
"errors"
"strings"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetClusterRoles gets all the clusterRoles for at the cluster level in a k8s endpoint.
@@ -21,7 +25,7 @@ func (kcl *KubeClient) GetClusterRoles() ([]models.K8sClusterRole, error) {
// fetchClusterRoles returns a list of all Roles in the specified namespace.
func (kcl *KubeClient) fetchClusterRoles() ([]models.K8sClusterRole, error) {
clusterRoles, err := kcl.cli.RbacV1().ClusterRoles().List(context.TODO(), metav1.ListOptions{})
clusterRoles, err := kcl.cli.RbacV1().ClusterRoles().List(context.TODO(), meta.ListOptions{})
if err != nil {
return nil, err
}
@@ -39,5 +43,61 @@ func parseClusterRole(clusterRole rbacv1.ClusterRole) models.K8sClusterRole {
return models.K8sClusterRole{
Name: clusterRole.Name,
CreationDate: clusterRole.CreationTimestamp.Time,
UID: clusterRole.UID,
IsSystem: isSystemClusterRole(&clusterRole),
}
}
func (kcl *KubeClient) DeleteClusterRoles(req models.K8sClusterRoleDeleteRequests) error {
var errors []error
for _, name := range req {
client := kcl.cli.RbacV1().ClusterRoles()
clusterRole, err := client.Get(context.Background(), name, meta.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
// this is a more serious error to do with the client so we return right away
return err
}
if isSystemClusterRole(clusterRole) {
log.Warn().Str("role_name", name).Msg("ignoring delete of 'system' cluster role, not allowed")
}
err = client.Delete(context.Background(), name, meta.DeleteOptions{})
if err != nil {
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role")
errors = append(errors, err)
}
}
return errorlist.Combine(errors)
}
func isSystemClusterRole(role *rbacv1.ClusterRole) bool {
if role.Namespace == "kube-system" || role.Namespace == "kube-public" ||
role.Namespace == "kube-node-lease" || role.Namespace == "portainer" {
return true
}
if strings.HasPrefix(role.Name, "system:") {
return true
}
if role.Labels != nil {
if role.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" {
return true
}
}
roles := getPortainerDefaultK8sRoleNames()
for i := range roles {
if role.Name == roles[i] {
return true
}
}
return false
}
@@ -3,9 +3,13 @@ package cli
import (
"context"
"errors"
"strings"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -38,8 +42,70 @@ func (kcl *KubeClient) fetchClusterRoleBindings() ([]models.K8sClusterRoleBindin
func parseClusterRoleBinding(clusterRoleBinding rbacv1.ClusterRoleBinding) models.K8sClusterRoleBinding {
return models.K8sClusterRoleBinding{
Name: clusterRoleBinding.Name,
UID: clusterRoleBinding.UID,
Namespace: clusterRoleBinding.Namespace,
RoleRef: clusterRoleBinding.RoleRef,
Subjects: clusterRoleBinding.Subjects,
CreationDate: clusterRoleBinding.CreationTimestamp.Time,
IsSystem: isSystemClusterRoleBinding(&clusterRoleBinding),
}
}
// DeleteClusterRoleBindings processes a K8sClusterRoleBindingDeleteRequest
// by deleting each cluster role binding in its given namespace. If deleting a specific cluster role binding
// fails, the error is logged and we continue to delete the remaining cluster role bindings.
func (kcl *KubeClient) DeleteClusterRoleBindings(reqs models.K8sClusterRoleBindingDeleteRequests) error {
var errors []error
for _, name := range reqs {
client := kcl.cli.RbacV1().ClusterRoleBindings()
clusterRoleBinding, err := client.Get(context.Background(), name, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
// This is a more serious error to do with the client so we return right away
return err
}
if isSystemClusterRoleBinding(clusterRoleBinding) {
log.Warn().Str("role_name", name).Msg("ignoring delete of 'system' cluster role binding, not allowed")
}
if err := client.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
log.Err(err).Str("role_name", name).Msg("unable to delete the cluster role binding")
errors = append(errors, err)
}
}
return errorlist.Combine(errors)
}
func isSystemClusterRoleBinding(binding *rbacv1.ClusterRoleBinding) bool {
if strings.HasPrefix(binding.Name, "system:") {
return true
}
if binding.Labels != nil {
if binding.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" {
return true
}
}
for _, sub := range binding.Subjects {
if strings.HasPrefix(sub.Name, "system:") {
return true
}
if sub.Namespace == "kube-system" ||
sub.Namespace == "kube-public" ||
sub.Namespace == "kube-node-lease" ||
sub.Namespace == "portainer" {
return true
}
}
return false
}
+1 -1
View File
@@ -102,7 +102,7 @@ func parseConfigMap(configMap *corev1.ConfigMap, withData bool) models.K8sConfig
func (kcl *KubeClient) CombineConfigMapsWithApplications(configMaps []models.K8sConfigMap) ([]models.K8sConfigMap, error) {
updatedConfigMaps := make([]models.K8sConfigMap, len(configMaps))
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineConfigMapsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
+12 -3
View File
@@ -94,7 +94,7 @@ func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo {
Status: namespace.Status,
CreationDate: namespace.CreationTimestamp.Format(time.RFC3339),
NamespaceOwner: namespace.Labels[namespaceOwnerLabel],
IsSystem: isSystemNamespace(*namespace),
IsSystem: isSystemNamespace(namespace),
IsDefault: namespace.Name == defaultNamespace,
}
}
@@ -171,7 +171,7 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1
return namespace, nil
}
func isSystemNamespace(namespace corev1.Namespace) bool {
func isSystemNamespace(namespace *corev1.Namespace) bool {
systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel]
if hasSystemLabel {
return systemLabelValue == "true"
@@ -184,6 +184,15 @@ func isSystemNamespace(namespace corev1.Namespace) bool {
return isSystem
}
func (kcl *KubeClient) isSystemNamespace(namespace string) bool {
ns, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), namespace, metav1.GetOptions{})
if err != nil {
return false
}
return isSystemNamespace(ns)
}
// ToggleSystemState will set a namespace as a system namespace, or remove this state
// if isSystem is true it will set `systemNamespaceLabel` to "true" and false otherwise
// this will skip if namespace is "default" or if the required state is already set
@@ -199,7 +208,7 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
return errors.Wrap(err, "failed fetching namespace object")
}
if isSystemNamespace(*namespace) == isSystem {
if isSystemNamespace(namespace) == isSystem {
return nil
}
+1 -1
View File
@@ -65,7 +65,7 @@ func Test_ToggleSystemState(t *testing.T) {
ns, err := kcl.cli.CoreV1().Namespaces().Get(context.Background(), nsName, metav1.GetOptions{})
assert.NoError(t, err)
assert.Equal(t, test.isSystem, isSystemNamespace(*ns))
assert.Equal(t, test.isSystem, isSystemNamespace(ns))
})
}
})
+17 -11
View File
@@ -11,6 +11,7 @@ import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -172,24 +173,24 @@ func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase corev1.PodPha
}
// fetchAllPodsAndReplicaSets fetches all pods and replica sets across the cluster, i.e. all namespaces
func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, error) {
func (kcl *KubeClient) fetchAllPodsAndReplicaSets(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, false, false)
}
// fetchAllApplicationsListResources fetches all pods, replica sets, stateful sets, and daemon sets across the cluster, i.e. all namespaces
// this is required for the applications list view
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, error) {
func (kcl *KubeClient) fetchAllApplicationsListResources(namespace string, podListOptions metav1.ListOptions) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
return kcl.fetchResourcesWithOwnerReferences(namespace, podListOptions, true, true)
}
// fetchResourcesWithOwnerReferences fetches pods and other resources based on owner references
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, error) {
func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podListOptions metav1.ListOptions, includeStatefulSets, includeDaemonSets bool) ([]corev1.Pod, []appsv1.ReplicaSet, []appsv1.Deployment, []appsv1.StatefulSet, []appsv1.DaemonSet, []corev1.Service, []autoscalingv2.HorizontalPodAutoscaler, error) {
pods, err := kcl.cli.CoreV1().Pods(namespace).List(context.Background(), podListOptions)
if err != nil {
if k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil
return nil, nil, nil, nil, nil, nil, nil, nil
}
return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list pods across the cluster: %w", err)
}
// if replicaSet owner reference exists, fetch the replica sets
@@ -199,12 +200,12 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi
if containsReplicaSetOwnerReference(pods) {
replicaSets, err = kcl.cli.AppsV1().ReplicaSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list replica sets across the cluster: %w", err)
}
deployments, err = kcl.cli.AppsV1().Deployments(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list deployments across the cluster: %w", err)
}
}
@@ -212,7 +213,7 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi
if includeStatefulSets && containsStatefulSetOwnerReference(pods) {
statefulSets, err = kcl.cli.AppsV1().StatefulSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list stateful sets across the cluster: %w", err)
}
}
@@ -220,16 +221,21 @@ func (kcl *KubeClient) fetchResourcesWithOwnerReferences(namespace string, podLi
if includeDaemonSets && containsDaemonSetOwnerReference(pods) {
daemonSets, err = kcl.cli.AppsV1().DaemonSets(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list daemon sets across the cluster: %w", err)
}
}
services, err := kcl.cli.CoreV1().Services(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list services across the cluster: %w", err)
}
return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, nil
hpas, err := kcl.cli.AutoscalingV2().HorizontalPodAutoscalers(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil && !k8serrors.IsNotFound(err) {
return nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("unable to list horizontal pod autoscalers across the cluster: %w", err)
}
return pods.Items, replicaSets.Items, deployments.Items, statefulSets.Items, daemonSets.Items, services.Items, hpas.Items, nil
}
// isPodUsingConfigMap checks if a pod is using a specific ConfigMap
+188 -5
View File
@@ -2,17 +2,200 @@ package cli
import (
"context"
"time"
"github.com/portainer/portainer/api/internal/randomstring"
"github.com/rs/zerolog/log"
authv1 "k8s.io/api/authorization/v1"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
authv1types "k8s.io/client-go/kubernetes/typed/authorization/v1"
corev1types "k8s.io/client-go/kubernetes/typed/core/v1"
rbacv1types "k8s.io/client-go/kubernetes/typed/rbac/v1"
)
// IsRBACEnabled checks if RBAC is enabled in the current Kubernetes cluster by listing cluster roles.
// if the cluster roles can be listed, RBAC is enabled.
// otherwise, RBAC is not enabled.
const maxRetries = 5
// IsRBACEnabled checks if RBAC is enabled in the cluster by creating a service account, then checking it's access to a resourcequota before and after setting a cluster role and cluster role binding
func (kcl *KubeClient) IsRBACEnabled() (bool, error) {
_, err := kcl.cli.RbacV1().ClusterRoles().List(context.TODO(), metav1.ListOptions{})
namespace := "default"
verb := "list"
resource := "resourcequotas"
saClient := kcl.cli.CoreV1().ServiceAccounts(namespace)
uniqueString := randomstring.RandomString(4) // Append a unique string to resource names, in case they already exist
saName := "portainer-rbac-test-sa-" + uniqueString
if err := createServiceAccount(saClient, saName, namespace); err != nil {
log.Error().Err(err).Msg("Error creating service account")
return false, err
}
defer deleteServiceAccount(saClient, saName)
accessReviewClient := kcl.cli.AuthorizationV1().LocalSubjectAccessReviews(namespace)
allowed, err := checkServiceAccountAccess(accessReviewClient, saName, verb, resource, namespace)
if err != nil {
log.Error().Err(err).Msg("Error checking service account access")
return false, err
}
// If the service account with no authorizations is allowed, RBAC must be disabled
if allowed {
return false, nil
}
return true, nil
// Otherwise give the service account an rbac authorisation and check again
roleClient := kcl.cli.RbacV1().Roles(namespace)
roleName := "portainer-rbac-test-role-" + uniqueString
if err := createRole(roleClient, roleName, verb, resource, namespace); err != nil {
log.Error().Err(err).Msg("Error creating role")
return false, err
}
defer deleteRole(roleClient, roleName)
roleBindingClient := kcl.cli.RbacV1().RoleBindings(namespace)
roleBindingName := "portainer-rbac-test-role-binding-" + uniqueString
if err := createRoleBinding(roleBindingClient, roleBindingName, roleName, saName, namespace); err != nil {
log.Error().Err(err).Msg("Error creating role binding")
return false, err
}
defer deleteRoleBinding(roleBindingClient, roleBindingName)
allowed, err = checkServiceAccountAccess(accessReviewClient, saName, verb, resource, namespace)
if err != nil {
log.Error().Err(err).Msg("Error checking service account access with authorizations added")
return false, err
}
// If the service account allowed to list resource quotas after given rbac role, then RBAC is enabled
return allowed, nil
}
func createServiceAccount(saClient corev1types.ServiceAccountInterface, name string, namespace string) error {
serviceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
}
_, err := saClient.Create(context.Background(), serviceAccount, metav1.CreateOptions{})
return err
}
func deleteServiceAccount(saClient corev1types.ServiceAccountInterface, name string) {
if err := saClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
log.Error().Err(err).Msg("Error deleting service account: " + name)
}
}
func createRole(roleClient rbacv1types.RoleInterface, name string, verb string, resource string, namespace string) error {
role := &rbacv1.Role{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
Rules: []rbacv1.PolicyRule{
{
APIGroups: []string{""},
Verbs: []string{verb},
Resources: []string{resource},
},
},
}
_, err := roleClient.Create(context.Background(), role, metav1.CreateOptions{})
return err
}
func deleteRole(roleClient rbacv1types.RoleInterface, name string) {
if err := roleClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
log.Error().Err(err).Msg("Error deleting role: " + name)
}
}
func createRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, clusterRoleBindingName string, roleName string, serviceAccountName string, namespace string) error {
clusterRoleBinding := &rbacv1.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: clusterRoleBindingName,
},
Subjects: []rbacv1.Subject{
{
Kind: "ServiceAccount",
Name: serviceAccountName,
Namespace: namespace,
},
},
RoleRef: rbacv1.RoleRef{
Kind: "Role",
Name: roleName,
APIGroup: "rbac.authorization.k8s.io",
},
}
roleBinding, err := roleBindingClient.Create(context.Background(), clusterRoleBinding, metav1.CreateOptions{})
if err != nil {
log.Error().Err(err).Msg("Error creating role binding: " + clusterRoleBindingName)
return err
}
// Retry checkRoleBinding a maximum of 5 times with a 100ms wait after each attempt
for range maxRetries {
err = checkRoleBinding(roleBindingClient, roleBinding.Name)
time.Sleep(100 * time.Millisecond) // Wait for 100ms, even if the check passes
if err == nil {
break
}
}
return err
}
func checkRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) error {
if _, err := roleBindingClient.Get(context.Background(), name, metav1.GetOptions{}); err != nil {
log.Error().Err(err).Msg("Error finding rolebinding: " + name)
return err
}
return nil
}
func deleteRoleBinding(roleBindingClient rbacv1types.RoleBindingInterface, name string) {
if err := roleBindingClient.Delete(context.Background(), name, metav1.DeleteOptions{}); err != nil {
log.Error().Err(err).Msg("Error deleting role binding: " + name)
}
}
func checkServiceAccountAccess(accessReviewClient authv1types.LocalSubjectAccessReviewInterface, serviceAccountName string, verb string, resource string, namespace string) (bool, error) {
subjectAccessReview := &authv1.LocalSubjectAccessReview{
ObjectMeta: metav1.ObjectMeta{
Namespace: namespace,
},
Spec: authv1.SubjectAccessReviewSpec{
ResourceAttributes: &authv1.ResourceAttributes{
Namespace: namespace,
Verb: verb,
Resource: resource,
},
User: "system:serviceaccount:default:" + serviceAccountName, // a workaround to be able to use the service account as a user
},
}
result, err := accessReviewClient.Create(context.Background(), subjectAccessReview, metav1.CreateOptions{})
if err != nil {
return false, err
}
return result.Status.Allowed, nil
}
+53 -2
View File
@@ -2,11 +2,15 @@ package cli
import (
"context"
"strings"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
@@ -48,18 +52,20 @@ func (kcl *KubeClient) fetchRoles(namespace string) ([]models.K8sRole, error) {
results := make([]models.K8sRole, 0)
for _, role := range roles.Items {
results = append(results, parseRole(role))
results = append(results, kcl.parseRole(role))
}
return results, nil
}
// parseRole converts a rbacv1.Role object to a models.K8sRole object.
func parseRole(role rbacv1.Role) models.K8sRole {
func (kcl *KubeClient) parseRole(role rbacv1.Role) models.K8sRole {
return models.K8sRole{
Name: role.Name,
UID: role.UID,
Namespace: role.Namespace,
CreationDate: role.CreationTimestamp.Time,
IsSystem: kcl.isSystemRole(&role),
}
}
@@ -108,3 +114,48 @@ func (kcl *KubeClient) upsertPortainerK8sClusterRoles() error {
return nil
}
func getPortainerDefaultK8sRoleNames() []string {
return []string{
string(portainerUserCRName),
}
}
func (kcl *KubeClient) isSystemRole(role *rbacv1.Role) bool {
if strings.HasPrefix(role.Name, "system:") {
return true
}
return kcl.isSystemNamespace(role.Namespace)
}
// DeleteRoles processes a K8sServiceDeleteRequest by deleting each role
// in its given namespace.
func (kcl *KubeClient) DeleteRoles(reqs models.K8sRoleDeleteRequests) error {
var errors []error
for namespace := range reqs {
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().Roles(namespace)
role, err := client.Get(context.Background(), name, v1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
// This is a more serious error to do with the client so we return right away
return err
}
if kcl.isSystemRole(role) {
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role, not allowed")
}
if err := client.Delete(context.TODO(), name, metav1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
}
return errorlist.Combine(errors)
}
+71 -2
View File
@@ -2,10 +2,16 @@ package cli
import (
"context"
"strings"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
"github.com/rs/zerolog/log"
corev1 "k8s.io/api/rbac/v1"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
@@ -47,19 +53,82 @@ func (kcl *KubeClient) fetchRoleBindings(namespace string) ([]models.K8sRoleBind
results := make([]models.K8sRoleBinding, 0)
for _, roleBinding := range roleBindings.Items {
results = append(results, parseRoleBinding(roleBinding))
results = append(results, kcl.parseRoleBinding(roleBinding))
}
return results, nil
}
// parseRoleBinding converts a rbacv1.RoleBinding object to a models.K8sRoleBinding object.
func parseRoleBinding(roleBinding rbacv1.RoleBinding) models.K8sRoleBinding {
func (kcl *KubeClient) parseRoleBinding(roleBinding rbacv1.RoleBinding) models.K8sRoleBinding {
return models.K8sRoleBinding{
Name: roleBinding.Name,
UID: roleBinding.UID,
Namespace: roleBinding.Namespace,
RoleRef: roleBinding.RoleRef,
Subjects: roleBinding.Subjects,
CreationDate: roleBinding.CreationTimestamp.Time,
IsSystem: kcl.isSystemRoleBinding(&roleBinding),
}
}
func (kcl *KubeClient) isSystemRoleBinding(rb *rbacv1.RoleBinding) bool {
if strings.HasPrefix(rb.Name, "system:") {
return true
}
if rb.Labels != nil {
if rb.Labels["kubernetes.io/bootstrapping"] == "rbac-defaults" {
return true
}
}
if rb.RoleRef.Name != "" {
role, err := kcl.getRole(rb.Namespace, rb.RoleRef.Name)
if err != nil {
return false
}
// Linked to a role that is marked a system role
if kcl.isSystemRole(role) {
return true
}
}
return false
}
func (kcl *KubeClient) getRole(namespace, name string) (*corev1.Role, error) {
client := kcl.cli.RbacV1().Roles(namespace)
return client.Get(context.Background(), name, metav1.GetOptions{})
}
// DeleteRoleBindings processes a K8sServiceDeleteRequest by deleting each service
// in its given namespace.
func (kcl *KubeClient) DeleteRoleBindings(reqs models.K8sRoleBindingDeleteRequests) error {
var errors []error
for namespace := range reqs {
for _, name := range reqs[namespace] {
client := kcl.cli.RbacV1().RoleBindings(namespace)
roleBinding, err := client.Get(context.Background(), name, v1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
// This is a more serious error to do with the client so we return right away
return err
}
if kcl.isSystemRoleBinding(roleBinding) {
log.Error().Str("role_name", name).Msg("ignoring delete of 'system' role binding, not allowed")
}
if err := client.Delete(context.Background(), name, v1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
}
return errorlist.Combine(errors)
}
+1 -1
View File
@@ -118,7 +118,7 @@ func parseSecret(secret *corev1.Secret, withData bool) models.K8sSecret {
func (kcl *KubeClient) CombineSecretsWithApplications(secrets []models.K8sSecret) ([]models.K8sSecret, error) {
updatedSecrets := make([]models.K8sSecret, len(secrets))
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineSecretsWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
+1 -1
View File
@@ -174,7 +174,7 @@ func (kcl *KubeClient) UpdateService(namespace string, info models.K8sServiceInf
func (kcl *KubeClient) CombineServicesWithApplications(services []models.K8sServiceInfo) ([]models.K8sServiceInfo, error) {
if containsServiceWithSelector(services) {
updatedServices := make([]models.K8sServiceInfo, len(services))
pods, replicaSets, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
pods, replicaSets, _, _, _, _, _, err := kcl.fetchAllPodsAndReplicaSets("", metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to fetch pods and replica sets. Error: %w", err)
}
+41 -2
View File
@@ -2,9 +2,12 @@ package cli
import (
"context"
"fmt"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/models/kubernetes"
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/portainer/portainer/api/internal/errorlist"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
@@ -50,18 +53,20 @@ func (kcl *KubeClient) fetchServiceAccounts(namespace string) ([]models.K8sServi
results := make([]models.K8sServiceAccount, 0)
for _, serviceAccount := range serviceAccounts.Items {
results = append(results, parseServiceAccount(serviceAccount))
results = append(results, kcl.parseServiceAccount(serviceAccount))
}
return results, nil
}
// parseServiceAccount converts a corev1.ServiceAccount object to a models.K8sServiceAccount object.
func parseServiceAccount(serviceAccount corev1.ServiceAccount) models.K8sServiceAccount {
func (kcl *KubeClient) parseServiceAccount(serviceAccount corev1.ServiceAccount) models.K8sServiceAccount {
return models.K8sServiceAccount{
Name: serviceAccount.Name,
UID: serviceAccount.UID,
Namespace: serviceAccount.Namespace,
CreationDate: serviceAccount.CreationTimestamp.Time,
IsSystem: kcl.isSystemServiceAccount(serviceAccount.Namespace),
}
}
@@ -81,6 +86,40 @@ func (kcl *KubeClient) GetPortainerUserServiceAccount(tokenData *portainer.Token
return serviceAccount, nil
}
func (kcl *KubeClient) isSystemServiceAccount(namespace string) bool {
return kcl.isSystemNamespace(namespace)
}
// DeleteServices processes a K8sServiceDeleteRequest by deleting each service
// in its given namespace.
func (kcl *KubeClient) DeleteServiceAccounts(reqs kubernetes.K8sServiceAccountDeleteRequests) error {
var errors []error
for namespace := range reqs {
for _, serviceName := range reqs[namespace] {
client := kcl.cli.CoreV1().ServiceAccounts(namespace)
sa, err := client.Get(context.Background(), serviceName, metav1.GetOptions{})
if err != nil {
if k8serrors.IsNotFound(err) {
continue
}
return err
}
if kcl.isSystemServiceAccount(sa.Namespace) {
return fmt.Errorf("cannot delete system service account %q", namespace+"/"+serviceName)
}
if err := client.Delete(context.Background(), serviceName, metav1.DeleteOptions{}); err != nil {
errors = append(errors, err)
}
}
}
return errorlist.Combine(errors)
}
// GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user.
func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error) {
serviceAccountName := UserServiceAccountName(userID, kcl.instanceID)
+2 -1
View File
@@ -7,6 +7,7 @@ import (
models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
storagev1 "k8s.io/api/storage/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
@@ -264,7 +265,7 @@ func (kcl *KubeClient) updateVolumesWithOwningApplications(volumes *[]models.K8s
if pod.Spec.Volumes != nil {
for _, podVolume := range pod.Spec.Volumes {
if podVolume.VolumeSource.PersistentVolumeClaim != nil && podVolume.VolumeSource.PersistentVolumeClaim.ClaimName == volume.PersistentVolumeClaim.Name && pod.Namespace == volume.PersistentVolumeClaim.Namespace {
application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, false)
application, err := kcl.ConvertPodToApplication(pod, replicaSetItems, deploymentItems, statefulSetItems, daemonSetItems, []corev1.Service{}, []autoscalingv2.HorizontalPodAutoscaler{}, false)
if err != nil {
log.Error().Err(err).Msg("Failed to convert pod to application")
return nil, fmt.Errorf("an error occurred during the CombineServicesWithApplications operation, unable to convert pod to application. Error: %w", err)
+27 -36
View File
@@ -3,10 +3,12 @@ package oauth
import (
"context"
"io"
"maps"
"mime"
"net/http"
"net/url"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
@@ -29,28 +31,28 @@ func NewService() *Service {
// On success, it will then return the username and token expiry time associated to authenticated user by fetching this information
// from the resource server and matching it with the user identifier setting.
func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) {
token, err := getOAuthToken(code, configuration)
token, err := GetOAuthToken(code, configuration)
if err != nil {
log.Error().Err(err).Msg("failed retrieving oauth token")
return "", err
}
idToken, err := getIdToken(token)
idToken, err := GetIdToken(token)
if err != nil {
log.Error().Err(err).Msg("failed parsing id_token")
}
resource, err := getResource(token.AccessToken, configuration)
resource, err := GetResource(token.AccessToken, configuration.ResourceURI)
if err != nil {
log.Error().Err(err).Msg("failed retrieving resource")
return "", err
}
resource = mergeSecondIntoFirst(idToken, resource)
maps.Copy(idToken, resource)
username, err := getUsername(resource, configuration)
username, err := GetUsername(resource, configuration.UserIdentifier)
if err != nil {
log.Error().Err(err).Msg("failed retrieving username")
@@ -60,34 +62,24 @@ func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings
return username, nil
}
// mergeSecondIntoFirst merges the overlap map into the base overwriting any existing values.
func mergeSecondIntoFirst(base map[string]any, overlap map[string]any) map[string]any {
for k, v := range overlap {
base[k] = v
}
return base
}
func getOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
func GetOAuthToken(code string, configuration *portainer.OAuthSettings) (*oauth2.Token, error) {
unescapedCode, err := url.QueryUnescape(code)
if err != nil {
return nil, err
}
config := buildConfig(configuration)
token, err := config.Exchange(context.Background(), unescapedCode)
if err != nil {
return nil, err
}
return token, nil
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
return config.Exchange(ctx, unescapedCode)
}
// getIdToken retrieves parsed id_token from the OAuth token response.
// GetIdToken retrieves parsed id_token from the OAuth token response.
// This is necessary for OAuth providers like Azure
// that do not provide information about user groups on the user resource endpoint.
func getIdToken(token *oauth2.Token) (map[string]any, error) {
func GetIdToken(token *oauth2.Token) (map[string]any, error) {
tokenData := make(map[string]any)
idToken := token.Extra("id_token")
@@ -113,8 +105,8 @@ func getIdToken(token *oauth2.Token) (map[string]any, error) {
return tokenData, nil
}
func getResource(token string, configuration *portainer.OAuthSettings) (map[string]any, error) {
req, err := http.NewRequest("GET", configuration.ResourceURI, nil)
func GetResource(token string, resourceURI string) (map[string]any, error) {
req, err := http.NewRequest(http.MethodGet, resourceURI, nil)
if err != nil {
return nil, err
}
@@ -159,6 +151,7 @@ func getResource(token string, configuration *portainer.OAuthSettings) (map[stri
datamap[k] = v[0]
}
}
return datamap, nil
}
@@ -170,18 +163,16 @@ func getResource(token string, configuration *portainer.OAuthSettings) (map[stri
return datamap, nil
}
func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config {
endpoint := oauth2.Endpoint{
AuthURL: configuration.AuthorizationURI,
TokenURL: configuration.AccessTokenURI,
AuthStyle: configuration.AuthStyle,
}
func buildConfig(config *portainer.OAuthSettings) *oauth2.Config {
return &oauth2.Config{
ClientID: configuration.ClientID,
ClientSecret: configuration.ClientSecret,
Endpoint: endpoint,
RedirectURL: configuration.RedirectURI,
Scopes: strings.Split(configuration.Scopes, ","),
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
RedirectURL: config.RedirectURI,
Scopes: strings.Split(config.Scopes, ","),
Endpoint: oauth2.Endpoint{
AuthURL: config.AuthorizationURI,
TokenURL: config.AccessTokenURI,
AuthStyle: config.AuthStyle,
},
}
}
+3 -5
View File
@@ -3,18 +3,16 @@ package oauth
import (
"errors"
"strconv"
portainer "github.com/portainer/portainer/api"
)
func getUsername(datamap map[string]any, configuration *portainer.OAuthSettings) (string, error) {
username, ok := datamap[configuration.UserIdentifier].(string)
func GetUsername(datamap map[string]any, userIdentifier string) (string, error) {
username, ok := datamap[userIdentifier].(string)
if ok && username != "" {
return username, nil
}
if !ok {
username, ok := datamap[configuration.UserIdentifier].(float64)
username, ok := datamap[userIdentifier].(float64)
if ok && username != 0 {
return strconv.Itoa(int(username)), nil
}
+7 -14
View File
@@ -11,8 +11,7 @@ func Test_getUsername(t *testing.T) {
oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"}
datamap := map[string]any{"name": "john"}
_, err := getUsername(datamap, oauthSettings)
if err == nil {
if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil {
t.Errorf("getUsername should fail if user identifier doesn't exist as key in oauth userinfo object")
}
})
@@ -21,8 +20,7 @@ func Test_getUsername(t *testing.T) {
oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"}
datamap := map[string]any{"username": ""}
_, err := getUsername(datamap, oauthSettings)
if err == nil {
if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil {
t.Errorf("getUsername should fail if username from oauth userinfo object is empty string")
}
})
@@ -31,8 +29,7 @@ func Test_getUsername(t *testing.T) {
oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"}
datamap := map[string]any{"username": 0}
_, err := getUsername(datamap, oauthSettings)
if err == nil {
if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil {
t.Errorf("getUsername should fail if username from oauth userinfo object is 0 val int")
}
})
@@ -41,8 +38,7 @@ func Test_getUsername(t *testing.T) {
oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"}
datamap := map[string]any{"username": -1}
_, err := getUsername(datamap, oauthSettings)
if err == nil {
if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil {
t.Errorf("getUsername should fail if username from oauth userinfo object is -1 (negative) int")
}
})
@@ -51,8 +47,7 @@ func Test_getUsername(t *testing.T) {
oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"}
datamap := map[string]any{"username": "john"}
_, err := getUsername(datamap, oauthSettings)
if err != nil {
if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err != nil {
t.Errorf("getUsername should succeed if username from oauth userinfo object matched and non-empty")
}
})
@@ -62,8 +57,7 @@ func Test_getUsername(t *testing.T) {
oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"}
datamap := map[string]any{"username": 1}
_, err := getUsername(datamap, oauthSettings)
if err == nil {
if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err == nil {
t.Errorf("getUsername should fail if username from oauth userinfo object matched is positive int")
}
})
@@ -72,8 +66,7 @@ func Test_getUsername(t *testing.T) {
oauthSettings := &portainer.OAuthSettings{UserIdentifier: "username"}
datamap := map[string]any{"username": 1.1}
_, err := getUsername(datamap, oauthSettings)
if err != nil {
if _, err := GetUsername(datamap, oauthSettings.UserIdentifier); err != nil {
t.Errorf("getUsername should succeed if username from oauth userinfo object matched and non-zero (or negative)")
}
})
+7 -6
View File
@@ -5,6 +5,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/oauth/oauthtest"
"github.com/stretchr/testify/assert"
"golang.org/x/oauth2"
)
@@ -16,14 +17,14 @@ func Test_getOAuthToken(t *testing.T) {
t.Run("getOAuthToken fails upon invalid code", func(t *testing.T) {
code := ""
if _, err := getOAuthToken(code, config); err == nil {
if _, err := GetOAuthToken(code, config); err == nil {
t.Errorf("getOAuthToken should fail upon providing invalid code; code=%v", code)
}
})
t.Run("getOAuthToken succeeds upon providing valid code", func(t *testing.T) {
code := validCode
token, err := getOAuthToken(code, config)
token, err := GetOAuthToken(code, config)
if token == nil || err != nil {
t.Errorf("getOAuthToken should successfully return access token upon providing valid code")
@@ -78,7 +79,7 @@ func Test_getIdToken(t *testing.T) {
token = token.WithExtra(map[string]any{"id_token": tc.idToken})
}
result, err := getIdToken(token)
result, err := GetIdToken(token)
assert.Equal(t, err, tc.expectedError)
assert.Equal(t, result, tc.expectedResult)
})
@@ -90,19 +91,19 @@ func Test_getResource(t *testing.T) {
defer srv.Close()
t.Run("should fail upon missing Authorization Bearer header", func(t *testing.T) {
if _, err := getResource("", config); err == nil {
if _, err := GetResource("", config.ResourceURI); err == nil {
t.Errorf("getResource should fail if access token is not provided in auth bearer header")
}
})
t.Run("should fail upon providing incorrect Authorization Bearer header", func(t *testing.T) {
if _, err := getResource("incorrect-token", config); err == nil {
if _, err := GetResource("incorrect-token", config.ResourceURI); err == nil {
t.Errorf("getResource should fail if incorrect access token provided in auth bearer header")
}
})
t.Run("should succeed upon providing correct Authorization Bearer header", func(t *testing.T) {
if _, err := getResource(oauthtest.AccessToken, config); err != nil {
if _, err := GetResource(oauthtest.AccessToken, config.ResourceURI); err != nil {
t.Errorf("getResource should succeed if correct access token provided in auth bearer header")
}
})
+49 -3
View File
@@ -2,6 +2,7 @@ package portainer
import (
"context"
"fmt"
"io"
"time"
@@ -1366,7 +1367,13 @@ type (
ValidateFlags(flags *CLIFlags) error
}
ComposeOptions struct {
Registries []Registry
}
ComposeUpOptions struct {
ComposeOptions
// ForceRecreate forces to recreate containers
ForceRecreate bool
// AbortOnContainerExit will stop the deployment if a container exits.
@@ -1374,9 +1381,12 @@ type (
//
// When this is set, docker compose will output its logs to stdout
AbortOnContainerExit bool
Prune bool
}
ComposeRunOptions struct {
ComposeOptions
// Remove will remove the container after it has stopped
Remove bool
// Args are the arguments to pass to the container
@@ -1392,7 +1402,7 @@ type (
Run(ctx context.Context, stack *Stack, endpoint *Endpoint, serviceName string, options ComposeRunOptions) error
Up(ctx context.Context, stack *Stack, endpoint *Endpoint, options ComposeUpOptions) error
Down(ctx context.Context, stack *Stack, endpoint *Endpoint) error
Pull(ctx context.Context, stack *Stack, endpoint *Endpoint) error
Pull(ctx context.Context, stack *Stack, endpoint *Endpoint, options ComposeOptions) error
}
// CryptoService represents a service for encrypting/hashing data
@@ -1498,6 +1508,8 @@ type (
SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error
IsRBACEnabled() (bool, error)
GetPortainerUserServiceAccount(tokendata *TokenData) (*corev1.ServiceAccount, error)
GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error)
DeleteServiceAccounts(reqs models.K8sServiceAccountDeleteRequests) error
GetServiceAccountBearerToken(userID int) (string, error)
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
@@ -1531,6 +1543,16 @@ type (
CreateRegistrySecret(registry *Registry, namespace string) error
IsRegistrySecret(namespace, secretName string) (bool, error)
ToggleSystemState(namespace string, isSystem bool) error
GetClusterRoles() ([]models.K8sClusterRole, error)
DeleteClusterRoles(models.K8sClusterRoleDeleteRequests) error
GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error)
DeleteClusterRoleBindings(models.K8sClusterRoleBindingDeleteRequests) error
GetRoles(namespace string) ([]models.K8sRole, error)
DeleteRoles(models.K8sRoleDeleteRequests) error
GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error)
DeleteRoleBindings(models.K8sRoleBindingDeleteRequests) error
}
// KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint)
@@ -1595,7 +1617,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.23.0"
APIVersion = "2.24.0"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1646,7 +1668,7 @@ const (
)
// List of supported features
var SupportedFeatureFlags = []featureflags.Feature{}
var SupportedFeatureFlags = []featureflags.Feature{"hsts", "csp"}
const (
_ AuthenticationMethod = iota
@@ -1722,6 +1744,30 @@ const (
EdgeStackStatusCompleted
)
var edgeStackStatusTypeStr = map[EdgeStackStatusType]string{
EdgeStackStatusPending: "Pending",
EdgeStackStatusDeploymentReceived: "DeploymentReceived",
EdgeStackStatusError: "Error",
EdgeStackStatusAcknowledged: "Acknowledged",
EdgeStackStatusRemoved: "Removed",
EdgeStackStatusRemoteUpdateSuccess: "RemoteUpdateSuccess",
EdgeStackStatusImagesPulled: "ImagesPulled",
EdgeStackStatusRunning: "Running",
EdgeStackStatusDeploying: "Deploying",
EdgeStackStatusRemoving: "Removing",
EdgeStackStatusPausedDeploying: "PausedDeploying",
EdgeStackStatusRollingBack: "RollingBack",
EdgeStackStatusRolledBack: "RolledBack",
EdgeStackStatusCompleted: "Completed",
}
func (s EdgeStackStatusType) String() string {
if str, ok := edgeStackStatusTypeStr[s]; ok {
return fmt.Sprintf("%d (%s)", s, str)
}
return fmt.Sprintf("%d (UNKNOWN)", s)
}
const (
_ EndpointStatus = iota
// EndpointStatusUp is used to represent an available environment(endpoint)
+4 -4
View File
@@ -76,11 +76,11 @@ vJUUCFYm8+9p6gTVOcoMit+eGSwa81PCPEs1TnU1PV/PaDFeUhn/mg==
type noopDeployer struct{}
// without unpacker
func (s *noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error {
func (s *noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRecreate bool) error {
func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
@@ -89,7 +89,7 @@ func (s *noopDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *p
}
// with unpacker
func (s *noopDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRecreate bool) error {
func (s *noopDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
func (s *noopDeployer) UndeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
@@ -101,7 +101,7 @@ func (s *noopDeployer) StartRemoteComposeStack(stack *portainer.Stack, endpoint
func (s *noopDeployer) StopRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func (s *noopDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error {
func (s *noopDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
func (s *noopDeployer) UndeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
+17 -17
View File
@@ -4,17 +4,17 @@ import (
"context"
"sync"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
k "github.com/portainer/portainer/api/kubernetes"
"github.com/pkg/errors"
)
type BaseStackDeployer interface {
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRecreate bool) error
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error
DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error
}
@@ -44,7 +44,7 @@ func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStac
dataStore: dataStore,
}
}
func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error {
func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
d.lock.Lock()
defer d.lock.Unlock()
@@ -54,28 +54,29 @@ func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *porta
return d.swarmStackManager.Deploy(stack, prune, pullImage, endpoint)
}
func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRecreate bool) error {
func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
d.lock.Lock()
defer d.lock.Unlock()
d.swarmStackManager.Login(registries, endpoint)
defer d.swarmStackManager.Logout(endpoint)
options := portainer.ComposeOptions{Registries: registries}
// --force-recreate doesn't pull updated images
if forcePullImage {
err := d.composeStackManager.Pull(context.TODO(), stack, endpoint)
if err != nil {
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, options); err != nil {
return err
}
}
err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ForceRecreate: forceRecreate,
})
if err != nil {
if err := d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ComposeOptions: options,
ForceRecreate: forceRecreate,
}); err != nil {
d.composeStackManager.Down(context.TODO(), stack, endpoint)
return err
}
return err
return nil
}
func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
@@ -99,8 +100,7 @@ func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *
return errors.Wrap(err, "failed to create temp kub deployment files")
}
err = k8sDeploymentConfig.Deploy()
if err != nil {
if err := k8sDeploymentConfig.Deploy(); err != nil {
return errors.Wrap(err, "failed to deploy kubernetes application")
}
+1 -2
View File
@@ -59,8 +59,7 @@ func (d *stackDeployer) DeployRemoteComposeStack(
// --force-recreate doesn't pull updated images
if forcePullImage {
err := d.composeStackManager.Pull(context.TODO(), stack, endpoint)
if err != nil {
if err := d.composeStackManager.Pull(context.TODO(), stack, endpoint, portainer.ComposeOptions{}); err != nil {
return err
}
}
@@ -3,13 +3,13 @@ package deployments
import (
"fmt"
"github.com/rs/zerolog/log"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type ComposeStackDeploymentConfig struct {
@@ -25,7 +25,6 @@ type ComposeStackDeploymentConfig struct {
}
func CreateComposeStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
user, err := dataStore.User().Read(securityContext.UserID)
if err != nil {
return nil, fmt.Errorf("unable to load user information from the database: %w", err)
@@ -81,11 +80,11 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
!securitySettings.AllowContainerCapabilitiesForRegularUsers) &&
!isAdminOrEndpointAdmin {
err = stackutils.ValidateStackFiles(config.stack, securitySettings, config.FileService)
if err != nil {
if err := stackutils.ValidateStackFiles(config.stack, securitySettings, config.FileService); err != nil {
return err
}
}
if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
}
@@ -1,4 +1,4 @@
package websocket
package ws
import (
"bufio"
@@ -16,18 +16,13 @@ import (
const (
// Time allowed to write a message to the peer
writeWait = 10 * time.Second
WriteWait = 10 * time.Second
// Send pings to peer with this period
pingPeriod = 50 * time.Second
PingPeriod = 50 * time.Second
)
func hijackRequest(
websocketConn *websocket.Conn,
conn net.Conn,
request *http.Request,
token string,
) error {
func HijackRequest(websocketConn *websocket.Conn, conn net.Conn, request *http.Request) error {
resp, err := sendHTTPRequest(conn, request)
if err != nil {
return err
@@ -39,17 +34,21 @@ func hijackRequest(
return fmt.Errorf("unexpected response status code: %d", resp.StatusCode)
}
var mu sync.Mutex
errorChan := make(chan error, 1)
go readWebSocketToTCP(websocketConn, conn, errorChan)
go writeTCPToWebSocket(websocketConn, conn, errorChan)
go StreamFromWebsocketToWriter(websocketConn, conn, errorChan)
go WriteReaderToWebSocket(websocketConn, &mu, conn, errorChan)
err = <-errorChan
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
log.Debug().Msgf("Unexpected close error: %v\n", err)
log.Debug().Err(err).Msg("unexpected close error")
return err
}
log.Debug().Msgf("session ended")
log.Info().Msg("session ended")
return nil
}
@@ -69,60 +68,40 @@ func sendHTTPRequest(conn net.Conn, req *http.Request) (*http.Response, error) {
return resp, nil
}
func readWebSocketToTCP(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) {
for {
messageType, p, err := websocketConn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
log.Debug().Msgf("Unexpected close error: %v\n", err)
}
errorChan <- err
return
}
if messageType == websocket.TextMessage || messageType == websocket.BinaryMessage {
_, err := tcpConn.Write(p)
if err != nil {
log.Debug().Msgf("Error writing to TCP connection: %v\n", err)
errorChan <- err
return
}
}
}
}
func writeTCPToWebSocket(websocketConn *websocket.Conn, tcpConn net.Conn, errorChan chan error) {
var mu sync.Mutex
out := make([]byte, readerBufferSize)
func WriteReaderToWebSocket(websocketConn *websocket.Conn, mu *sync.Mutex, reader io.Reader, errorChan chan error) {
out := make([]byte, ReaderBufferSize)
input := make(chan string)
pingTicker := time.NewTicker(pingPeriod)
pingTicker := time.NewTicker(PingPeriod)
defer pingTicker.Stop()
defer websocketConn.Close()
websocketConn.SetReadLimit(2048)
mu.Lock()
websocketConn.SetReadLimit(ReaderBufferSize)
websocketConn.SetPongHandler(func(string) error {
return nil
})
websocketConn.SetPingHandler(func(data string) error {
websocketConn.SetWriteDeadline(time.Now().Add(writeWait))
websocketConn.SetWriteDeadline(time.Now().Add(WriteWait))
return websocketConn.WriteMessage(websocket.PongMessage, []byte(data))
})
reader := bufio.NewReader(tcpConn)
mu.Unlock()
go func() {
for {
n, err := reader.Read(out)
if err != nil {
errorChan <- err
if !errors.Is(err, io.EOF) {
log.Debug().Msgf("error reading from server: %v", err)
log.Debug().Err(err).Msg("error reading from server")
}
return
}
processedOutput := validString(string(out[:n]))
processedOutput := ValidString(string(out[:n]))
input <- processedOutput
}
}()
@@ -130,34 +109,37 @@ func writeTCPToWebSocket(websocketConn *websocket.Conn, tcpConn net.Conn, errorC
for {
select {
case msg := <-input:
err := wswrite(websocketConn, &mu, msg)
if err != nil {
log.Debug().Msgf("error writing to websocket: %v", err)
if err := wsWrite(websocketConn, mu, msg); err != nil {
log.Debug().Err(err).Msg("error writing to websocket")
errorChan <- err
return
}
case <-pingTicker.C:
if err := wsping(websocketConn, &mu); err != nil {
log.Debug().Msgf("error writing to websocket during pong response: %v", err)
if err := wsPing(websocketConn, mu); err != nil {
log.Debug().Err(err).Msg("error writing to websocket during pong response")
errorChan <- err
return
}
}
}
}
func wswrite(websocketConn *websocket.Conn, mu *sync.Mutex, msg string) error {
func wsWrite(websocketConn *websocket.Conn, mu *sync.Mutex, msg string) error {
mu.Lock()
defer mu.Unlock()
websocketConn.SetWriteDeadline(time.Now().Add(writeWait))
websocketConn.SetWriteDeadline(time.Now().Add(WriteWait))
return websocketConn.WriteMessage(websocket.TextMessage, []byte(msg))
}
func wsping(websocketConn *websocket.Conn, mu *sync.Mutex) error {
func wsPing(websocketConn *websocket.Conn, mu *sync.Mutex) error {
mu.Lock()
defer mu.Unlock()
websocketConn.SetWriteDeadline(time.Now().Add(writeWait))
websocketConn.SetWriteDeadline(time.Now().Add(WriteWait))
return websocketConn.WriteMessage(websocket.PingMessage, nil)
}
+77
View File
@@ -0,0 +1,77 @@
package ws
import (
"io"
"unicode/utf8"
"github.com/gorilla/websocket"
"github.com/rs/zerolog/log"
)
const ReaderBufferSize = 2048
func StreamFromWebsocketToWriter(websocketConn *websocket.Conn, writer io.Writer, errorChan chan error) {
for {
messageType, in, err := websocketConn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
log.Debug().Err(err).Msg("unexpected close error")
}
errorChan <- err
return
}
if messageType != websocket.TextMessage && messageType != websocket.BinaryMessage {
continue
}
if _, err := writer.Write(in); err != nil {
log.Debug().Err(err).Msg("writing error")
errorChan <- err
return
}
}
}
func StreamFromReaderToWebsocket(websocketConn *websocket.Conn, reader io.Reader, errorChan chan error) {
out := make([]byte, ReaderBufferSize)
for {
n, err := reader.Read(out)
if err != nil {
errorChan <- err
break
}
processedOutput := ValidString(string(out[:n]))
if err := websocketConn.WriteMessage(websocket.TextMessage, []byte(processedOutput)); err != nil {
errorChan <- err
break
}
}
}
func ValidString(s string) string {
if utf8.ValidString(s) {
return s
}
v := make([]rune, 0, len(s))
for i, r := range s {
if r == utf8.RuneError {
_, size := utf8.DecodeRuneInString(s[i:])
if size == 1 {
continue
}
}
v = append(v, r)
}
return string(v)
}
+1 -6
View File
@@ -144,12 +144,7 @@ div.input-mask {
}
.widget .widget-footer {
border-top: 1px solid #e9e9e9;
padding: 10px;
}
.widget .widget-footer {
border-top: 1px solid #e9e9e9;
padding: 10px;
padding: 20px;
}
.widget .widget-title .pagination,
.widget .widget-footer .pagination {
+28
View File
@@ -32,6 +32,34 @@
border-top: 1px solid var(--border-table-top-color);
}
/* the first cell in the table should have 20px padding instead of 5px to match all other widgets */
.widget .table:not(td .table) > thead > tr > th:first-child,
.widget .table:not(td .table) > tbody > tr > th:first-child,
.widget .table:not(td .table) > tfoot > tr > th:first-child,
.widget .table:not(td .table) > thead > tr > td:first-child,
.widget .table:not(td .table) > tbody > tr > td:first-child,
.widget .table:not(td .table) > tfoot > tr > td:first-child {
padding-left: 20px;
}
/* the last cell in the table should have 20px padding instead of 5px to match all other widgets */
.widget .table:not(td .table) > thead > tr > th:last-child,
.widget .table:not(td .table) > tbody > tr > th:last-child,
.widget .table:not(td .table) > tfoot > tr > th:last-child,
.widget .table:not(td .table) > thead > tr > td:last-child,
.widget .table:not(td .table) > tbody > tr > td:last-child,
.widget .table:not(td .table) > tfoot > tr > td:last-child {
padding-right: 20px;
}
/* tables inside widgets should extend the full width of the widget, with 20px table cell padding padding on the left and right */
.widget-body:not(.no-padding) > .table,
.widget-body:not(.no-padding) > .widget-content > .table {
margin: 0 -20px;
width: calc(100% + 40px);
max-width: calc(100% + 40px);
}
.input-group-addon {
color: var(--text-input-group-addon-color);
background-color: var(--bg-input-group-addon-color);
+2 -1
View File
@@ -56,11 +56,12 @@ function ServiceServiceFactory(AngularToReact) {
* @param {string?} rollback
*/
async function updateServiceAngularJS(environmentId, service, config, rollback) {
const data = await getServiceAngularJS(environmentId, service.Id);
return updateService({
environmentId,
config,
serviceId: service.Id,
version: service.Version,
version: data.Version,
registryId: config.registryId,
rollback,
});
@@ -146,7 +146,7 @@
</tr>
<tr authorization="DockerContainerLogs, DockerContainerInspect, DockerContainerStats, DockerExecStart">
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<div class="btn-group -ml-3" role="group" aria-label="...">
<a authorization="DockerContainerLogs" class="btn" type="button" ui-sref="docker.containers.container.logs({ id: container.Id })"
><pr-icon icon="'file-text'" class-name="'space-right'"></pr-icon>Logs</a
>
@@ -166,7 +166,7 @@ angular.module('portainer.docker').controller('ImageController', [
$scope.state.exportInProgress = true;
ImageService.downloadImages([{ tags: image.RepoTags, id: image.Id }])
.then(function success(data) {
var downloadData = new Blob([data.file], { type: 'application/x-tar' });
var downloadData = new Blob([data], { type: 'application/x-tar' });
FileSaver.saveAs(downloadData, 'images.tar');
Notifications.success('Success', 'Image successfully downloaded');
})
@@ -2,7 +2,7 @@
<rd-widget>
<rd-widget-header icon="list" title-text="Configs"> </rd-widget-header>
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
<div class="form-inline" style="padding: 10px 20px" authorization="DockerServiceUpdate">
Add a config:
<select
class="form-control !h-[30px] !text-[13px]"
@@ -12,7 +12,7 @@
>
<option selected disabled hidden value="">Select a config</option>
</select>
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)"> <pr-icon icon="'plus'"></pr-icon> add config </a>
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)"> <pr-icon icon="'plus'"></pr-icon> Add config </a>
</div>
<table class="table" style="margin-top: 5px">
<thead>
@@ -2,7 +2,7 @@
<rd-widget>
<rd-widget-header icon="list" title-text="Logging driver"> </rd-widget-header>
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
<div class="form-inline" style="padding: 10px 20px" authorization="DockerServiceUpdate">
Driver:
<select
class="form-control !h-[30px] !text-[13px]"
@@ -16,7 +16,7 @@
<option value="none">none</option>
</select>
<a class="btn btn-default btn-sm" ng-click="!service.LogDriverName || service.LogDriverName === 'none' || addLogDriverOpt(service)">
<pr-icon icon="'plus'"></pr-icon> add logging driver option
<pr-icon icon="'plus'"></pr-icon> Add logging driver option
</a>
</div>
<table class="table">
@@ -2,7 +2,7 @@
<rd-widget>
<rd-widget-header icon="list" title-text="Secrets"> </rd-widget-header>
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
<div class="form-inline" style="padding: 10px 20px" authorization="DockerServiceUpdate">
Add a secret:
<select
class="form-control !h-[30px] !text-[13px]"
@@ -20,7 +20,7 @@
<label class="btn btn-light" ng-model="state.addSecret.override" uib-btn-radio="false">Default location</label>
<label class="btn btn-light" ng-model="state.addSecret.override" uib-btn-radio="true">Override</label>
</div>
<a class="btn btn-default btn-sm" ng-click="addSecret(service, state.addSecret)"> <pr-icon icon="'plus'"></pr-icon> add secret </a>
<a class="btn btn-default btn-sm" ng-click="addSecret(service, state.addSecret)"> <pr-icon icon="'plus'"></pr-icon> Add secret </a>
</div>
<table class="table" style="margin-top: 5px">
<thead>
-13
View File
@@ -10,19 +10,6 @@
<meta http-equiv="pragma" content="no-cache" />
<meta name="robots" content="noindex" />
<base id="base" />
<script>
// http://localhost:49000 is a docker extension specific url (see /build/docker-extension/docker-compose.yml)
if (window.origin == 'http://localhost:49000') {
// we are loading the app from a local file as in docker extension
document.getElementById('base').href = 'http://localhost:49000/';
window.ddExtension = true;
} else {
var path = window.location.pathname.replace(/^\/+|\/+$/g, '');
var basePath = path ? '/' + path + '/' : '/';
document.getElementById('base').href = basePath;
}
</script>
<!-- HTML5 shim, for IE6-8 support of HTML5 elements -->
<!--[if lt IE 9]>
+12
View File
@@ -21,6 +21,18 @@ import { onStartupAngular } from './app';
import { configApp } from './config';
import { constantsModule } from './ng-constants';
// http://localhost:49000 is a docker extension specific url (see /build/docker-extension/docker-compose.yml)
if (window.origin == 'http://localhost:49000') {
// we are loading the app from a local file as in docker extension
document.getElementById('base').href = 'http://localhost:49000/';
window.ddExtension = true;
} else {
var path = window.location.pathname.replace(/^\/+|\/+$/g, '');
var basePath = path ? '/' + path + '/' : '/';
document.getElementById('base').href = basePath;
}
initFeatureService(Edition[process.env.PORTAINER_EDITION]);
angular
+5 -5
View File
@@ -207,7 +207,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const applications = {
name: 'kubernetes.applications',
url: '/applications',
url: '/applications?tab',
views: {
'content@': {
component: 'kubernetesApplicationsView',
@@ -233,7 +233,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const application = {
name: 'kubernetes.applications.application',
url: '/:namespace/:name?resource-type&tab',
url: '/:namespace/:name?resource-type',
views: {
'content@': {
component: 'applicationDetailsView',
@@ -489,12 +489,12 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
},
};
const resourcePoolAccess = {
const namespaceAccess = {
name: 'kubernetes.resourcePools.resourcePool.access',
url: '/access',
views: {
'content@': {
component: 'kubernetesResourcePoolAccessView',
component: 'kubernetesNamespaceAccessView',
},
},
data: {
@@ -647,7 +647,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
$stateRegistryProvider.register(resourcePools);
$stateRegistryProvider.register(namespaceCreation);
$stateRegistryProvider.register(resourcePool);
$stateRegistryProvider.register(resourcePoolAccess);
$stateRegistryProvider.register(namespaceAccess);
$stateRegistryProvider.register(volumes);
$stateRegistryProvider.register(volume);
$stateRegistryProvider.register(registries);
@@ -43,7 +43,7 @@
ui-sref="kubernetes.deploy({ referrer: 'kubernetes.configurations' })"
data-cy="k8sConfig-deployFromManifestButton"
>
<pr-icon icon="'plus'" class-name="'!h-3'"></pr-icon>Create from manifest
<pr-icon icon="'plus'" class-name="'!h-3'"></pr-icon>Create from file
</button>
</div>
<div class="settings">
@@ -41,8 +41,7 @@
Show custom values
</button>
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues" role="status">
<pr-icon icon="'refresh-cw'" class="mr-1"></pr-icon>
Loading values.yaml...
<inline-loader children="'Loading values.yaml...'" />
</span>
<button ng-if="$ctrl.state.showCustomValues" class="btn btn-xs btn-default vertical-center !ml-0 mr-2" ng-click="$ctrl.state.showCustomValues = false;">
<pr-icon icon="'minus'" class="vertical-center"></pr-icon>
+4 -5
View File
@@ -1,9 +1,8 @@
import _ from 'lodash-es';
import { KubernetesConfigMap, KubernetesPortainerAccessConfigMap } from 'Kubernetes/models/config-map/models';
import { KubernetesConfigMapCreatePayload, KubernetesConfigMapUpdatePayload } from 'Kubernetes/models/config-map/payloads';
import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import { ConfigurationOwnerUsernameLabel } from '@/react/kubernetes/configs/constants';
class KubernetesConfigMapConverter {
static apiToPortainerAccessConfigMap(data) {
const res = new KubernetesPortainerAccessConfigMap();
@@ -35,7 +34,7 @@ class KubernetesConfigMapConverter {
res.Id = data.metadata.uid;
res.Name = data.metadata.name;
res.Namespace = data.metadata.namespace;
res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[ConfigurationOwnerUsernameLabel] : '';
res.CreationDate = data.metadata.creationTimestamp;
res.Yaml = yaml ? yaml.data : '';
res.Labels = data.metadata.labels;
@@ -79,7 +78,7 @@ class KubernetesConfigMapConverter {
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace.Namespace.Name;
const configurationOwner = _.truncate(data.ConfigurationOwner, { length: 63, omission: '' });
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner;
res.metadata.labels[ConfigurationOwnerUsernameLabel] = configurationOwner;
_.forEach(data.Data, (entry) => {
if (entry.IsBinary) {
@@ -100,7 +99,7 @@ class KubernetesConfigMapConverter {
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace;
res.metadata.labels = data.Labels || {};
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = data.ConfigurationOwner;
res.metadata.labels[ConfigurationOwnerUsernameLabel] = data.ConfigurationOwner;
_.forEach(data.Data, (entry) => {
if (entry.IsBinary) {
res.binaryData[entry.Key] = entry.Value;
+4 -4
View File
@@ -2,7 +2,7 @@ import _ from 'lodash-es';
import { KubernetesSecretCreatePayload, KubernetesSecretUpdatePayload } from 'Kubernetes/models/secret/payloads';
import { KubernetesApplicationSecret } from 'Kubernetes/models/secret/models';
import { KubernetesPortainerConfigurationDataAnnotation } from 'Kubernetes/models/configuration/models';
import { KubernetesPortainerConfigurationOwnerLabel } from 'Kubernetes/models/configuration/models';
import { ConfigurationOwnerUsernameLabel } from '@/react/kubernetes/configs/constants';
import { KubernetesConfigurationFormValuesEntry } from 'Kubernetes/models/configuration/formvalues';
import { KubernetesSecretTypeOptions } from 'Kubernetes/models/configuration/models';
class KubernetesSecretConverter {
@@ -12,7 +12,7 @@ class KubernetesSecretConverter {
res.metadata.namespace = secret.Namespace.Namespace.Name;
res.type = secret.Type;
const configurationOwner = _.truncate(secret.ConfigurationOwner, { length: 63, omission: '' });
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = configurationOwner;
res.metadata.labels[ConfigurationOwnerUsernameLabel] = configurationOwner;
let annotation = '';
_.forEach(secret.Data, (entry) => {
@@ -40,7 +40,7 @@ class KubernetesSecretConverter {
res.metadata.namespace = secret.Namespace;
res.type = secret.Type;
res.metadata.labels = secret.Labels || {};
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = secret.ConfigurationOwner;
res.metadata.labels[ConfigurationOwnerUsernameLabel] = secret.ConfigurationOwner;
let annotation = '';
_.forEach(secret.Data, (entry) => {
@@ -69,7 +69,7 @@ class KubernetesSecretConverter {
res.Namespace = payload.metadata.namespace;
res.Type = payload.type;
res.Labels = payload.metadata.labels || {};
res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[ConfigurationOwnerUsernameLabel] : '';
res.CreationDate = payload.metadata.creationTimestamp;
res.Annotations = payload.metadata.annotations;
@@ -1,21 +0,0 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { ApplicationsDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/ApplicationsDatatable';
export const applicationsModule = angular
.module('portainer.kubernetes.react.components.applications', [])
.component(
'kubernetesApplicationsDatatable',
r2a(withUIRouter(withCurrentUser(ApplicationsDatatable)), [
'namespace',
'namespaces',
'onNamespaceChange',
'onRefresh',
'onRemove',
'hideStacks',
])
).name;
-12
View File
@@ -23,7 +23,6 @@ import { ApplicationSummarySection } from '@/react/kubernetes/applications/compo
import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable';
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
import { StackNameLabelInsight } from '@/react/kubernetes/DeployView/StackName/StackNameLabelInsight';
@@ -61,14 +60,12 @@ import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
import { IntegratedAppsDatatable } from '@/react/kubernetes/components/IntegratedAppsDatatable/IntegratedAppsDatatable';
import { applicationsModule } from './applications';
import { namespacesModule } from './namespaces';
import { clusterManagementModule } from './clusterManagement';
import { registriesModule } from './registries';
export const ngModule = angular
.module('portainer.kubernetes.react.components', [
applicationsModule,
namespacesModule,
clusterManagementModule,
registriesModule,
@@ -208,15 +205,6 @@ export const ngModule = angular
['formValues', 'oldFormValues']
)
)
.component(
'kubernetesApplicationsStacksDatatable',
r2a(withUIRouter(withCurrentUser(ApplicationsStacksDatatable)), [
'onRemove',
'namespace',
'namespaces',
'onNamespaceChange',
])
)
.component(
'kubernetesIntegratedApplicationsDatatable',
r2a(withUIRouter(withCurrentUser(IntegratedAppsDatatable)), [
@@ -6,7 +6,7 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable';
import { NamespaceAppsDatatable } from '@/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable';
import { NamespaceAccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable';
import { AccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable';
export const namespacesModule = angular
.module('portainer.kubernetes.react.components.namespaces', [])
@@ -24,8 +24,5 @@ export const namespacesModule = angular
)
.component(
'namespaceAccessDatatable',
r2a(withUIRouter(withReactQuery(NamespaceAccessDatatable)), [
'dataset',
'onRemove',
])
r2a(withUIRouter(withReactQuery(AccessDatatable)), [])
).name;
+10
View File
@@ -11,6 +11,7 @@ import { ServicesView } from '@/react/kubernetes/services/ServicesView';
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
import { CreateNamespaceView } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceView';
import { ApplicationsView } from '@/react/kubernetes/applications/ListView/ApplicationsView';
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView';
import { NamespacesView } from '@/react/kubernetes/namespaces/ListView/NamespacesView';
@@ -18,6 +19,7 @@ import { ServiceAccountsView } from '@/react/kubernetes/more-resources/ServiceAc
import { ClusterRolesView } from '@/react/kubernetes/more-resources/ClusterRolesView';
import { RolesView } from '@/react/kubernetes/more-resources/RolesView';
import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
export const viewsModule = angular
.module('portainer.kubernetes.react.views', [])
@@ -29,6 +31,10 @@ export const viewsModule = angular
'kubernetesNamespacesView',
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), [])
)
.component(
'kubernetesNamespaceAccessView',
r2a(withUIRouter(withReactQuery(withCurrentUser(AccessView))), [])
)
.component(
'kubernetesServicesView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), [])
@@ -55,6 +61,10 @@ export const viewsModule = angular
[]
)
)
.component(
'kubernetesApplicationsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ApplicationsView))), [])
)
.component(
'applicationDetailsView',
r2a(
@@ -3,7 +3,6 @@ import _ from 'lodash-es';
import PortainerError from 'Portainer/error';
import KubernetesConfigMapConverter from 'Kubernetes/converters/configMap';
import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
import { KubernetesPortainerAccessConfigMap } from 'Kubernetes/models/config-map/models';
class KubernetesConfigMapService {
/* @ngInject */
@@ -18,22 +17,6 @@ class KubernetesConfigMapService {
this.deleteAsync = this.deleteAsync.bind(this);
}
getAccess(namespace, name) {
return this.$async(async () => {
try {
const params = new KubernetesCommonParams();
params.id = name;
const raw = await this.KubernetesConfigMaps(namespace).get(params).$promise;
return KubernetesConfigMapConverter.apiToPortainerAccessConfigMap(raw);
} catch (err) {
if (err.status === 404) {
return new KubernetesPortainerAccessConfigMap();
}
throw new PortainerError('Unable to retrieve Portainer accesses', err);
}
});
}
createAccess(config) {
return this.$async(async () => {
try {
@@ -1,59 +0,0 @@
<page-header ng-if="ctrl.state.viewReady" title="'Application list'" breadcrumbs="['Applications']" reload="true"></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<div class="row">
<div class="col-sm-12" data-cy="k8sApp-appList">
<rd-widget>
<rd-widget-body classes="no-padding">
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality">
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
<uib-tab-heading class="vertical-center"> <pr-icon icon="'box'"></pr-icon> Applications </uib-tab-heading>
<kubernetes-applications-datatable
dataset="ctrl.state.applications"
on-refresh="(ctrl.getApplications)"
namespaces="ctrl.state.namespaces"
namespace="ctrl.state.namespaceName"
on-namespace-change="(ctrl.onChangeNamespaceDropdown)"
is-loading="ctrl.state.isAppsLoading"
on-remove="(ctrl.removeAction)"
hide-stacks="ctrl.deploymentOptions.hideStacksFunctionality"
>
</kubernetes-applications-datatable>
</uib-tab>
<uib-tab index="2" classes="btn-sm" select="ctrl.selectTab(2)">
<uib-tab-heading class="vertical-center"> <pr-icon icon="'list'"></pr-icon> Stacks </uib-tab-heading>
<kubernetes-applications-stacks-datatable
dataset="ctrl.state.stacks"
on-refresh="(ctrl.getApplications)"
on-remove="(ctrl.removeStacksAction)"
namespaces="ctrl.state.namespaces"
namespace="ctrl.state.namespaceName"
is-loading="ctrl.state.isAppsLoading"
on-namespace-change="(ctrl.onChangeNamespaceDropdown)"
show-system="ctrl.state.isSystemResources"
set-system-resources="(ctrl.setSystemResources)"
>
</kubernetes-applications-stacks-datatable>
</uib-tab>
</uib-tabset>
<kubernetes-applications-datatable
ng-if="ctrl.deploymentOptions.hideStacksFunctionality"
dataset="ctrl.state.applications"
on-refresh="(ctrl.getApplications)"
namespaces="ctrl.state.namespaces"
namespace="ctrl.state.namespaceName"
on-namespace-change="(ctrl.onChangeNamespaceDropdown)"
is-loading="ctrl.state.isAppsLoading"
on-remove="(ctrl.removeAction)"
hide-stacks="ctrl.deploymentOptions.hideStacksFunctionality"
>
</kubernetes-applications-datatable>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>
@@ -1,9 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesApplicationsView', {
templateUrl: './applications.html',
controller: 'KubernetesApplicationsController',
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
endpoint: '<',
},
});
@@ -1,181 +0,0 @@
import angular from 'angular';
import _ from 'lodash-es';
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
import { KubernetesPortainerApplicationStackNameLabel } from 'Kubernetes/models/application/models';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
import { getStacksFromApplications } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable/getStacksFromApplications';
import { getApplications } from '@/react/kubernetes/applications/application.queries.ts';
import { getNamespaces } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
class KubernetesApplicationsController {
/* @ngInject */
constructor(
$async,
$state,
$scope,
Authentication,
Notifications,
KubernetesApplicationService,
EndpointService,
HelmService,
KubernetesConfigurationService,
LocalStorage,
StackService,
KubernetesNamespaceService
) {
this.$async = $async;
this.$state = $state;
this.$scope = $scope;
this.Authentication = Authentication;
this.Notifications = Notifications;
this.KubernetesApplicationService = KubernetesApplicationService;
this.HelmService = HelmService;
this.KubernetesConfigurationService = KubernetesConfigurationService;
this.Authentication = Authentication;
this.LocalStorage = LocalStorage;
this.StackService = StackService;
this.KubernetesNamespaceService = KubernetesNamespaceService;
this.onInit = this.onInit.bind(this);
this.removeAction = this.removeAction.bind(this);
this.removeActionAsync = this.removeActionAsync.bind(this);
this.removeStacksAction = this.removeStacksAction.bind(this);
this.removeStacksActionAsync = this.removeStacksActionAsync.bind(this);
this.onPublishingModeClick = this.onPublishingModeClick.bind(this);
this.onChangeNamespaceDropdown = this.onChangeNamespaceDropdown.bind(this);
}
selectTab(index) {
this.LocalStorage.storeActiveTab('applications', index);
}
async removeStacksActionAsync(selectedItems) {
let actionCount = selectedItems.length;
for (const stack of selectedItems) {
try {
const isAppFormCreated = stack.Applications.some((x) => !x.ApplicationKind);
if (isAppFormCreated) {
const promises = _.map(stack.Applications, (app) => this.KubernetesApplicationService.delete(app));
await Promise.all(promises);
}
await this.StackService.removeKubernetesStacksByName(stack.Name, stack.ResourcePool, false, this.endpoint.Id);
this.Notifications.success('Stack successfully removed', stack.Name);
_.remove(this.state.stacks, { Name: stack.Name });
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to remove stack');
} finally {
--actionCount;
if (actionCount === 0) {
this.$state.reload(this.$state.current);
}
}
}
}
removeStacksAction(selectedItems) {
return this.$async(this.removeStacksActionAsync, selectedItems);
}
async removeActionAsync(selectedItems) {
let actionCount = selectedItems.length;
for (const application of selectedItems) {
try {
if (application.ApplicationType === KubernetesApplicationTypes.Helm) {
await this.HelmService.uninstall(this.endpoint.Id, application);
} else {
if (application.Metadata.labels && application.Metadata.labels[KubernetesPortainerApplicationStackNameLabel]) {
// remove stack if no app left in the stack
const appsInNamespace = await getApplications(this.endpoint.Id, { namespace: application.ResourcePool, withDependencies: false });
const stacksInNamespace = getStacksFromApplications(appsInNamespace);
const stack = stacksInNamespace.find((x) => x.Name === application.StackName);
if (stack.Applications.length === 0 && application.StackId) {
await this.StackService.remove({ Id: application.StackId }, false, this.endpoint.Id);
}
}
await this.KubernetesApplicationService.delete(application);
}
this.Notifications.success('Application successfully removed', application.Name);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to remove application');
} finally {
--actionCount;
if (actionCount === 0) {
this.$state.reload(this.$state.current);
}
}
}
}
removeAction(selectedItems) {
this.$async(() => this.removeActionAsync(selectedItems));
}
onPublishingModeClick(application) {
this.state.activeTab = 1;
_.forEach(this.state.ports, (item) => {
item.Expanded = false;
item.Highlighted = false;
if (item.Name === application.Name && item.Ports.length > 1) {
item.Expanded = true;
item.Highlighted = true;
}
});
}
onChangeNamespaceDropdown(namespaceName) {
return this.$async(async () => {
this.state.namespaceName = namespaceName;
// save the selected namespaceName in local storage with the key 'kubernetes_namespace_filter_${environmentId}_${userID}'
this.LocalStorage.storeNamespaceFilter(this.endpoint.Id, this.user.ID, namespaceName);
});
}
async onInit() {
this.state = {
activeTab: this.LocalStorage.getActiveTab('applications'),
currentName: this.$state.$current.name,
isAdmin: this.Authentication.isAdmin(),
viewReady: false,
applications: [],
stacks: [],
ports: [],
namespaces: [],
namespaceName: '',
};
this.deploymentOptions = await getDeploymentOptions();
this.user = this.Authentication.getUserDetails();
this.state.namespaces = await getNamespaces(this.endpoint.Id);
const savedNamespace = this.LocalStorage.getNamespaceFilter(this.endpoint.Id, this.user.ID); // could be null if not found, and '' if all namepsaces is selected
const preferredNamespace = savedNamespace === null ? 'default' : savedNamespace;
this.state.namespaces = this.state.namespaces.filter((n) => n.Status.phase === 'Active');
this.state.namespaces = _.sortBy(this.state.namespaces, 'Name');
// set all namespaces ('') if there are no namespaces, or if all namespaces is selected
if (!this.state.namespaces.length || preferredNamespace === '') {
this.state.namespaceName = '';
} else {
// otherwise, set the preferred namespaceName if it exists, otherwise set the first namespaceName
this.state.namespaceName = this.state.namespaces.find((n) => n.Name === preferredNamespace) ? preferredNamespace : this.state.namespaces[0].Name;
}
this.state.viewReady = true;
}
$onInit() {
return this.$async(this.onInit);
}
$onDestroy() {
if (this.state.currentName !== this.$state.$current.name) {
this.LocalStorage.storeActiveTab('applications', 0);
}
}
}
export default KubernetesApplicationsController;
angular.module('portainer.kubernetes').controller('KubernetesApplicationsController', KubernetesApplicationsController);
@@ -424,7 +424,7 @@ class KubernetesCreateApplicationController {
const ingressNamesLoaded = this.ingresses.map((i) => i.Name);
const areAllIngressesLoaded = uniqueIngressNamesUsed.every((ingressNameUsed) => ingressNamesLoaded.includes(ingressNameUsed));
if (!areAllIngressesLoaded) {
this.refreshIngresses();
this.refreshIngresses(this.application.ResourcePool);
}
}
// update the services
@@ -24,19 +24,19 @@
<table class="table">
<tbody class="release-table">
<tr>
<td class="vertical-center !pl-0">Name</td>
<td class="vertical-center">Name</td>
<td class="vertical-center !p-2" data-cy="k8sAppDetail-appName">
{{ $ctrl.state.release.name }}
</td>
</tr>
<tr>
<td class="vertical-center !pl-0">Chart</td>
<td class="vertical-center">Chart</td>
<td class="vertical-center !p-2">
{{ $ctrl.state.release.chart }}
</td>
</tr>
<tr>
<td class="vertical-center !pl-0">App version</td>
<td class="vertical-center">App version</td>
<td class="vertical-center !p-2">
{{ $ctrl.state.release.app_version }}
</td>
+3 -3
View File
@@ -11,7 +11,7 @@
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
<uib-tab-heading class="flex-center gap-1"> <pr-icon icon="'hard-drive'" size="'sm'"></pr-icon> Node </uib-tab-heading>
<form class="form-horizontal" name="kubernetesNodeUpdateForm" style="padding: 20px" autocomplete="off">
<form class="form-horizontal widget-body" name="kubernetesNodeUpdateForm" autocomplete="off">
<table class="table">
<tbody ng-if="ctrl.node">
<tr>
@@ -73,7 +73,7 @@
</tbody>
</table>
<div style="padding: 8px">
<div class="mt-5">
<kubernetes-resource-reservation
ng-if="ctrl.resourceReservation"
cpu-reservation="ctrl.resourceReservation.CpuRequest"
@@ -88,7 +88,7 @@
</kubernetes-resource-reservation>
</div>
<div style="padding: 8px">
<div>
<!-- #region labels -->
<div class="col-sm-12 form-section-title"> Labels </div>
@@ -28,15 +28,15 @@
<pr-icon icon="'file-code'"></pr-icon>
ConfigMap
</uib-tab-heading>
<div style="padding: 20px">
<div class="widget-body">
<table class="table" data-cy="k8sConfigDetail-configTable">
<tbody>
<tr>
<td class="w-[40%] !border-none !pl-0">Name</td>
<td class="w-[40%] !border-none">Name</td>
<td class="!border-none"> {{ ctrl.configuration.Name }} </td>
</tr>
<tr>
<td class="!pl-0">Namespace</td>
<td>Namespace</td>
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.configuration.Namespace })">{{ ctrl.configuration.Namespace }}</a>
<span style="margin-left: 5px" class="label label-info image-tag" ng-if="ctrl.isSystemNamespace()">system</span>
@@ -28,25 +28,25 @@
<pr-icon icon="'lock'"></pr-icon>
Secret
</uib-tab-heading>
<div style="padding: 20px">
<div class="widget-body">
<table class="table" data-cy="k8sConfigDetail-configTable">
<tbody>
<tr>
<td class="w-[40%] !border-none !pl-0">Name</td>
<td class="w-[40%] !border-none">Name</td>
<td class="!border-none">
{{ ctrl.configuration.Name }}
<span style="margin-left: 5px" class="label label-info image-tag" ng-if="ctrl.configuration.IsRegistrySecret">system</span>
</td>
</tr>
<tr>
<td class="!pl-0">Namespace</td>
<td>Namespace</td>
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.configuration.Namespace })">{{ ctrl.configuration.Namespace }}</a>
<span style="margin-left: 5px" class="label label-info image-tag" ng-if="ctrl.isSystemNamespace()">system</span>
</td>
</tr>
<tr ng-if="ctrl.secretTypeName">
<td class="!pl-0">Secret Type</td>
<td>Secret Type</td>
<td> {{ ctrl.secretTypeName }} </td>
</tr>
</tbody>
+3 -12
View File
@@ -1,4 +1,4 @@
<page-header ng-if="ctrl.state.viewReady" title="'Create from manifest'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
<page-header ng-if="ctrl.state.viewReady" title="'Create from file'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
@@ -31,7 +31,7 @@
<portainer-tooltip message="'If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment'">
</portainer-tooltip>
</label>
<div class="col-sm-8 vertical-center pt-3">
<div class="col-sm-8 vertical-center pt-1">
<label class="switch">
<input type="checkbox" name="toggle_logo" ng-model="ctrl.formValues.namespace_toggle" data-cy="use-namespce-from-menifest" />
<span class="slider round"></span>
@@ -125,6 +125,7 @@
stack-type="3"
on-change="(ctrl.onChangeTemplateId)"
value="ctrl.state.templateId"
is-load-failed="ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE && ctrl.state.templateId && ctrl.state.templateLoadFailed"
></custom-template-selector>
<custom-templates-variables-field
@@ -135,16 +136,6 @@
></custom-templates-variables-field>
</div>
<span ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE && ctrl.state.templateId && ctrl.state.templateLoadFailed">
<p class="small vertical-center text-danger mb-5" ng-if="ctrl.currentUser.isAdmin || ctrl.currentUser.id === ctrl.state.template.CreatedByUserId">
<pr-icon icon="'alert-triangle'" mode="'danger'" size="'md'" feather="true"></pr-icon>Custom template could not be loaded, please
<a ui-sref="kubernetes.templates.custom.edit({id: ctrl.state.templateId})">click here</a> for configuration.</p
>
<p class="small vertical-center text-danger mb-5" ng-if="!(ctrl.currentUser.isAdmin || ctrl.currentUser.id === ctrl.state.template.CreatedByUserId)">
<pr-icon icon="'alert-triangle'" mode="'danger'" size="'md'" feather="true"></pr-icon>Custom template could not be loaded, please contact your administrator.</p
>
</span>
<!-- editor -->
<div class="mt-4">
<web-editor-form
@@ -1,116 +0,0 @@
<page-header
ng-if="ctrl.state.viewReady"
title="'Namespace access management'"
breadcrumbs="[
{ label:'Namespaces', link:'kubernetes.resourcePools' },
{
label:ctrl.pool.Namespace.Name,
link: 'kubernetes.resourcePools.resourcePool',
linkParams:{id: ctrl.pool.Namespace.Name}
},
'Access management'
]"
reload="true"
>
</page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<div ng-if="ctrl.state.viewReady">
<div class="row" ng-if="ctrl.pool">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="layers" title-text="Namespace"></rd-widget-header>
<rd-widget-body>
<table class="table">
<tbody>
<tr>
<td class="!pl-0">Name</td>
<td>
{{ ctrl.pool.Namespace.Name }}
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<rd-widget ng-if="ctrl.availableUsersAndTeams">
<rd-widget-header icon="user-x" title-text="Create access"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div
ng-if="!ctrl.isRBACEnabled"
class="small mx-[15px] mb-6 flex gap-1 rounded-lg border border-solid border-warning-5 bg-warning-2 p-4 text-warning-8 th-highcontrast:bg-yellow-11 th-highcontrast:text-white th-dark:bg-yellow-11 th-dark:text-white"
>
<div class="mt-0.5">
<pr-icon icon="'alert-triangle'" feather="true" class-name="'text-warning-7 th-dark:text-white th-highcontrast:text-white'"></pr-icon>
</div>
<div>
<p> Your cluster does not have Kubernetes role-based access control (RBAC) enabled. </p>
<p> This means you can't use Portainer RBAC functionality to regulate access to environment resources based on user roles. </p>
<p class="mb-0">
To enable RBAC, start the&nbsp;<a
class="th-highcontrast:text-blue-4 th-dark:text-blue-7"
href="https://kubernetes.io/docs/concepts/overview/components/#kube-apiserver"
target="_blank"
>API server</a
>&nbsp;with the&nbsp;<code class="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">--authorization-mode</code>&nbsp;flag set to a
comma-separated list that includes&nbsp;<code class="bg-gray-4 th-highcontrast:bg-black th-dark:bg-black">RBAC</code>, for example:&nbsp;
<code class="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">kube-apiserver --authorization-mode=Example1,RBAC,Example2</code>.
</p>
</div>
</div>
<span class="col-sm-12 small text-warning">
<p class="vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Adding user access will require the affected user(s) to logout and login for the changes to be taken into account.
</p>
</span>
</div>
<div class="form-group">
<label class="col-sm-3 col-lg-2 control-label text-left" for="users-selector"> Select user(s) and/or team(s) </label>
<div class="col-sm-9 col-lg-4">
<span class="small text-muted" ng-if="ctrl.availableUsersAndTeams.length === 0">
No user nor team access has been set on the environment. Head over to the
<a ui-sref="portainer.endpoints">Environments view</a> to manage them.
</span>
<namespace-access-users-selector
ng-if="ctrl.availableUsersAndTeams.length > 0"
input-id="users-selector"
value="ctrl.formValues.multiselectOutput"
options="ctrl.availableUsersAndTeams"
on-change="(ctrl.onUsersAndTeamsChange)"
></namespace-access-users-selector>
</div>
</div>
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<button
type="submit"
class="btn btn-primary btn-sm vertical-center !ml-0"
ng-disabled="ctrl.formValues.multiselectOutput.length === 0 || ctrl.actionInProgress"
ng-click="ctrl.authorizeAccess()"
button-spinner="ctrl.actionInProgress"
>
<span class="vertical-center" ng-hide="ctrl.state.actionInProgress"><pr-icon icon="'plus'" class="vertical-center"></pr-icon> Create access</span>
<span ng-show="ctrl.state.actionInProgress">Creating access...</span>
</button>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<namespace-access-datatable ng-if="ctrl.authorizedUsersAndTeams" dataset="ctrl.authorizedUsersAndTeams" on-remove="(ctrl.unauthorizeAccess)"> </namespace-access-datatable>
</div>
@@ -1,9 +0,0 @@
angular.module('portainer.kubernetes').component('kubernetesResourcePoolAccessView', {
templateUrl: './resourcePoolAccess.html',
controller: 'KubernetesResourcePoolAccessController',
controllerAs: 'ctrl',
bindings: {
$transition$: '<',
endpoint: '<',
},
});
@@ -1,145 +0,0 @@
import angular from 'angular';
import _ from 'lodash-es';
import { KubernetesPortainerConfigMapConfigName, KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapAccessKey } from 'Kubernetes/models/config-map/models';
import { UserAccessViewModel, TeamAccessViewModel } from 'Portainer/models/access';
import KubernetesConfigMapHelper from 'Kubernetes/helpers/configMapHelper';
import { getIsRBACEnabled } from '@/react/kubernetes/cluster/getIsRBACEnabled';
class KubernetesResourcePoolAccessController {
/* @ngInject */
constructor($async, $state, $scope, Notifications, KubernetesResourcePoolService, KubernetesConfigMapService, GroupService, AccessService, EndpointProvider) {
this.$async = $async;
this.$state = $state;
this.$scope = $scope;
this.EndpointProvider = EndpointProvider;
this.Notifications = Notifications;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.KubernetesConfigMapService = KubernetesConfigMapService;
this.GroupService = GroupService;
this.AccessService = AccessService;
this.onInit = this.onInit.bind(this);
this.authorizeAccessAsync = this.authorizeAccessAsync.bind(this);
this.unauthorizeAccessAsync = this.unauthorizeAccessAsync.bind(this);
this.onUsersAndTeamsChange = this.onUsersAndTeamsChange.bind(this);
this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
}
initAccessConfigMap(configMap) {
configMap.Name = KubernetesPortainerConfigMapConfigName;
configMap.Namespace = KubernetesPortainerConfigMapNamespace;
configMap.Data[KubernetesPortainerConfigMapAccessKey] = {};
return configMap;
}
/**
* Init
*/
async onInit() {
const endpoint = this.endpoint;
this.state = {
actionInProgress: false,
viewReady: false,
};
this.formValues = {
multiselectOutput: [],
};
// default to true if error is thrown
this.isRBACEnabled = true;
try {
const name = this.$transition$.params().id;
let [pool, configMap] = await Promise.all([
this.KubernetesResourcePoolService.get(name),
this.KubernetesConfigMapService.getAccess(KubernetesPortainerConfigMapNamespace, KubernetesPortainerConfigMapConfigName),
]);
this.isRBACEnabled = await getIsRBACEnabled(this.EndpointProvider.endpointID());
const group = await this.GroupService.group(endpoint.GroupId);
const roles = [];
const endpointAccesses = await this.AccessService.accesses(endpoint, group, roles);
this.pool = pool;
if (configMap.Id === 0) {
configMap = this.initAccessConfigMap(configMap);
}
configMap = KubernetesConfigMapHelper.parseJSONData(configMap);
this.authorizedUsersAndTeams = [];
this.accessConfigMap = configMap;
const poolAccesses = configMap.Data[KubernetesPortainerConfigMapAccessKey][name];
if (poolAccesses) {
this.authorizedUsersAndTeams = _.filter(endpointAccesses.authorizedUsersAndTeams, (item) => {
if (item instanceof UserAccessViewModel && poolAccesses.UserAccessPolicies) {
return poolAccesses.UserAccessPolicies[item.Id] !== undefined;
} else if (item instanceof TeamAccessViewModel && poolAccesses.TeamAccessPolicies) {
return poolAccesses.TeamAccessPolicies[item.Id] !== undefined;
}
return false;
});
}
this.availableUsersAndTeams = _.without(endpointAccesses.authorizedUsersAndTeams, ...this.authorizedUsersAndTeams);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve namespace information');
} finally {
this.state.viewReady = true;
}
}
$onInit() {
return this.$async(this.onInit);
}
/**
* Authorize access
*/
async authorizeAccessAsync() {
try {
this.state.actionInProgress = true;
const newAccesses = _.concat(this.authorizedUsersAndTeams, this.formValues.multiselectOutput);
const accessConfigMap = KubernetesConfigMapHelper.modifiyNamespaceAccesses(angular.copy(this.accessConfigMap), this.pool.Namespace.Name, newAccesses);
await this.KubernetesConfigMapService.updateAccess(accessConfigMap);
this.Notifications.success('Success', 'Access successfully created');
this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to create accesses');
}
}
onUsersAndTeamsChange(value) {
this.$scope.$evalAsync(() => {
this.formValues.multiselectOutput = value;
});
}
authorizeAccess() {
return this.$async(this.authorizeAccessAsync);
}
/**
*
*/
async unauthorizeAccessAsync(selectedItems) {
try {
this.state.actionInProgress = true;
const newAccesses = _.without(this.authorizedUsersAndTeams, ...selectedItems);
const accessConfigMap = KubernetesConfigMapHelper.modifiyNamespaceAccesses(angular.copy(this.accessConfigMap), this.pool.Namespace.Name, newAccesses);
await this.KubernetesConfigMapService.updateAccess(accessConfigMap);
this.Notifications.success('Success', 'Access successfully removed');
this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to remove accesses');
} finally {
this.state.actionInProgress = false;
}
}
unauthorizeAccess(selectedItems) {
return this.$async(this.unauthorizeAccessAsync, selectedItems);
}
}
export default KubernetesResourcePoolAccessController;
angular.module('portainer.kubernetes').controller('KubernetesResourcePoolAccessController', KubernetesResourcePoolAccessController);
@@ -15,23 +15,18 @@
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
<uib-tab-heading class="vertical-center"> <pr-icon icon="'layers'"></pr-icon> Namespace </uib-tab-heading>
<form class="form-horizontal" autocomplete="off" name="resourcePoolEditForm" style="padding: 20px; margin-top: 10px">
<!-- name-input -->
<div class="form-group">
<div class="col-sm-12">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>
{{ ctrl.pool.Namespace.Name }}
<span class="label label-info image-tag label-margins" ng-if="ctrl.isSystem">system</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<form class="form-horizontal widget-body" autocomplete="off" name="resourcePoolEditForm" style="margin-top: 10px">
<table class="table">
<tbody>
<tr>
<td class="w-[40%]">Name</td>
<td>
{{ ctrl.pool.Namespace.Name }}
<span class="label label-info image-tag label-margins" ng-if="ctrl.isSystem">system</span>
</td>
</tr>
</tbody>
</table>
<div class="col-sm-12 !p-0">
<annotations-be-teaser></annotations-be-teaser>
@@ -28,7 +28,7 @@
<pr-icon icon="'database'"></pr-icon>
Volume
</uib-tab-heading>
<div style="padding: 20px">
<div class="widget-body">
<table class="table">
<tbody>
<tr>

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