Compare commits

...

93 Commits

Author SHA1 Message Date
Oscar Zhou 8869b91b71 version: bump version to 2.37.0 (#1501) 2025-12-11 11:00:02 +13:00
Steven Kang 2406d67bfc feat(fcm): initial release (#1153)
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
Co-authored-by: James Player <james.player@portainer.io>
Co-authored-by: Cara Ryan <cara.ryan@portainer.io>
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Viktor Pettersson <viktor.pettersson@portainer.io>
Co-authored-by: Viktor Pettersson <viktor.grasljunga@gmail.com>
Co-authored-by: Malcolm Lockyer <segfault88@users.noreply.github.com>
Co-authored-by: RHCowan <50324595+RHCowan@users.noreply.github.com>
Co-authored-by: Robbie Cowan <robert.cowan@portainer.io>
2025-12-09 08:05:38 +09:00
Oscar Zhou f0266e9316 fix(stack/remote): fail to pull image in stack with relative path enabled [BE-12237] (#1493) 2025-12-09 08:59:19 +13:00
Chaim Lev-Ari c08f42315e feat(docker/host): disable browse for non admin [BE-12438] (#1484) 2025-12-08 16:51:52 -03:00
Chaim Lev-Ari d2649dac90 fix(docker/services): ignore missing EndpointSpec [BE-12460] (#1494) 2025-12-08 16:51:18 -03:00
LP B 300681055e fix(api): do not give away information on error (#1496) 2025-12-08 16:50:00 -03:00
andres-portainer 712dbc9396 fix(endpointedge): reject async edge environments from the edge job logs handler BE-12372 (#1488) 2025-12-08 15:05:32 -03:00
andres-portainer f6b8e8615f fix(endpointedge): fix an incorrect documentation comment BE-12372 (#1486) 2025-12-08 11:59:53 -03:00
andres-portainer 4826c13848 fix(endpointedge): add a check for the relation of an environment and an edge job before updating the logs BE-12372 (#1487) 2025-12-08 11:59:40 -03:00
Yajith Dayarathna 80f497a185 chore(ci): minor ci workflow updates (#1491) 2025-12-08 14:12:24 +13:00
LP B d2a9adb4be fix(compose): use project in compose start options (#1477) 2025-12-05 15:22:40 +01:00
Oscar Zhou 8675086441 fix(stack): "update the stack" button is disable in stakc deployed via web editor [BE-12456] (#1473) 2025-12-05 08:56:13 +13:00
Devon Steenberg b79e784764 fix(stacks): stack updating with container_name [BE-12443] (#1453) 2025-12-02 09:32:03 +13:00
Chaim Lev-Ari 93ba3e700e fix(ui/code-editor): keep search panel in editor layer [BE-12429] (#1452) 2025-11-27 14:32:57 +02:00
Chaim Lev-Ari bf6cb8d0b8 refactor(stacks): use formik in StackRedeployGitForm [BE-12430] (#1433) 2025-11-27 08:43:51 +02:00
Hannah Cooper 7010d7bf66 Update bug_report to include 2.33.5 and 2.36.0 (#1447) 2025-11-27 10:35:38 +13:00
Oscar Zhou 1a862157a0 fix(snapshot): prevent from returning SnapshotRaw data [BE-12431] (#1441) 2025-11-26 13:07:43 +13:00
Chaim Lev-Ari 532575cab5 refactor(stacks): migrate info tab to react [BE-12383] (#1415) 2025-11-25 13:17:26 +02:00
Chaim Lev-Ari 0794d0f89f refactor(docker/configs): migrate to react [BE-6541] (#1430) 2025-11-25 12:02:50 +02:00
Chaim Lev-Ari e227ffd6d8 feat(stacks): create webhook id only if needed [BE-12392] (#1432) 2025-11-25 10:48:15 +02:00
Devon Steenberg 5058b40871 chore(version): bump to v2.36.0 (#1434) 2025-11-25 11:09:49 +13:00
Chaim Lev-Ari 5d847b59b2 feat(analytics): remove matomo dependency [BE-12404] (#1431) 2025-11-24 16:30:03 +02:00
Oscar Zhou c8d44b9416 fix(edgestack): external label on k8s application deployed by edgestack [BE-12318] (#1428) 2025-11-22 09:04:31 +13:00
Oscar Zhou 14d67d1ec7 fix(edgestack): external label on k8s application deployed by edgestack [BE-12318] (#1385) 2025-11-21 12:44:42 +13:00
Hannah Cooper 6866faf4fe Update bug_report to include 2.33.4 (#1420) 2025-11-20 13:06:07 +13:00
Viktor Pettersson 567d628a52 fix(edge-stacks): inconsistent edge stack count BE-12285 (#1382) 2025-11-20 10:56:38 +13:00
Chaim Lev-Ari a3eab75405 refactor(registries): remove superfluous useEffect in PrivateRegistryFieldset [BE-12408] (#1396) 2025-11-19 08:12:11 +02:00
Chaim Lev-Ari 566f6b067c fix(environments): fix podman auto onboarding script [BE-12327] (#1395) 2025-11-18 14:30:23 +02:00
Chaim Lev-Ari e73d07281c fix(endpoints): Change syntax for multi-line commands in Windows (#1355)
Co-authored-by: Shawn <host@shawnsg.dev>
2025-11-18 08:48:32 +02:00
Steven Kang e59d4dea77 fix: CVE-2024-25621 - develop [R8S-639] (#1412) 2025-11-18 17:34:10 +13:00
Steven Kang 4ca5370b86 fix: CVE-2025-47913 - develop [R8S-638] (#1401) 2025-11-18 16:28:14 +13:00
Devon Steenberg e831971dd1 fix(docker): bump docker max api version [BE-12399] (#1392) 2025-11-18 11:27:16 +13:00
Steven Kang 99d996dde9 fix: CVE-2025-47906 and CVE-2025-47910 - develop [R8S-618] (#1389) 2025-11-18 08:57:00 +13:00
Malcolm Lockyer 712d31b416 fix(agent): for iamra and ecr login, detect errors and retry [be-12284] (#1362) 2025-11-17 11:51:09 +13:00
Steven Kang 0394855b2f feat: reorder environment creation types (#1359) 2025-11-17 10:09:19 +13:00
Chaim Lev-Ari 9024b021ee feat(environments): deprecate openamt [BE-12359] (#1390) 2025-11-16 09:55:00 +02:00
Chaim Lev-Ari 8071641179 refactor(stacks): convert editor to tab (#1374) 2025-11-12 15:44:13 +02:00
Chaim Lev-Ari 0075374241 fix(ui/datatables): show selected filter values [BE-11301] (#1387) 2025-11-12 15:21:17 +02:00
Chaim Lev-Ari c35ddc8c76 feat(git): hide user/pass for save creds [BE-10953] (#1376) 2025-11-12 15:20:20 +02:00
Oscar Zhou 4b4aef7ef8 fix(stack): apply new stack manual redeployment filed name to regular stack [BE-12384] (#1375) 2025-11-12 09:17:57 +13:00
Copilot 6db4a62e01 Fix swagger enum issues causing duplicate constants in generated code (#1373)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: deviantony <5485061+deviantony@users.noreply.github.com>
Co-authored-by: Anthony Lapenna <anthony.lapenna@portainer.io>
2025-11-12 08:45:08 +13:00
Chaim Lev-Ari db394b6145 feat(logs): filter activity logs by envs and users [BE-12275] (#1383) 2025-11-11 14:49:26 +02:00
Chaim Lev-Ari 53e7704724 feat(stacks): allow to rename stacks [BE-12317] (#1339) 2025-11-09 09:39:29 +02:00
Chaim Lev-Ari f607c7c271 reactor(stacks): migrate deploy git to react [BE-12382] (#1372) 2025-11-09 09:36:06 +02:00
Oscar Zhou 48c689e5d6 fix(registry): custom registry configure page doesn't reflect actual setting [BE-12385] (#1378) 2025-11-08 10:13:00 +13:00
Oscar Zhou 2f2251ff33 fix(registry): pulling private image from registry fails despite credential is valid [BE-12237] (#1303) 2025-11-08 10:12:17 +13:00
Devon Steenberg 29254d1a66 fix(proxy): replace Director with Rewrite field [BE-12328] (#1358) 2025-11-05 10:57:01 +13:00
Chaim Lev-Ari 19cbae1732 feat(registries): check dockerhub credentials [BE-12329] (#1338) 2025-11-04 18:46:37 +02:00
Chaim Lev-Ari 73ad27640c refactor(stacks): migrate duplication form to react [BE-12353] (#1357) 2025-11-04 18:44:54 +02:00
Chaim Lev-Ari 1be96e1bd1 fix(telemetry): update privacy policy url [BE-12350] (#1348) 2025-11-04 14:25:03 +02:00
Chaim Lev-Ari a9834be2ff fix(widget): remove fixed margin on button [BE-12344] (#1346) 2025-11-04 14:24:26 +02:00
Chaim Lev-Ari d8ab86d86f fix(templates): keep icon to their border size [BE-12349] (#1343) 2025-11-04 14:23:56 +02:00
Chaim Lev-Ari 3f1bd8e290 fix(ui): fix warnings in client-side tests [BE-12351] (#1342) 2025-11-04 14:23:11 +02:00
Chaim Lev-Ari 34a7d75e10 fix(edge-scripts): add podman auto onboarding script [BE-12327] (#1333) 2025-11-04 14:21:37 +02:00
Oscar Zhou ae53de42df fix(stack): stack prune service does not persist [BE-12314] (#1323) 2025-11-03 12:22:04 +13:00
Oscar Zhou b70321a0aa fix(edgestack): unify gitops update flow [BE-12184] (#1110) 2025-11-01 20:20:51 +13:00
Oscar Zhou 0ff39f9a61 refactor(stack): move stack update into transaction [BE-12244] (#1324) 2025-10-31 17:19:56 +13:00
Ali 876ba0fa0f fix: add titles to truncated text [r8s-610] (#1331)
Small behavioral change
2025-10-30 16:43:15 +13:00
Hannah Cooper c7c65d2f97 Update bug_report to include 2.33.3 (#1352) 2025-10-30 15:18:48 +13:00
andres-portainer 736f7e198f fix(CVE-2025-62725): upgrade github.com/docker/compose/v2 to v2.40.2 BE-12352 (#1345) 2025-10-29 18:17:46 -03:00
Viktor Pettersson 8cb3589fb8 chore(go.mod): pin github.com/robfig/cron/v3 to v3.0.1 due to lack of maintenance BE-12226 (#1334) 2025-10-24 10:00:09 +13:00
Chaim Lev-Ari 56530d8791 fix(sidebar): add copyright icon to CE (#1325) 2025-10-23 18:14:09 +03:00
Chaim Lev-Ari da6b0e3dcc refactor(registries): convert docker hub form to react (#1335) 2025-10-23 17:00:49 +03:00
Steven Kang eb02f99cae feat: crds support [r8s-580] (#1254)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
2025-10-23 11:07:03 +13:00
Chaim Lev-Ari cb0efae81c chore(gitops): upgrade parse-duration dep [r8s-608] (#1328) 2025-10-22 13:20:20 +03:00
Viktor Pettersson e5f98e6145 test(scheduler): use synctest to cut execution time by 95% BE-12226 (#1330) 2025-10-22 10:48:12 +13:00
Devon Steenberg 8a23007ad2 fix(deps): update github.com/container/image/v5 dep [BE-12212] (#1313) 2025-10-20 15:47:46 +13:00
Oscar Zhou 592b196848 fix(registry): selecting one item checked all items in registry access table [BE-12036] (#1318) 2025-10-20 12:55:32 +13:00
Ali 8eb273e54b docs(kubernetes): update Helm install docs link to /user/kubernetes/applications/manifest/helm [R8S-601] (#1317)
Minor docs change
2025-10-20 09:33:07 +13:00
Ali 78c7e752f9 chore(build): fix relative paths for make dev [r8s-588] (#1314) 2025-10-17 10:40:23 +13:00
Hannah Cooper 7c51a3b5ff Update bug report to include 2.35.0 (#1310) 2025-10-16 12:18:34 +13:00
Viktor Pettersson 3e77db4cee chore(version): bump to v2.35.0 (#1304) 2025-10-15 15:35:33 +13:00
Steven Kang c1c831fea3 feat: gitops for Helm [r8s-343] (#1252)
Co-authored-by: testA113 <aliharriss1995@gmail.com>
Co-authored-by: Ali <83188384+testA113@users.noreply.github.com>
2025-10-15 11:36:20 +13:00
Steven Kang 6734eab555 fix: add web socket headers for kubeconfig based access - develop [r8s-592] (#1288) 2025-10-10 13:41:07 +13:00
Viktor Pettersson 6ecfbf17c0 fix(autopatch): remove auto-patch feature flag BE-12086 (#1189) 2025-10-10 09:23:47 +13:00
Ali 42fe068db7 fix(security): fix typos in security policy [r8s-573] (#1278)
Co-authored-by: timbretimber <105982513+timbretimber@users.noreply.github.com>
2025-10-09 12:25:11 +13:00
Steven Kang 6b3db56ab2 fix: display dependency version for kubectl and helm - develop [R8S-501] (#1281) 2025-10-07 16:23:47 +13:00
Ali eee15d5ff2 chore(dev): update build scripts to support mac (darwin) [r8s-588] (#1279) 2025-10-07 13:36:17 +13:00
andres-portainer 7a618311d6 feat(boltdb): attempt to compact using a read-only database BE-12287 (#1267) 2025-09-30 19:10:20 -03:00
Oscar Zhou 7dba9ff885 fix(k8s): memory leak during k8s stack deployment [BE-12281] (#1266) 2025-10-01 08:33:01 +13:00
James Carppe 4c9c292316 Version bump for 2.33.2 (#1259) 2025-09-25 17:32:58 +12:00
James Player 00613efbd8 fix(kubernetes UI): Update ingress cache after updating (#1247) 2025-09-25 11:26:36 +12:00
andres-portainer b7384874cf feat(database): add a flag to compact on startup BE-12283 (#1255) 2025-09-24 18:44:09 -03:00
Ali c8ee2ca4a1 fix(rbac): redirect on unauthorized namespace [r8s-564] (#1244) 2025-09-24 22:09:28 +12:00
andres-portainer f97bb4a439 fix(edgestacks): add a missing webhook uniqueness check BE-12219 (#1250) 2025-09-23 17:21:13 -03:00
LP B d83b349016 fix(api/endpoints): edge stack status type filter no longer always include Pending envs (#1229) 2025-09-22 16:10:39 +02:00
Ali 657cd04af2 fix(cve): fix frontend CVEs [r8s-563] (#1239) 2025-09-22 10:15:29 +12:00
Oscar Zhou 24a092836b fix(activitylog): remove export limit and fix search function [BE-12270] (#1235) 2025-09-19 14:52:33 +12:00
andres-portainer 290374f6fc fix(kubernetes/cli): unexport a field BE-12259 (#1228) 2025-09-18 14:39:38 -03:00
andres-portainer 2e7acc73d8 fix(kubernetes/cli): fix a data-race BE-12259 (#1218) 2025-09-18 09:19:29 -03:00
Oscar Zhou 666d51482e fix(container): apply less accurate solution to calculate container status for swarm environment [BE-12256] (#1225) 2025-09-18 16:29:35 +12:00
Oscar Zhou eedf37d18a feat(edge): add option to allow always clone git repository [BE-12240] (#1215) 2025-09-17 18:25:42 +12:00
Viktor Pettersson 16f210966b fix(version): change API version support from LTS to STS (#1223) 2025-09-17 17:18:03 +12:00
436 changed files with 20109 additions and 2949 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ plugins:
- import
parserOptions:
ecmaVersion: 2018
ecmaVersion: latest
sourceType: module
project: './tsconfig.json'
ecmaFeatures:
+6
View File
@@ -94,7 +94,13 @@ body:
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 [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.36.0'
- '2.35.0'
- '2.34.0'
- '2.33.5'
- '2.33.4'
- '2.33.3'
- '2.33.2'
- '2.33.1'
- '2.33.0'
- '2.32.0'
+5
View File
@@ -9,3 +9,8 @@ linters:
- pattern: ^dataservices.DataStore.(EdgeGroup|EdgeJob|EdgeStack|EndpointRelation|Endpoint|GitCredential|Registry|ResourceControl|Role|Settings|Snapshot|Stack|Tag|User)$
msg: Use a transaction instead
analyze-types: true
exclusions:
rules:
- path: _test\.go
linters:
- forbidigo
-10
View File
@@ -1,9 +1,3 @@
# See: https://gist.github.com/asukakenji/f15ba7e588ac42795f421b48b8aede63
# For a list of valid GOOS and GOARCH values
# Note: these can be overriden on the command line e.g. `make PLATFORM=<platform> ARCH=<arch>`
PLATFORM=$(shell go env GOOS)
ARCH=$(shell go env GOARCH)
# build target, can be one of "production", "testing", "development"
ENV=development
WEBPACK_CONFIG=webpack/webpack.$(ENV).js
@@ -37,10 +31,6 @@ build-image: build-all ## Build the Portainer image locally
build-storybook: ## Build and serve the storybook files
yarn storybook:build
devops: clean deps build-client ## Build the everything target specifically for CI
echo "Building the devops binary..."
@./build/build_binary_azuredevops.sh "$(PLATFORM)" "$(ARCH)"
##@ Build dependencies
.PHONY: deps server-deps client-deps tidy
deps: server-deps client-deps ## Download all client and server build dependancies
+1
View File
@@ -56,6 +56,7 @@ func CLIFlags() *portainer.CLIFlags {
PullLimitCheckDisabled: kingpin.Flag("pull-limit-check-disabled", "Pull limit check").Envar(portainer.PullLimitCheckDisabledEnvVar).Default(defaultPullLimitCheckDisabled).Bool(),
TrustedOrigins: kingpin.Flag("trusted-origins", "List of trusted origins for CSRF protection. Separate multiple origins with a comma.").Envar(portainer.TrustedOriginsEnvVar).String(),
CSP: kingpin.Flag("csp", "Content Security Policy (CSP) header").Envar(portainer.CSPEnvVar).Default("true").Bool(),
CompactDB: kingpin.Flag("compact-db", "Enable database compaction on startup").Envar(portainer.CompactDBEnvVar).Default("false").Bool(),
}
}
+1 -1
View File
@@ -84,7 +84,7 @@ func initFileService(dataStorePath string) portainer.FileService {
}
func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService portainer.FileService, shutdownCtx context.Context) dataservices.DataStore {
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey)
connection, err := database.NewDatabase("boltdb", *flags.Data, secretKey, *flags.CompactDB)
if err != nil {
log.Fatal().Err(err).Msg("failed creating database connection")
}
+1
View File
@@ -53,4 +53,5 @@ type Connection interface {
UpdateObjectFunc(bucketName string, key []byte, object any, updateFn func()) error
ConvertToKey(v int) []byte
ConvertStringToKey(v string) []byte
}
+72 -8
View File
@@ -21,6 +21,9 @@ import (
const (
DatabaseFileName = "portainer.db"
EncryptedDatabaseFileName = "portainer.edb"
txMaxSize = 65536
compactedSuffix = ".compacted"
)
var (
@@ -35,6 +38,7 @@ type DbConnection struct {
InitialMmapSize int
EncryptionKey []byte
isEncrypted bool
Compact bool
*bolt.DB
}
@@ -132,15 +136,8 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
func (connection *DbConnection) Open() error {
log.Info().Str("filename", connection.GetDatabaseFileName()).Msg("loading PortainerDB")
// Now we open the db
databasePath := connection.GetDatabaseFilePath()
db, err := bolt.Open(databasePath, 0600, &bolt.Options{
Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
})
db, err := bolt.Open(databasePath, 0600, connection.boltOptions(connection.Compact))
if err != nil {
return err
}
@@ -149,6 +146,24 @@ func (connection *DbConnection) Open() error {
db.MaxBatchDelay = connection.MaxBatchDelay
connection.DB = db
if connection.Compact {
log.Info().Msg("compacting database")
if err := connection.compact(); err != nil {
log.Error().Err(err).Msg("failed to compact database")
// Close the read-only database and re-open in read-write mode
if err := connection.Close(); err != nil {
log.Warn().Err(err).Msg("failure to close the database after failed compaction")
}
connection.Compact = false
return connection.Open()
} else {
log.Info().Msg("database compaction completed")
}
}
return nil
}
@@ -218,6 +233,10 @@ func (connection *DbConnection) ConvertToKey(v int) []byte {
return b
}
func (connection *DbConnection) ConvertStringToKey(v string) []byte {
return []byte(v)
}
// keyToString Converts a key to a string value suitable for logging
func keyToString(b []byte) string {
if len(b) != 8 {
@@ -414,3 +433,48 @@ func (connection *DbConnection) RestoreMetadata(s map[string]any) error {
return err
}
// compact attempts to compact the database and replace it iff it succeeds
func (connection *DbConnection) compact() (err error) {
compactedPath := connection.GetDatabaseFilePath() + compactedSuffix
if err := os.Remove(compactedPath); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failure to remove an existing compacted database: %w", err)
}
compactedDB, err := bolt.Open(compactedPath, 0o600, connection.boltOptions(false))
if err != nil {
return fmt.Errorf("failure to create the compacted database: %w", err)
}
compactedDB.MaxBatchSize = connection.MaxBatchSize
compactedDB.MaxBatchDelay = connection.MaxBatchDelay
if err := bolt.Compact(compactedDB, connection.DB, txMaxSize); err != nil {
return fmt.Errorf("failure to compact the database: %w",
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
}
if err := os.Rename(compactedPath, connection.GetDatabaseFilePath()); err != nil {
return fmt.Errorf("failure to move the compacted database: %w",
errors.Join(err, compactedDB.Close(), os.Remove(compactedPath)))
}
if err := connection.Close(); err != nil {
log.Warn().Err(err).Msg("failure to close the database after compaction")
}
connection.DB = compactedDB
return nil
}
func (connection *DbConnection) boltOptions(readOnly bool) *bolt.Options {
return &bolt.Options{
Timeout: 1 * time.Second,
InitialMmapSize: connection.InitialMmapSize,
FreelistType: bolt.FreelistMapType,
NoFreelistSync: true,
ReadOnly: readOnly,
}
}
+60
View File
@@ -5,7 +5,11 @@ import (
"path"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.etcd.io/bbolt"
)
func Test_NeedsEncryptionMigration(t *testing.T) {
@@ -119,3 +123,59 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
})
}
}
func TestDBCompaction(t *testing.T) {
db := &DbConnection{Path: t.TempDir()}
err := db.Open()
require.NoError(t, err)
err = db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("testbucket"))
if err != nil {
return err
}
b.Put([]byte("key"), []byte("value"))
return nil
})
require.NoError(t, err)
err = db.Close()
require.NoError(t, err)
// Reopen the DB to trigger compaction
db.Compact = true
err = db.Open()
require.NoError(t, err)
// Check that the data is still there
err = db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("testbucket"))
if b == nil {
return nil
}
val := b.Get([]byte("key"))
require.Equal(t, []byte("value"), val)
return nil
})
require.NoError(t, err)
err = db.Close()
require.NoError(t, err)
// Failures
compactedPath := db.GetDatabaseFilePath() + compactedSuffix
err = os.Mkdir(compactedPath, 0o755)
require.NoError(t, err)
f, err := os.Create(filesystem.JoinPaths(compactedPath, "somefile"))
require.NoError(t, err)
require.NoError(t, f.Close())
err = db.Open()
require.NoError(t, err)
}
+2 -1
View File
@@ -8,11 +8,12 @@ import (
)
// NewDatabase should use config options to return a connection to the requested database
func NewDatabase(storeType, storePath string, encryptionKey []byte) (connection portainer.Connection, err error) {
func NewDatabase(storeType, storePath string, encryptionKey []byte, compact bool) (connection portainer.Connection, err error) {
if storeType == "boltdb" {
return &boltdb.DbConnection{
Path: storePath,
EncryptionKey: encryptionKey,
Compact: compact,
}, nil
}
+24
View File
@@ -0,0 +1,24 @@
package database
import (
"testing"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/filesystem"
"github.com/stretchr/testify/require"
)
func TestNewDatabase(t *testing.T) {
dbPath := filesystem.JoinPaths(t.TempDir(), "test.db")
connection, err := NewDatabase("boltdb", dbPath, nil, false)
require.NoError(t, err)
require.NotNil(t, connection)
_, ok := connection.(*boltdb.DbConnection)
require.True(t, ok)
connection, err = NewDatabase("unknown", dbPath, nil, false)
require.Error(t, err)
require.Nil(t, connection)
}
+3
View File
@@ -50,6 +50,9 @@ func (m mockConnection) ViewTx(fn func(portainer.Transaction) error) error {
func (m mockConnection) ConvertToKey(v int) []byte {
return []byte(strconv.Itoa(v))
}
func (c mockConnection) ConvertStringToKey(v string) []byte {
return []byte(v)
}
func TestReadAll(t *testing.T) {
service := BaseDataService[testObject, int]{
+70
View File
@@ -0,0 +1,70 @@
package version
import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
"github.com/portainer/portainer/api/dataservices"
)
type ServiceTx struct {
dataservices.BaseDataServiceTx[models.Version, int] // ID is not used
}
func (tx ServiceTx) InstanceID() (string, error) {
v, err := tx.Version()
if err != nil {
return "", err
}
return v.InstanceID, nil
}
func (tx ServiceTx) UpdateInstanceID(ID string) error {
v, err := tx.Version()
if err != nil {
if !dataservices.IsErrObjectNotFound(err) {
return err
}
v = &models.Version{}
}
v.InstanceID = ID
return tx.UpdateVersion(v)
}
func (tx ServiceTx) Edition() (portainer.SoftwareEdition, error) {
v, err := tx.Version()
if err != nil {
return 0, err
}
return portainer.SoftwareEdition(v.Edition), nil
}
func (tx ServiceTx) Version() (*models.Version, error) {
var v models.Version
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
if err != nil {
return nil, err
}
return &v, nil
}
func (tx ServiceTx) UpdateVersion(version *models.Version) error {
return tx.Tx.UpdateObject(BucketName, []byte(versionKey), version)
}
func (tx ServiceTx) SchemaVersion() (string, error) {
var v models.Version
err := tx.Tx.GetObject(BucketName, []byte(versionKey), &v)
if err != nil {
return "", err
}
return v.SchemaVersion, nil
}
+10
View File
@@ -33,6 +33,16 @@ func NewService(connection portainer.Connection) (*Service, error) {
}, nil
}
func (service *Service) Tx(tx portainer.Transaction) ServiceTx {
return ServiceTx{
BaseDataServiceTx: dataservices.BaseDataServiceTx[models.Version, int]{
Bucket: BucketName,
Connection: service.connection,
Tx: tx,
},
}
}
func (service *Service) SchemaVersion() (string, error) {
v, err := service.Version()
if err != nil {
@@ -614,7 +614,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.34.0",
"KubectlShellImage": "portainer/kubectl-shell:2.37.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -943,7 +943,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.34.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.37.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null
}
+1 -1
View File
@@ -44,7 +44,7 @@ func NewTestStore(t testing.TB, init, secure bool) (bool, *Store, func(), error)
secretKey = nil
}
connection, err := database.NewDatabase("boltdb", storePath, secretKey)
connection, err := database.NewDatabase("boltdb", storePath, secretKey, false)
if err != nil {
panic(err)
}
+2 -2
View File
@@ -7,12 +7,12 @@ import (
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/containers/image/v5/docker"
imagetypes "github.com/containers/image/v5/types"
"github.com/docker/docker/api/types/image"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"go.podman.io/image/v5/docker"
imagetypes "go.podman.io/image/v5/types"
)
// Options holds docker registry object options
+1 -1
View File
@@ -7,11 +7,11 @@ import (
"strings"
"text/template"
"github.com/containers/image/v5/docker/reference"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/image"
"github.com/opencontainers/go-digest"
"github.com/pkg/errors"
"go.podman.io/image/v5/docker/reference"
)
type ImageID string
+2 -2
View File
@@ -3,8 +3,8 @@ package images
import (
"strings"
"github.com/containers/image/v5/docker"
"github.com/containers/image/v5/types"
"go.podman.io/image/v5/docker"
"go.podman.io/image/v5/types"
)
func ParseReference(imageStr string) (types.ImageReference, error) {
+34 -1
View File
@@ -3,6 +3,7 @@ package stats
import (
"context"
"errors"
"strings"
"sync"
"github.com/docker/docker/api/types/container"
@@ -20,7 +21,11 @@ type DockerClient interface {
ContainerInspect(ctx context.Context, containerID string) (container.InspectResponse, error)
}
func CalculateContainerStats(ctx context.Context, cli DockerClient, containers []container.Summary) (ContainerStats, error) {
func CalculateContainerStats(ctx context.Context, cli DockerClient, isSwarm bool, containers []container.Summary) (ContainerStats, error) {
if isSwarm {
return CalculateContainerStatsForSwarm(containers), nil
}
var running, stopped, healthy, unhealthy int
var mu sync.Mutex
@@ -90,3 +95,31 @@ func getContainerStatus(state *container.State) ContainerStats {
return stat
}
// This is a temporary workaround to calculate container stats for Swarm
// TODO: Remove this once we have a proper way to calculate container stats for Swarm
func CalculateContainerStatsForSwarm(containers []container.Summary) ContainerStats {
var running, stopped, healthy, unhealthy int
for _, container := range containers {
switch container.State {
case "running":
running++
case "exited", "stopped":
stopped++
}
if strings.Contains(container.Status, "(healthy)") {
healthy++
} else if strings.Contains(container.Status, "(unhealthy)") {
unhealthy++
}
}
return ContainerStats{
Running: running,
Stopped: stopped,
Healthy: healthy,
Unhealthy: unhealthy,
Total: len(containers),
}
}
+21 -2
View File
@@ -79,7 +79,7 @@ func TestCalculateContainerStats(t *testing.T) {
// Call the function and measure time
startTime := time.Now()
stats, err := CalculateContainerStats(context.Background(), mockClient, containers)
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
require.NoError(t, err, "failed to calculate container stats")
duration := time.Since(startTime)
@@ -120,7 +120,7 @@ func TestCalculateContainerStatsAllErrors(t *testing.T) {
mockClient.On("ContainerInspect", mock.Anything, "container2").Return(container.InspectResponse{}, errors.New("permission denied"))
// Call the function
stats, err := CalculateContainerStats(context.Background(), mockClient, containers)
stats, err := CalculateContainerStats(context.Background(), mockClient, false, containers)
// Assert that an error was returned
require.Error(t, err, "should return error when all containers fail to inspect")
@@ -232,3 +232,22 @@ func TestGetContainerStatus(t *testing.T) {
})
}
}
func TestCalculateContainerStatsForSwarm(t *testing.T) {
containers := []container.Summary{
{State: "running"},
{State: "running", Status: "Up 5 minutes (healthy)"},
{State: "exited"},
{State: "stopped"},
{State: "running", Status: "Up 10 minutes"},
{State: "running", Status: "Up about an hour (unhealthy)"},
}
stats := CalculateContainerStatsForSwarm(containers)
assert.Equal(t, 4, stats.Running)
assert.Equal(t, 2, stats.Stopped)
assert.Equal(t, 1, stats.Healthy)
assert.Equal(t, 1, stats.Unhealthy)
assert.Equal(t, 6, stats.Total)
}
+28 -3
View File
@@ -49,17 +49,34 @@ type (
// Is relative path supported
SupportRelativePath bool
// AlwaysCloneGitRepoForRelativePath is a flag indicating if the agent must always clone the git repository for relative path.
// This field is only valid when SupportRelativePath is true.
// Used only for EE
AlwaysCloneGitRepoForRelativePath bool
// Mount point for relative path
FilesystemPath string
// Used only for EE
// EnvVars is a list of environment variables to inject into the stack
EnvVars []portainer.Pair
// Used only for EE async edge agent
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool
// ForceUpdate is a flag indicating if the agent must force the update of the stack.
// Used only for EE
ForceUpdate bool
DeployerOptionsPayload DeployerOptionsPayload
// Used only for EE async edge agent
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
// Deprecated(2.36): use DeployerOptionsPayload.ForceRecreate instead
ReadyRePullImage bool
// CreatedBy is the username that created this stack
// Used for adding labels to Kubernetes manifests
CreatedBy string
// CreatedByUserId is the user ID that created this stack
// Used for adding labels to Kubernetes manifests
CreatedByUserId string
}
DeployerOptionsPayload struct {
@@ -72,6 +89,14 @@ type (
// This flag drives `docker compose down --volumes` option
// Used only for EE
RemoveVolumes bool
// ForceRecreate is a flag indicating if the agent must force the redeployment of the stack.
// This field is only used when the Force Redeployment is triggered.
// Once the stack is redeployed, this field will be reset to false.
// For standard edge agent, this field is used in agent side
// For async edge agent, this field is used in both agent side and server side.
// This flag drives `docker compose up --force-recreate` option
ForceRecreate bool
}
// RegistryCredentials holds the credentials for a Docker registry.
+2 -2
View File
@@ -13,7 +13,7 @@ import (
)
// UpdateGitObject updates a git object based on its config
func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, forceUpdate, enableVersionFolder bool, projectPath string) (bool, string, error) {
func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *gittypes.RepoConfig, enableVersionFolder bool, projectPath string) (bool, string, error) {
if gitConfig == nil {
return false, "", nil
}
@@ -43,7 +43,7 @@ func UpdateGitObject(gitService portainer.GitService, objId string, gitConfig *g
hashChanged := !strings.EqualFold(newHash, gitConfig.ConfigHash)
if !hashChanged && !forceUpdate {
if !hashChanged {
log.Debug().
Str("hash", newHash).
Str("url", gitConfig.URL).
+1 -1
View File
@@ -143,7 +143,7 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.H
stackCount = len(stacks)
}
containersStats, err := stats.CalculateContainerStats(r.Context(), cli, containers)
containersStats, err := stats.CalculateContainerStats(r.Context(), cli, info.Swarm.ControlAvailable, containers)
if err != nil {
return httperror.InternalServerError("Unable to retrieve Docker containers stats", err)
}
@@ -27,7 +27,7 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
var edgeStack *portainer.EdgeStack
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData.ID, r)
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData, r)
return err
}); err != nil {
switch {
@@ -43,14 +43,14 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
return response.JSON(w, edgeStack)
}
func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method string, dryrun bool, userID portainer.UserID, r *http.Request) (*portainer.EdgeStack, error) {
func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method string, dryrun bool, tokenData *portainer.TokenData, r *http.Request) (*portainer.EdgeStack, error) {
switch method {
case "string":
return handler.createEdgeStackFromFileContent(r, tx, dryrun)
return handler.createEdgeStackFromFileContent(r, tx, tokenData, dryrun)
case "repository":
return handler.createEdgeStackFromGitRepository(r, tx, dryrun, userID)
return handler.createEdgeStackFromGitRepository(r, tx, tokenData, dryrun)
case "file":
return handler.createEdgeStackFromFileUpload(r, tx, dryrun)
return handler.createEdgeStackFromFileUpload(r, tx, tokenData, dryrun)
}
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
@@ -1,11 +1,13 @@
package edgestacks
import (
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -99,7 +101,7 @@ func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error {
// @failure 500 "Internal server error"
// @failure 503 "Edge compute features are disabled"
// @router /edge_stacks/create/file [post]
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
payload := &edgeStackFromFileUploadPayload{}
if err := payload.Validate(r); err != nil {
return nil, err
@@ -113,6 +115,8 @@ func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, tx datase
if dryrun {
return stack, nil
}
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
return handler.storeFileContent(tx, stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent)
@@ -9,6 +9,7 @@ import (
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/validate"
@@ -103,7 +104,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
// @failure 500 "Internal server error"
// @failure 503 "Edge compute features are disabled"
// @router /edge_stacks/create/repository [post]
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) {
func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
var payload edgeStackFromGitRepositoryPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return nil, err
@@ -133,8 +134,11 @@ func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, tx dat
}
}
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
return handler.storeManifestFromGitRepository(tx, stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig)
return handler.storeManifestFromGitRepository(tx, stackFolder, relatedEndpointIds, payload.DeploymentType, tokenData.ID, repoConfig)
})
}
@@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/filesystem"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/edge"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -74,7 +75,7 @@ func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error {
// @failure 500 "Internal server error"
// @failure 503 "Edge compute features are disabled"
// @router /edge_stacks/create/string [post]
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, dryrun bool) (*portainer.EdgeStack, error) {
func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
var payload edgeStackFromStringPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return nil, err
@@ -85,6 +86,9 @@ func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, tx datas
return nil, errors.Wrap(err, "failed to create Edge stack object")
}
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
if dryrun {
return stack, nil
}
@@ -74,7 +74,7 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, payload.EndpointID))
}
var stack *portainer.EdgeStack
@@ -24,8 +24,8 @@ func (payload *logsPayload) Validate(r *http.Request) error {
}
// endpointEdgeJobsLogs
// @summary Inspect an EdgeJob Log
// @description **Access policy**: public
// @summary Update the logs collected from an Edge Job
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
// @tags edge, endpoints
// @accept json
// @produce json
@@ -34,6 +34,7 @@ func (payload *logsPayload) Validate(r *http.Request) error {
// @success 200
// @failure 500
// @failure 400
// @failure 403
// @router /endpoints/{id}/edge/jobs/{jobID}/logs [post]
func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoint, err := middlewares.FetchEndpoint(r)
@@ -42,35 +43,35 @@ func (handler *Handler) endpointEdgeJobsLogs(w http.ResponseWriter, r *http.Requ
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
}
edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID")
if err != nil {
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment name: %s", err, endpoint.Name))
return httperror.BadRequest("Invalid edge job identifier route variable", fmt.Errorf("invalid Edge job route variable: %w. Environment ID: %d", err, endpoint.ID))
}
var payload logsPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment name: %s", err, endpoint.Name))
return httperror.BadRequest("Invalid request payload", fmt.Errorf("invalid Edge job request payload: %w. Environment ID: %d", err, endpoint.ID))
}
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
return handler.getEdgeJobLobs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload)
return handler.updateEdgeJobLogs(tx, endpoint.ID, portainer.EdgeJobID(edgeJobID), payload)
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
return httpErr
}
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
}
return response.JSON(w, nil)
}
func (handler *Handler) getEdgeJobLobs(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID, payload logsPayload) error {
func (handler *Handler) updateEdgeJobLogs(tx dataservices.DataStoreTx, endpointID portainer.EndpointID, edgeJobID portainer.EdgeJobID, payload logsPayload) error {
endpoint, err := tx.Endpoint().Endpoint(endpointID)
if tx.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
@@ -85,6 +86,11 @@ func (handler *Handler) getEdgeJobLobs(tx dataservices.DataStoreTx, endpointID p
return httperror.InternalServerError("Unable to find an edge job with the specified identifier inside the database", err)
}
if resp, err := handler.buildSchedules(tx, endpoint, []portainer.EdgeJob{*edgeJob}); err != nil || len(resp) == 0 {
return httperror.InternalServerError("Unable to verify if the edge job is assigned to the environment",
fmt.Errorf("unable to verify if the edge job is assigned to the environment: %w. Environment name: %s", err, endpoint.Name))
}
if err := handler.FileService.StoreEdgeJobTaskLogFileFromBytes(strconv.Itoa(int(edgeJobID)), strconv.Itoa(int(endpoint.ID)), []byte(payload.FileContent)); err != nil {
return httperror.InternalServerError("Unable to save task log to the filesystem", err)
}
@@ -0,0 +1,40 @@
package endpointedge
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/stretchr/testify/require"
)
func TestUpdateUnrelatedEdgeJobLogs(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
h := &Handler{DataStore: store}
endpointID := portainer.EndpointID(2)
edgeJobID := portainer.EdgeJobID(3)
payload := logsPayload{FileContent: "log content"}
err := store.Endpoint().Create(&portainer.Endpoint{
ID: endpointID,
Name: "test-endpoint",
})
require.NoError(t, err)
err = store.EdgeJob().CreateWithID(edgeJobID, &portainer.EdgeJob{
ID: edgeJobID,
Name: "test-edge-job",
})
require.NoError(t, err)
// There is no relation between the edge job and the endpoint, so the
// update must fail
err = store.UpdateTx(func(tx dataservices.DataStoreTx) error {
return h.updateEdgeJobLogs(tx, endpointID, edgeJobID, payload)
})
require.Error(t, err)
}
@@ -40,18 +40,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
}
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
}
edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId")
if err != nil {
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment name: %s", err, endpoint.Name))
return httperror.BadRequest("Invalid edge stack identifier route variable", fmt.Errorf("invalid Edge stack route variable: %w. Environment ID: %d", err, endpoint.ID))
}
s, err, _ := edgeStackSingleFlightGroup.Do(strconv.Itoa(edgeStackID), func() (any, error) {
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment name: %s", err, endpoint.Name))
return nil, httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("unable to find the Edge stack from database: %w. Environment ID: %d", err, endpoint.ID))
}
return edgeStack, err
@@ -62,7 +62,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
return httpErr
}
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", fmt.Errorf("failed to find Edge stack from the database: %w. Environment ID: %d", err, endpoint.ID))
}
// WARNING: this variable must not be mutated
@@ -71,7 +71,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
fileName := edgeStack.EntryPoint
if endpointutils.IsDockerEndpoint(endpoint) {
if fileName == "" {
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment name: %s", endpoint.Name))
return httperror.BadRequest("Docker is not supported by this stack", fmt.Errorf("no filename is provided for the Docker endpoint. Environment ID: %d", endpoint.ID))
}
}
@@ -84,18 +84,18 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
fileName = edgeStack.ManifestPath
if fileName == "" {
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment name: %s", endpoint.Name))
return httperror.BadRequest("Kubernetes is not supported by this stack", fmt.Errorf("no filename is provided for the Kubernetes endpoint. Environment ID: %d", endpoint.ID))
}
}
dirEntries, err := filesystem.LoadDir(edgeStack.ProjectPath)
if err != nil {
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("Unable to load repository", fmt.Errorf("failed to load project directory: %w. Environment ID: %d", err, endpoint.ID))
}
fileContent, err := filesystem.FilterDirForCompatibility(dirEntries, fileName, endpoint.Agent.Version)
if err != nil {
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("File not found", fmt.Errorf("unable to find file: %w. Environment ID: %d", err, endpoint.ID))
}
dirEntries = filesystem.FilterDirForEntryFile(dirEntries, fileName)
@@ -106,5 +106,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
StackFileContent: fileContent,
Name: edgeStack.Name,
Namespace: namespace,
CreatedBy: edgeStack.CreatedBy,
CreatedByUserId: edgeStack.CreatedByUserId,
})
}
@@ -97,13 +97,13 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
firstConn := endpoint.LastCheckInDate == 0
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment name: %s", err, endpoint.Name))
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("unauthorized Edge endpoint operation: %w. Environment ID: %d", err, endpoint.ID))
}
handler.DataStore.Endpoint().UpdateHeartbeat(endpoint.ID)
if err := handler.requestBouncer.TrustedEdgeEnvironmentAccess(handler.DataStore, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment name: %s", err, endpoint.Name))
return httperror.Forbidden("Permission denied to access environment. The device has not been trusted yet", fmt.Errorf("untrusted Edge environment access: %w. Environment ID: %d", err, endpoint.ID))
}
var statusResponse *endpointEdgeStatusInspectResponse
@@ -113,11 +113,11 @@ func (handler *Handler) endpointEdgeStatusInspect(w http.ResponseWriter, r *http
}); err != nil {
var httpErr *httperror.HandlerError
if errors.As(err, &httpErr) {
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment name: %s", httpErr.Err, endpoint.Name)
httpErr.Err = fmt.Errorf("edge polling error: %w. Environment ID: %d", httpErr.Err, endpoint.ID)
return httpErr
}
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment name: %s", err, endpoint.Name))
return httperror.InternalServerError("Unexpected error", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, endpoint.ID))
}
return cacheResponse(w, endpoint.ID, *statusResponse)
@@ -170,7 +170,7 @@ func (handler *Handler) inspectStatus(tx dataservices.DataStoreTx, r *http.Reque
Credentials: tunnel.Credentials,
}
schedules, handlerErr := handler.buildSchedules(tx, endpoint)
schedules, handlerErr := handler.buildAllSchedules(tx, endpoint)
if handlerErr != nil {
return nil, handlerErr
}
@@ -208,14 +208,18 @@ func parseAgentPlatform(r *http.Request) (portainer.EndpointType, error) {
}
}
func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) {
schedules := []edgeJobResponse{}
func (handler *Handler) buildAllSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint) ([]edgeJobResponse, *httperror.HandlerError) {
edgeJobs, err := tx.EdgeJob().ReadAll()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve Edge Jobs", err)
}
return handler.buildSchedules(tx, endpoint, edgeJobs)
}
func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *portainer.Endpoint, edgeJobs []portainer.EdgeJob) ([]edgeJobResponse, *httperror.HandlerError) {
schedules := []edgeJobResponse{}
endpointGroups, err := tx.EndpointGroup().ReadAll()
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve endpoint groups", err)
@@ -240,17 +244,10 @@ func (handler *Handler) buildSchedules(tx dataservices.DataStoreTx, endpoint *po
continue
}
var collectLogs bool
if _, ok := job.GroupLogsCollection[endpoint.ID]; ok {
collectLogs = job.GroupLogsCollection[endpoint.ID].CollectLogs
} else {
collectLogs = job.Endpoints[endpoint.ID].CollectLogs
}
schedule := edgeJobResponse{
ID: job.ID,
CronExpression: job.CronExpression,
CollectLogs: collectLogs,
CollectLogs: job.GroupLogsCollection[endpoint.ID].CollectLogs || job.Endpoints[endpoint.ID].CollectLogs,
Version: job.Version,
}
@@ -20,7 +20,6 @@ import (
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @param excludeSnapshot query bool false "if true, the snapshot data won't be retrieved"
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
// @success 200 {object} portainer.Endpoint "Success"
// @failure 400 "Invalid request"
// @failure 404 "Environment(Endpoint) not found"
@@ -53,10 +52,9 @@ func (handler *Handler) endpointInspect(w http.ResponseWriter, r *http.Request)
endpoint.ComposeSyntaxMaxVersion = handler.ComposeStackManager.ComposeSyntaxMaxVersion()
excludeSnapshot, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshot", true)
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
if !excludeSnapshot {
if err := handler.SnapshotService.FillSnapshotData(endpoint, !excludeRaw); err != nil {
if err := handler.SnapshotService.FillSnapshotData(endpoint, false); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
+1 -3
View File
@@ -45,7 +45,6 @@ const (
// @param edgeDeviceUntrusted query bool false "if true, show only untrusted edge agents, if false show only trusted edge agents (relevant only for edge agents)"
// @param edgeCheckInPassedSeconds query number false "if bigger then zero, show only edge agents that checked-in in the last provided seconds (relevant only for edge agents)"
// @param excludeSnapshots query bool false "if true, the snapshot data won't be retrieved"
// @param excludeSnapshotRaw query bool false "if true, the SnapshotRaw field won't be retrieved"
// @param name query string false "will return only environments(endpoints) with this name"
// @param edgeStackId query portainer.EdgeStackID false "will return the environements of the specified edge stack"
// @param edgeStackStatus query string false "only applied when edgeStackId exists. Filter the returned environments based on their deployment status in the stack (not the environment status!)" Enum("Pending", "Ok", "Error", "Acknowledged", "Remove", "RemoteUpdateSuccess", "ImagesPulled")
@@ -63,7 +62,6 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
excludeRaw, _ := request.RetrieveBooleanQueryParameter(r, "excludeSnapshotRaw", true)
endpointGroups, err := handler.DataStore.EndpointGroup().ReadAll()
if err != nil {
@@ -118,7 +116,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
endpointutils.UpdateEdgeEndpointHeartbeat(&paginatedEndpoints[idx], settings)
if !query.excludeSnapshots {
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], !excludeRaw); err != nil {
if err := handler.SnapshotService.FillSnapshotData(&paginatedEndpoints[idx], false); err != nil {
return httperror.InternalServerError("Unable to add snapshot data", err)
}
}
+44 -37
View File
@@ -256,7 +256,7 @@ func (handler *Handler) filterEndpointsByQuery(
return filteredEndpoints, totalAvailableEndpoints, nil
}
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, envId portainer.EndpointID, statusFilter portainer.EdgeStackStatusType) bool {
func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusForEnv, statusFilter portainer.EdgeStackStatusType) bool {
// consider that if the env has no status in the stack it is in Pending state
if statusFilter == portainer.EdgeStackStatusPending {
return stackStatus == nil || len(stackStatus.Status) == 0
@@ -272,55 +272,62 @@ func endpointStatusInStackMatchesFilter(stackStatus *portainer.EdgeStackStatusFo
}
func filterEndpointsByEdgeStack(endpoints []portainer.Endpoint, edgeStackId portainer.EdgeStackID, statusFilter *portainer.EdgeStackStatusType, datastore dataservices.DataStore) ([]portainer.Endpoint, error) {
stack, err := datastore.EdgeStack().EdgeStack(edgeStackId)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve edge stack from the database")
}
envIds := roar.Roar[portainer.EndpointID]{}
for _, edgeGroupdId := range stack.EdgeGroups {
edgeGroup, err := datastore.EdgeGroup().Read(edgeGroupdId)
var filteredEndpoints []portainer.Endpoint
if err := datastore.ViewTx(func(tx dataservices.DataStoreTx) error {
stack, err := tx.EdgeStack().EdgeStack(edgeStackId)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve edge group from the database")
return errors.WithMessage(err, "Unable to retrieve edge stack from the database")
}
if edgeGroup.Dynamic {
endpointIDs, err := edgegroups.GetEndpointsByTags(datastore, edgeGroup.TagIDs, edgeGroup.PartialMatch)
envIds := roar.Roar[portainer.EndpointID]{}
for _, edgeGroupId := range stack.EdgeGroups {
edgeGroup, err := tx.EdgeGroup().Read(edgeGroupId)
if err != nil {
return nil, errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
return errors.WithMessage(err, "Unable to retrieve edge group from the database")
}
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
if edgeGroup.Dynamic {
endpointIDs, err := edgegroups.GetEndpointsByTags(tx, edgeGroup.TagIDs, edgeGroup.PartialMatch)
if err != nil {
return errors.WithMessage(err, "Unable to retrieve environments and environment groups for Edge group")
}
edgeGroup.EndpointIDs = roar.FromSlice(endpointIDs)
}
envIds.Union(edgeGroup.EndpointIDs)
}
envIds.Union(edgeGroup.EndpointIDs)
}
filteredEnvIds := roar.Roar[portainer.EndpointID]{}
filteredEnvIds.Union(envIds)
if statusFilter != nil {
var innerErr error
if statusFilter != nil {
var innerErr error
envIds.Iterate(func(envId portainer.EndpointID) bool {
edgeStackStatus, err := tx.EdgeStackStatus().Read(edgeStackId, envId)
if err != nil && !dataservices.IsErrObjectNotFound(err) {
innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
return false
}
if !endpointStatusInStackMatchesFilter(edgeStackStatus, *statusFilter) {
filteredEnvIds.Remove(envId)
}
envIds.Iterate(func(envId portainer.EndpointID) bool {
edgeStackStatus, err := datastore.EdgeStackStatus().Read(edgeStackId, envId)
if dataservices.IsErrObjectNotFound(err) {
return true
} else if err != nil {
innerErr = errors.WithMessagef(err, "Unable to retrieve edge stack status for environment %d", envId)
return false
})
if innerErr != nil {
return innerErr
}
if !endpointStatusInStackMatchesFilter(edgeStackStatus, portainer.EndpointID(envId), *statusFilter) {
envIds.Remove(envId)
}
return true
})
if innerErr != nil {
return nil, innerErr
}
filteredEndpoints = filteredEndpointsByIds(endpoints, filteredEnvIds)
return nil
}); err != nil {
return nil, err
}
filteredEndpoints := filteredEndpointsByIds(endpoints, envIds)
return filteredEndpoints, nil
}
+88 -26
View File
@@ -5,6 +5,7 @@ import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
@@ -297,42 +298,103 @@ func TestFilterEndpointsByEdgeStack(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
endpoints := []portainer.Endpoint{
{ID: 1, Name: "Endpoint 1"},
{ID: 2, Name: "Endpoint 2"},
{ID: 3, Name: "Endpoint 3"},
{ID: 1, Name: "Endpoint 1", Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 2, Name: "Endpoint 2", TagIDs: []portainer.TagID{1}, Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 3, Name: "Endpoint 3", TagIDs: []portainer.TagID{1}, Type: portainer.EdgeAgentOnDockerEnvironment, UserTrusted: true},
{ID: 4, Name: "Endpoint 4"},
}
edgeStackId := portainer.EdgeStackID(1)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.Tag().Create(&portainer.Tag{ID: 1, Name: "tag", Endpoints: map[portainer.EndpointID]bool{2: true, 3: true}}))
err := store.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{
ID: edgeStackId,
Name: "Test Edge Stack",
EdgeGroups: []portainer.EdgeGroupID{1, 2},
for i := range endpoints {
require.NoError(t, tx.Endpoint().Create(&endpoints[i]))
}
require.NoError(t, tx.EdgeStack().Create(edgeStackId, &portainer.EdgeStack{
ID: edgeStackId,
Name: "Test Edge Stack",
EdgeGroups: []portainer.EdgeGroupID{1, 2},
}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
Dynamic: true,
TagIDs: []portainer.TagID{1},
}))
require.NoError(t, tx.EdgeStackStatus().Create(edgeStackId, endpoints[0].ID, &portainer.EdgeStackStatusForEnv{
Status: []portainer.EdgeStackDeploymentStatus{{Type: portainer.EdgeStackStatusAcknowledged}}}))
return nil
}))
test := func(status *portainer.EdgeStackStatusType, expected []portainer.Endpoint) {
tmp := make([]portainer.Endpoint, len(endpoints))
require.Equal(t, 4, copy(tmp, endpoints))
es, err := filterEndpointsByEdgeStack(tmp, edgeStackId, status, store)
require.NoError(t, err)
// validate that the len is the same
require.Len(t, es, len(expected))
// and that all items are the expected ones
for i := range expected {
require.Contains(t, es, expected[i])
}
}
test(nil, []portainer.Endpoint{endpoints[0], endpoints[1], endpoints[2]})
status := portainer.EdgeStackStatusPending
test(&status, []portainer.Endpoint{endpoints[1], endpoints[2]})
status = portainer.EdgeStackStatusCompleted
test(&status, []portainer.Endpoint{})
status = portainer.EdgeStackStatusAcknowledged
test(&status, []portainer.Endpoint{endpoints[0]}) // that's the only one with an edge stack status in DB
}
func TestErrorsFilterEndpointsByEdgeStack(t *testing.T) {
t.Run("must error by edge stack not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
})
require.NoError(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 1,
Name: "Edge Group 1",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{1}),
t.Run("must error by edge group not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "1", EdgeGroups: []portainer.EdgeGroupID{1}}))
return nil
}))
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
})
require.NoError(t, err)
err = store.EdgeGroup().Create(&portainer.EdgeGroup{
ID: 2,
Name: "Edge Group 2",
EndpointIDs: roar.FromSlice([]portainer.EndpointID{2, 3}),
t.Run("must error by env tag not found", func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
require.NotNil(t, store)
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
require.NoError(t, tx.EdgeStack().Create(1, &portainer.EdgeStack{ID: 1, Name: "1", EdgeGroups: []portainer.EdgeGroupID{1}}))
require.NoError(t, tx.EdgeGroup().Create(&portainer.EdgeGroup{ID: 1, Name: "edge group", Dynamic: true, TagIDs: []portainer.TagID{1}}))
return nil
}))
_, err := filterEndpointsByEdgeStack([]portainer.Endpoint{}, 1, nil, store)
require.Error(t, err)
})
require.NoError(t, err)
es, err := filterEndpointsByEdgeStack(endpoints, edgeStackId, nil, store)
require.NoError(t, err)
require.Len(t, es, 3)
require.Contains(t, es, endpoints[0]) // Endpoint 1
require.Contains(t, es, endpoints[1]) // Endpoint 2
require.Contains(t, es, endpoints[2]) // Endpoint 3
require.NotContains(t, es, endpoints[3]) // Endpoint 4
}
func TestFilterEndpointsByEdgeGroup(t *testing.T) {
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.34.0
// @version 2.37.0
// @description.markdown api-description.md
// @termsOfService
@@ -19,6 +19,7 @@ import (
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
@@ -63,6 +63,7 @@ func (payload *openAMTConfigurePayload) Validate(r *http.Request) error {
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param body body openAMTConfigurePayload true "OpenAMT Settings"
// @success 204 "Success"
// @failure 400 "Invalid request"
@@ -20,6 +20,7 @@ import (
// @security jwt
// @produce json
// @param id path int true "Environment(Endpoint) identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
@@ -79,6 +80,7 @@ func (payload *deviceActionPayload) Validate(r *http.Request) error {
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param id path int true "Environment identifier"
// @param deviceId path int true "Device identifier"
// @param body body deviceActionPayload true "Device Action"
@@ -141,6 +143,7 @@ type AuthorizationResponse struct {
// @security jwt
// @accept json
// @produce json
// @deprecated
// @param id path int true "Environment identifier"
// @param deviceId path int true "Device identifier"
// @param body body deviceFeaturesPayload true "Device Features"
@@ -48,6 +48,7 @@ const (
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @deprecated
// @success 200 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied to access settings"
@@ -8,6 +8,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
)
@@ -26,6 +27,7 @@ func NewHandler(bouncer security.BouncerService) *Handler {
Router: mux.NewRouter(),
}
h.Use(middlewares.DeprecatedSimple)
h.Handle("/open_amt/configure", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTConfigure))).Methods(http.MethodPost)
h.Handle("/open_amt/{id}/info", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTHostInfo))).Methods(http.MethodGet)
h.Handle("/open_amt/{id}/activate", bouncer.AdminAccess(httperror.LoggerHandler(h.openAMTActivate))).Methods(http.MethodPost)
+12 -1
View File
@@ -22,8 +22,18 @@ import (
func hideFields(registry *portainer.Registry, hideAccesses bool) {
registry.Password = ""
registry.ManagementConfiguration = nil
if registry.ManagementConfiguration != nil {
// TLS and SkipTLSVerify should be retained since it's not sensitive information
minimalManagementConfig := &portainer.RegistryManagementConfiguration{}
minimalManagementConfig.TLSConfig = registry.ManagementConfiguration.TLSConfig
registry.ManagementConfiguration = minimalManagementConfig
}
if hideAccesses {
if registry.ManagementConfiguration != nil {
registry.ManagementConfiguration.TLSConfig.TLSCACertPath = ""
registry.ManagementConfiguration.TLSConfig.TLSCertPath = ""
registry.ManagementConfiguration.TLSConfig.TLSKeyPath = ""
}
registry.RegistryAccesses = nil
}
}
@@ -71,6 +81,7 @@ func (handler *Handler) initRouter(bouncer accessGuard) {
// Keep the gitlab proxy on the regular authenticated router as it doesn't require specific registry access
authenticatedRouter := handler.NewRoute().Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
authenticatedRouter.Handle("/registries/ping", httperror.LoggerHandler(handler.pingRegistry)).Methods(http.MethodPost)
authenticatedRouter.PathPrefix("/registries/proxies/gitlab").Handler(httperror.LoggerHandler(handler.proxyRequestsToGitlabAPIWithoutRegistry))
}
@@ -7,6 +7,7 @@ import (
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/registryutils"
"github.com/portainer/portainer/api/pendingactions/handlers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
@@ -80,7 +81,7 @@ func (handler *Handler) deleteKubernetesSecrets(registry *portainer.Registry) {
for _, ns := range access.Namespaces {
if err := cli.DeleteRegistrySecret(registry.ID, ns); err != nil {
failedNamespaces = append(failedNamespaces, ns)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", cli.RegistrySecretName(registry.ID), ns, endpointId)
log.Warn().Err(err).Msgf("Unable to delete registry secret %q from namespace %q for environment %d. Retrying offline", registryutils.RegistrySecretName(registry.ID), ns, endpointId)
}
}
@@ -0,0 +1,180 @@
package registries
import (
"context"
"errors"
"net/http"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/portainer/portainer/pkg/liboras"
"github.com/rs/zerolog/log"
"oras.land/oras-go/v2/registry/remote/errcode"
)
type registryPingPayload struct {
// Registry Type. Valid values are:
// 1 (Quay.io),
// 2 (Azure container registry),
// 3 (custom registry),
// 4 (Gitlab registry),
// 5 (ProGet registry),
// 6 (DockerHub)
// 7 (ECR)
// 8 (Github registry)
Type portainer.RegistryType `example:"6" validate:"required" enums:"1,2,3,4,5,6,7,8"`
// URL or IP address of the Docker registry
URL string `example:"registry-1.docker.io" validate:"required"`
// Username used to authenticate against this registry
Username string `example:"registry_user"`
// Password used to authenticate against this registry
Password string `example:"registry_password"`
// Use TLS
TLS bool `example:"true"`
}
type registryPingResponse struct {
// Success indicates if the registry connection was successful
Success bool `json:"success" example:"true"`
// Message provides details about the connection test result
Message string `json:"message" example:"Registry connection successful"`
}
func (payload *registryPingPayload) Validate(_ *http.Request) error {
if len(payload.Username) == 0 || len(payload.Password) == 0 {
return httperror.BadRequest("Username and password are required", nil)
}
switch payload.Type {
case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry, portainer.EcrRegistry, portainer.GithubRegistry:
default:
return httperror.BadRequest("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry), 6 (DockerHub), 7 (ECR), 8 (Github registry)", nil)
}
return nil
}
// @id RegistryPing
// @summary Test registry connection
// @description Test connection to a registry with provided credentials
// @description **Access policy**: authenticated
// @tags registries
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param body body registryPingPayload true "Registry credentials to test"
// @success 200 {object} registryPingResponse "Success"
// @failure 400 "Invalid request"
// @failure 500 "Server error"
// @router /registries/ping [post]
func (handler *Handler) pingRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload registryPingPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
// Create a temporary registry configuration for testing
tempRegistry := &portainer.Registry{
Type: payload.Type,
URL: payload.URL,
Authentication: true,
Username: payload.Username,
Password: payload.Password,
}
// For DockerHub, ensure URL is set correctly
if payload.Type == portainer.DockerHubRegistry && payload.URL == "" {
tempRegistry.URL = "registry-1.docker.io"
}
// Set up TLS configuration
if payload.Type == portainer.CustomRegistry {
tempRegistry.ManagementConfiguration = &portainer.RegistryManagementConfiguration{
Type: payload.Type,
TLSConfig: portainer.TLSConfiguration{
TLS: payload.TLS || fips.FIPSMode(),
},
}
}
// Test the registry connection
success, message := handler.testRegistryConnection(tempRegistry)
responseData := registryPingResponse{
Success: success,
Message: message,
}
return response.JSON(w, responseData)
}
// testRegistryConnection tests if we can connect to the registry
func (handler *Handler) testRegistryConnection(registry *portainer.Registry) (bool, string) {
registryClient, err := liboras.CreateClient(*registry)
if err != nil {
log.Error().Err(err).Str("registryURL", registry.URL).Msg("Failed to create registry client")
return false, "Connection error: Failed to create registry client - " + err.Error()
}
ctx := context.Background()
err = registryClient.Ping(ctx)
if err != nil {
errorMessage := categorizeRegistryError(err, registry.URL)
return false, errorMessage
}
log.Debug().Str("registryURL", registry.URL).Msg("Registry ping successful")
return true, "Registry connection successful"
}
// categorizeRegistryError analyzes the error and returns a user-friendly message
// that distinguishes between connection errors and authentication errors
func categorizeRegistryError(err error, registryURL string) string {
if err == nil {
return ""
}
var userMessage string
var errResp *errcode.ErrorResponse
if errors.As(err, &errResp) {
// 401 Unauthorized or 403 Forbidden = authentication/authorization issue
if errResp.StatusCode == http.StatusUnauthorized || errResp.StatusCode == http.StatusForbidden {
userMessage = "Access token invalid: Authentication failed - please verify your username and access token"
} else {
userMessage = "Connection error: " + err.Error()
}
logEvent := log.Error().
Err(err).
Str("registryURL", registryURL).
Int("statusCode", errResp.StatusCode).
Str("userMessage", userMessage)
if len(errResp.Errors) > 0 {
logEvent.Interface("errors", errResp.Errors)
}
logEvent.Msg("Registry ping failed")
return userMessage
}
// Default: treat everything else as connection error
userMessage = "Connection error: " + err.Error()
log.Error().
Err(err).
Str("registryURL", registryURL).
Str("userMessage", userMessage).
Msg("Registry ping failed")
return userMessage
}
@@ -0,0 +1,334 @@
package registries
import (
"bytes"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"oras.land/oras-go/v2/registry/remote/errcode"
)
func Test_categorizeRegistryError(t *testing.T) {
tests := []struct {
name string
err error
registryURL string
want string
}{
{
name: "nil error returns empty string",
err: nil,
registryURL: "registry.example.com",
want: "",
},
{
name: "401 Unauthorized returns access token invalid message",
err: &errcode.ErrorResponse{
StatusCode: http.StatusUnauthorized,
},
registryURL: "registry-1.docker.io",
want: "Access token invalid: Authentication failed - please verify your username and access token",
},
{
name: "403 Forbidden returns access token invalid message",
err: &errcode.ErrorResponse{
StatusCode: http.StatusForbidden,
},
registryURL: "registry-1.docker.io",
want: "Access token invalid: Authentication failed - please verify your username and access token",
},
{
name: "500 Internal Server Error returns connection error",
err: &errcode.ErrorResponse{
StatusCode: http.StatusInternalServerError,
Method: "GET",
URL: &url.URL{Scheme: "https", Host: "registry-1.docker.io", Path: "/v2/"},
Errors: errcode.Errors{},
},
registryURL: "registry-1.docker.io",
want: "Connection error: GET \"https://registry-1.docker.io/v2/\": response status code 500: Internal Server Error",
},
{
name: "404 Not Found returns connection error",
err: &errcode.ErrorResponse{
StatusCode: http.StatusNotFound,
Method: "GET",
URL: &url.URL{Scheme: "https", Host: "registry.example.com", Path: "/v2/"},
Errors: errcode.Errors{},
},
registryURL: "registry.example.com",
want: "Connection error: GET \"https://registry.example.com/v2/\": response status code 404: Not Found",
},
{
name: "400 Bad Request with error details returns connection error with details",
err: &errcode.ErrorResponse{
StatusCode: http.StatusBadRequest,
Method: "GET",
URL: &url.URL{Scheme: "https", Host: "registry.example.com", Path: "/v2/"},
Errors: errcode.Errors{
{
Code: errcode.ErrorCodeNameInvalid,
Message: "invalid repository name",
},
},
},
registryURL: "registry.example.com",
want: "Connection error: GET \"https://registry.example.com/v2/\": response status code 400: name invalid: invalid repository name",
},
{
name: "non-errcode error returns connection error",
err: errors.New("dial tcp: lookup registry.example.com: no such host"),
registryURL: "registry.example.com",
want: "Connection error: dial tcp: lookup registry.example.com: no such host",
},
{
name: "network timeout error returns connection error",
err: errors.New("context deadline exceeded"),
registryURL: "registry.example.com",
want: "Connection error: context deadline exceeded",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := categorizeRegistryError(tt.err, tt.registryURL)
assert.Equal(t, tt.want, got)
})
}
}
func Test_registryPingPayload_Validate(t *testing.T) {
tests := []struct {
name string
payload registryPingPayload
wantErr bool
errMsg string
}{
{
name: "valid DockerHub payload",
payload: registryPingPayload{
Type: 6, // DockerHub
URL: "registry-1.docker.io",
Username: "testuser",
Password: "testpass",
},
wantErr: false,
},
{
name: "valid custom registry payload",
payload: registryPingPayload{
Type: 3, // Custom
URL: "registry.example.com",
Username: "admin",
Password: "secret",
TLS: true,
},
wantErr: false,
},
{
name: "empty username returns error",
payload: registryPingPayload{
Type: 6,
URL: "registry-1.docker.io",
Username: "",
Password: "testpass",
},
wantErr: true,
errMsg: "Username and password are required",
},
{
name: "empty password returns error",
payload: registryPingPayload{
Type: 6,
URL: "registry-1.docker.io",
Username: "testuser",
Password: "",
},
wantErr: true,
errMsg: "Username and password are required",
},
{
name: "invalid registry type returns error",
payload: registryPingPayload{
Type: 99, // Invalid type
URL: "registry-1.docker.io",
Username: "testuser",
Password: "testpass",
},
wantErr: true,
errMsg: "Invalid registry type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.payload.Validate(nil)
if tt.wantErr {
require.Error(t, err)
if tt.errMsg != "" {
assert.Contains(t, err.Error(), tt.errMsg)
}
} else {
assert.NoError(t, err)
}
})
}
}
func TestHandler_pingRegistry(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
tests := []struct {
name string
payload registryPingPayload
wantStatusCode int
wantSuccess bool
checkResponse func(t *testing.T, resp registryPingResponse)
}{
{
name: "invalid payload - empty username",
payload: registryPingPayload{
Type: portainer.DockerHubRegistry,
URL: "registry-1.docker.io",
Username: "",
Password: "testpass",
},
wantStatusCode: http.StatusBadRequest,
},
{
name: "invalid payload - empty password",
payload: registryPingPayload{
Type: portainer.DockerHubRegistry,
URL: "registry-1.docker.io",
Username: "testuser",
Password: "",
},
wantStatusCode: http.StatusBadRequest,
},
{
name: "invalid payload - invalid registry type",
payload: registryPingPayload{
Type: 99,
URL: "registry-1.docker.io",
Username: "testuser",
Password: "testpass",
},
wantStatusCode: http.StatusBadRequest,
},
{
name: "valid payload with invalid credentials returns 200 with success=false",
payload: registryPingPayload{
Type: portainer.DockerHubRegistry,
URL: "registry-1.docker.io",
Username: "invalid-user",
Password: "invalid-pass",
},
wantStatusCode: http.StatusOK,
wantSuccess: false,
checkResponse: func(t *testing.T, resp registryPingResponse) {
assert.False(t, resp.Success)
assert.Contains(t, resp.Message, "Access token invalid")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payloadBytes, err := json.Marshal(tt.payload)
require.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "/registries/ping", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
// Set up security context
restrictedContext := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
UserMemberships: []portainer.TeamMembership{},
}
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
handlerErr := handler.pingRegistry(w, r)
if tt.wantStatusCode != http.StatusOK {
// For error cases, check the handler returns an error
require.NotNil(t, handlerErr)
assert.Equal(t, tt.wantStatusCode, handlerErr.StatusCode)
} else {
// For success cases (200), even if the ping failed
require.Nil(t, handlerErr)
assert.Equal(t, http.StatusOK, w.Code)
var resp registryPingResponse
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, tt.wantSuccess, resp.Success)
if tt.checkResponse != nil {
tt.checkResponse(t, resp)
}
}
})
}
}
func TestHandler_pingRegistry_DockerHubURL(t *testing.T) {
_, store := datastore.MustNewTestStore(t, false, false)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
t.Run("empty URL for DockerHub gets default URL", func(t *testing.T) {
payload := registryPingPayload{
Type: portainer.DockerHubRegistry,
URL: "", // Empty URL
Username: "testuser",
Password: "testpass",
}
payloadBytes, err := json.Marshal(payload)
require.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "/registries/ping", bytes.NewReader(payloadBytes))
w := httptest.NewRecorder()
restrictedContext := &security.RestrictedRequestContext{
IsAdmin: true,
UserID: 1,
UserMemberships: []portainer.TeamMembership{},
}
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
r = r.WithContext(ctx)
handlerErr := handler.pingRegistry(w, r)
// Should succeed (handler returns nil), but the ping itself will fail with auth error
require.Nil(t, handlerErr)
assert.Equal(t, http.StatusOK, w.Code)
var resp registryPingResponse
err = json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
// The ping will fail (invalid credentials), but that's expected
// We're just testing that the URL defaulting logic works
assert.False(t, resp.Success)
assert.Contains(t, resp.Message, "Access token invalid")
})
}
@@ -0,0 +1,53 @@
package stacks
import (
"io"
"net/http"
"net/http/httptest"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
)
func mockCreateUser(store *datastore.Store) (*portainer.User, error) {
user := &portainer.User{ID: 1, Username: "testUser", Role: portainer.AdministratorRole, PortainerAuthorizations: authorization.DefaultPortainerAuthorizations()}
err := store.User().Create(user)
return user, err
}
func mockCreateEndpoint(store *datastore.Store) (*portainer.Endpoint, error) {
endpoint := &portainer.Endpoint{
ID: 1,
Name: "testEndpoint",
SecuritySettings: portainer.EndpointSecuritySettings{
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
AllowVolumeBrowserForRegularUsers: true,
AllowHostNamespaceForRegularUsers: true,
AllowDeviceMappingForRegularUsers: true,
AllowStackManagementForRegularUsers: true,
AllowContainerCapabilitiesForRegularUsers: true,
AllowSysctlSettingForRegularUsers: true,
EnableHostManagementFeatures: true,
},
}
err := store.Endpoint().Create(endpoint)
return endpoint, err
}
func mockCreateStackRequestWithSecurityContext(method, target string, body io.Reader) *http.Request {
req := httptest.NewRequest(method,
target,
body)
ctx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{
IsAdmin: true,
UserID: portainer.UserID(1),
})
return req.WithContext(ctx)
}
+71 -41
View File
@@ -6,6 +6,7 @@ import (
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/deployments"
@@ -23,6 +24,10 @@ type updateComposeStackPayload struct {
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"`
// A list of environment(endpoint) variables used during stack deployment
Env []portainer.Pair
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"`
}
@@ -42,6 +47,10 @@ type updateSwarmStackPayload struct {
Env []portainer.Pair
// Prune services that are no longer referenced (only available for Swarm stacks)
Prune bool `example:"true"`
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"`
}
@@ -78,13 +87,6 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
return httperror.BadRequest("Invalid stack identifier route variable", err)
}
stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, this API endpoint
// can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack.
@@ -92,63 +94,84 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
if err != nil {
return httperror.BadRequest("Invalid query parameter: endpointId", err)
}
if endpointID != int(stack.EndpointID) {
stack.EndpointID = portainer.EndpointID(endpointID)
var stack *portainer.Stack
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var httpErr *httperror.HandlerError
stack, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID))
if httpErr != nil {
return httpErr
}
return nil
})
return response.TxResponse(w, stack, err)
}
func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, *httperror.HandlerError) {
stack, err := tx.Stack().Read(stackID)
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
} else if err != nil {
return nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
if endpointID != 0 && endpointID != stack.EndpointID {
stack.EndpointID = endpointID
}
endpoint, err := tx.Endpoint().Endpoint(stack.EndpointID)
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
return nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
}
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
return nil, httperror.Forbidden("Permission denied to access environment", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
//only check resource control when it is a DockerSwarmStack or a DockerComposeStack
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
return nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
}
if access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl); err != nil {
return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
} else if !access {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
return nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
}
if canManage, err := handler.userCanManageStacks(securityContext, endpoint); err != nil {
return httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err)
return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err)
} else if !canManage {
errMsg := "Stack editing is disabled for non-admin users"
return httperror.Forbidden(errMsg, errors.New(errMsg))
return nil, httperror.Forbidden(errMsg, errors.New(errMsg))
}
if err := handler.updateAndDeployStack(r, stack, endpoint); err != nil {
return err
if err := handler.updateAndDeployStack(tx, r, stack, endpoint); err != nil {
return nil, err
}
user, err := handler.DataStore.User().Read(securityContext.UserID)
user, err := tx.User().Read(securityContext.UserID)
if err != nil {
return httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
return nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
}
stack.UpdatedBy = user.Username
stack.UpdateDate = time.Now().Unix()
stack.Status = portainer.StackStatusActive
if err := handler.DataStore.Stack().Update(stack.ID, stack); err != nil {
return httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
if err := tx.Stack().Update(stack.ID, stack); err != nil {
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
@@ -156,19 +179,19 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
stack.GitConfig.Authentication.Password = ""
}
return response.JSON(w, stack)
return stack, nil
}
func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
switch stack.Type {
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
return handler.updateSwarmStack(r, stack, endpoint)
return handler.updateSwarmStack(tx, r, stack, endpoint)
case portainer.DockerComposeStack:
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
return handler.updateComposeStack(r, stack, endpoint)
return handler.updateComposeStack(tx, r, stack, endpoint)
case portainer.KubernetesStack:
return handler.updateKubernetesStack(r, stack, endpoint)
}
@@ -176,7 +199,7 @@ func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.S
return httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type))
}
func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
// Must not be git based stack. stop the auto update job if there is any
if stack.AutoUpdate != nil {
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
@@ -191,6 +214,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
return httperror.BadRequest("Invalid request payload", err)
}
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
stack.Env = payload.Env
if stack.GitConfig != nil {
@@ -213,14 +237,13 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(securityContext,
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfigTx(tx, securityContext,
stack,
endpoint,
handler.DataStore,
handler.FileService,
handler.StackDeployer,
payload.PullImage,
false)
payload.RepullImageAndRedeploy,
payload.RepullImageAndRedeploy)
if err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
@@ -243,7 +266,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
return nil
}
func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
// Must not be git based stack. stop the auto update job if there is any
if stack.AutoUpdate != nil {
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
@@ -257,7 +280,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
stack.Env = payload.Env
if stack.GitConfig != nil {
@@ -280,14 +303,13 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(securityContext,
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfigTx(tx, securityContext,
stack,
endpoint,
handler.DataStore,
handler.FileService,
handler.StackDeployer,
payload.Prune,
payload.PullImage)
payload.RepullImageAndRedeploy)
if err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
@@ -296,6 +318,14 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
return httperror.InternalServerError(err.Error(), err)
}
if stack.Option != nil {
stack.Option.Prune = payload.Prune
} else {
stack.Option = &portainer.StackOption{
Prune: payload.Prune,
}
}
// Deploy the stack
if err := swarmDeploymentConfig.Deploy(); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
@@ -73,6 +73,14 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return httperror.InternalServerError(msg, errors.New(msg))
}
if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" &&
(stack.AutoUpdate == nil ||
(stack.AutoUpdate != nil && stack.AutoUpdate.Webhook != payload.AutoUpdate.Webhook)) {
if isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook); !isUnique || err != nil {
return httperror.Conflict("Webhook ID already exists", errors.New("webhook ID already exists"))
}
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, this API environment(endpoint)
// can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack.
@@ -27,10 +27,13 @@ type stackGitRedployPayload struct {
RepositoryAuthorizationType gittypes.GitCredentialAuthType
Env []portainer.Pair
Prune bool
// Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"`
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
StackName string
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"`
}
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
@@ -124,7 +127,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
}
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.Env = payload.Env
if stack.Type == portainer.DockerSwarmStack {
@@ -168,7 +171,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
defer clean()
if err := handler.deployStack(r, stack, payload.PullImage, endpoint); err != nil {
if err := handler.deployStack(r, stack, payload.RepullImageAndRedeploy, endpoint); err != nil {
return err
}
@@ -0,0 +1,78 @@
package stacks
import (
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/gofrs/uuid"
"github.com/segmentio/encoding/json"
"github.com/stretchr/testify/require"
)
func TestStackUpdateGitWebhookUniqueness(t *testing.T) {
webhook, err := uuid.NewV4()
require.NoError(t, err)
_, store := datastore.MustNewTestStore(t, false, false)
endpoint := &portainer.Endpoint{
ID: 123,
Name: "endpoint1",
Type: portainer.DockerEnvironment,
}
err = store.Endpoint().Create(endpoint)
require.NoError(t, err)
stack1 := portainer.Stack{
ID: 456,
EndpointID: endpoint.ID,
AutoUpdate: &portainer.AutoUpdateSettings{
Webhook: webhook.String(),
},
GitConfig: &gittypes.RepoConfig{
URL: "https://github.com/portainer/portainer.git",
},
}
err = store.Stack().Create(&stack1)
require.NoError(t, err)
stack2 := stack1
stack2.ID++
stack2.AutoUpdate = nil
err = store.Stack().Create(&stack2)
require.NoError(t, err)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
payload := &stackGitUpdatePayload{
AutoUpdate: &portainer.AutoUpdateSettings{
Webhook: webhook.String(),
},
}
jsonPayload, err := json.Marshal(payload)
require.NoError(t, err)
url := "/stacks/" + strconv.Itoa(int(stack2.ID)) + "/git?endpointId=" + strconv.Itoa(int(endpoint.ID))
req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(jsonPayload))
rrc := &security.RestrictedRequestContext{}
req = req.WithContext(security.StoreRestrictedRequestContext(req, rrc))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusConflict, rr.Code)
}
@@ -0,0 +1,419 @@
package stacks
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"github.com/pkg/errors"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_updateStackInTx(t *testing.T) {
t.Run("Transaction commits successfully - changes are persisted", func(t *testing.T) {
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
Env: []portainer.Pair{{Name: "FOO", Value: "BAR"}},
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-1",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
// Execute updateStackInTx within a successful transaction
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
return nil
})
require.NoError(t, err, "transction should succeed")
// Verify the stack was updated in the database (transaction committed)
stackAfterCommit, err := setup.store.Stack().Read(setup.stack.ID)
require.NoError(t, err, "should be able to read stack after commit")
require.NotNil(t, stackAfterCommit)
require.Equal(t, "BAR", stackAfterCommit.Env[0].Value, "stack env variable should be updated")
})
t.Run("Transaction rollback on error - changes not persisted", func(t *testing.T) {
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
Env: []portainer.Pair{{Name: "FOO", Value: "BAR"}},
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-1",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
// Execute updateStackInTx within a transaction that we force to fail
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
updatedStack, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
// Verify changes are visible within the transaction
assert.NotNil(t, updatedStack)
assert.Equal(t, setup.user.Username, updatedStack.UpdatedBy)
assert.NotZero(t, updatedStack.UpdateDate)
// Force the transaction to fail by returning an error
return errors.New("forced transaction failure")
})
// Verify the transaction failed
require.Error(t, err)
assert.Contains(t, err.Error(), "forced transaction failure")
// Verify the stack was NOT updated in the database (transaction rolled back)
stackAfterRollback, err := setup.store.Stack().Read(setup.stack.ID)
require.NoError(t, err)
require.Zero(t, stackAfterRollback.Env, "stack env variable should remain unchanged after rollback")
})
t.Run("Error: Stack not found returns NotFound httperror", func(t *testing.T) {
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-1",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
setup.req.URL.Path = "/stacks/9999" // Non-existent stack ID
var handlerErr *httperror.HandlerError
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, 9999, setup.endpoint.ID)
return handlerErr
})
require.NotNil(t, handlerErr, "handler error should be set")
assert.Equal(t, http.StatusNotFound, handlerErr.StatusCode, "should return 404 NotFound")
assert.Contains(t, handlerErr.Message, "Unable to find a stack", "error message should mention stack")
})
t.Run("Error: Endpoint not found returns NotFound httperror", func(t *testing.T) {
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-1",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
var handlerErr *httperror.HandlerError
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, 2999) // Non-existent endpoint ID
return nil
})
require.NotNil(t, handlerErr, "handler error should be set")
assert.Equal(t, http.StatusNotFound, handlerErr.StatusCode, "should return 404 NotFound")
assert.Contains(t, handlerErr.Message, "Unable to find the environment", "error message should mention environment")
})
t.Run("Error: user cannot access the stack", func(t *testing.T) {
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-1",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
originalUser, err := setup.store.User().Read(setup.user.ID)
require.NoError(t, err, "error reading user")
// Modify the user's role to restrict access
originalUser.Role = portainer.StandardUserRole
err = setup.store.User().Update(originalUser.ID, originalUser)
require.NoError(t, err, "error updating user role")
var handlerErr *httperror.HandlerError
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
return nil
})
require.NotNil(t, handlerErr, "handler error should be set")
assert.Equal(t, http.StatusForbidden, handlerErr.StatusCode, "should return 403 Forbidden")
assert.Contains(t, handlerErr.Message, "Access denied", "error message should mention access")
})
t.Run("Error: user not found", func(t *testing.T) {
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-1",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
err := setup.store.User().Delete(setup.user.ID) // Delete the user to simulate "user not found"
require.NoError(t, err, "error deleting user")
var handlerErr *httperror.HandlerError
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
return nil
})
require.NotNil(t, handlerErr, "handler error should be set")
assert.Equal(t, http.StatusInternalServerError, handlerErr.StatusCode, "should return 500 Internal Server Error")
assert.Contains(t, handlerErr.Message, "Unable to verify user authorizations to validate stack access", "error message should mention user authorizations")
})
}
func TestStackUpdate(t *testing.T) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, true)
testDataPath := filepath.Join(t.TempDir())
fileService, err := filesystem.NewService(testDataPath, "")
require.NoError(t, err, "error init file service")
// Create test user
_, err = mockCreateUser(store)
require.NoError(t, err, "error creating user")
// Create test endpoint
endpoint, err := mockCreateEndpoint(store)
require.NoError(t, err, "error creating endpoint")
// Create test stack
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-1",
EntryPoint: "docker-compose.yml",
EndpointID: endpoint.ID,
ProjectPath: fileService.GetDatastorePath() + fmt.Sprintf("/compose/%d", 1),
Type: portainer.DockerSwarmStack,
}
err = store.Stack().Create(stack)
require.NoError(t, err, "error creating stack")
// Create resource control for the stack
resourceControl := &portainer.ResourceControl{
ID: portainer.ResourceControlID(stack.ID),
ResourceID: stackutils.ResourceControlID(stack.EndpointID, stack.Name),
Type: portainer.StackResourceControl,
AdministratorsOnly: false,
}
err = store.ResourceControl().Create(resourceControl)
require.NoError(t, err, "error creating resource control")
// Store initial stack file
_, err = fileService.StoreStackFileFromBytes(
strconv.Itoa(int(stack.ID)),
stack.EntryPoint,
[]byte("version: '3'\nservices:\n web:\n image: nginx:v1"),
)
require.NoError(t, err, "error storing stack file")
// Create handler
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fileService
handler.StackDeployer = testStackDeployer{}
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
handler.SwarmStackManager = swarmStackManager{}
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
}
// Create mock request with security context
jsonPayload, err := json.Marshal(payload)
require.NoError(t, err)
t.Run("Endpoint is not provided in query param nor header", func(t *testing.T) {
req := mockCreateStackRequestWithSecurityContext(
http.MethodPut,
fmt.Sprintf("/stacks/%d", stack.ID),
bytes.NewBuffer(jsonPayload),
)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, "expected status BadRequest when endpoint is not provided")
})
t.Run("Stack doesn't exist", func(t *testing.T) {
req := mockCreateStackRequestWithSecurityContext(
http.MethodPut,
fmt.Sprintf("/stacks/test-stack-1?endpointId=%d", endpoint.ID),
bytes.NewBuffer(jsonPayload),
)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, "expected status NotFound when stack doesn't exist")
})
t.Run("Update stack successfully", func(t *testing.T) {
fips.InitFIPS(false)
req := mockCreateStackRequestWithSecurityContext(
http.MethodPut,
fmt.Sprintf("/stacks/%d?endpointId=%d", stack.ID, endpoint.ID),
bytes.NewBuffer(jsonPayload),
)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "expected status OK when stack is updated successfully")
var stackResponse portainer.Stack
err = json.NewDecoder(rec.Body).Decode(&stackResponse)
require.NoError(t, err, "error decoding response body")
require.NotZero(t, stackResponse.UpdateDate, "stack update date should be set")
})
}
// setupUpdateStackInTxTest creates a fresh test environment for each subtest
type updateStackInTxTestSetup struct {
store *datastore.Store
fileService portainer.FileService
handler *Handler
user *portainer.User
endpoint *portainer.Endpoint
stack *portainer.Stack
resourceControl *portainer.ResourceControl
jsonPayload []byte
req *http.Request
}
func setupUpdateStackInTxTest(t *testing.T, stack *portainer.Stack, payload *updateComposeStackPayload) *updateStackInTxTestSetup {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, true)
testDataPath := filepath.Join(t.TempDir())
fileService, err := filesystem.NewService(testDataPath, "")
require.NoError(t, err, "error init file service")
// Create test user
user, err := mockCreateUser(store)
require.NoError(t, err, "error creating user")
// Create test endpoint
endpoint, err := mockCreateEndpoint(store)
require.NoError(t, err, "error creating endpoint")
// Create test stack
stack.EndpointID = endpoint.ID
stack.ProjectPath = fileService.GetDatastorePath() + fmt.Sprintf("/compose/%d", stack.ID)
err = store.Stack().Create(stack)
require.NoError(t, err, "error creating stack")
// Create resource control for the stack
resourceControl := &portainer.ResourceControl{
ID: portainer.ResourceControlID(stack.ID),
ResourceID: stackutils.ResourceControlID(stack.EndpointID, stack.Name),
Type: portainer.StackResourceControl,
AdministratorsOnly: false,
}
err = store.ResourceControl().Create(resourceControl)
require.NoError(t, err, "error creating resource control")
// Store initial stack file
_, err = fileService.StoreStackFileFromBytes(
strconv.Itoa(int(stack.ID)),
stack.EntryPoint,
[]byte("version: '3'\nservices:\n web:\n image: nginx:v1"),
)
require.NoError(t, err, "error storing stack file")
// Create handler
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fileService
handler.StackDeployer = testStackDeployer{}
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
// Create mock request with security context
jsonPayload, err := json.Marshal(payload)
require.NoError(t, err)
req := mockCreateStackRequestWithSecurityContext(
http.MethodPut,
fmt.Sprintf("/stacks/%d?endpointId=%d", stack.ID, endpoint.ID),
bytes.NewBuffer(jsonPayload),
)
return &updateStackInTxTestSetup{
store: store,
fileService: fileService,
handler: handler,
user: user,
endpoint: endpoint,
stack: stack,
resourceControl: resourceControl,
jsonPayload: jsonPayload,
req: req,
}
}
type swarmStackManager struct {
portainer.SwarmStackManager
}
func (manager swarmStackManager) NormalizeStackName(name string) string {
return name
}
type testStackDeployer struct {
deployments.StackDeployer
}
func (testStackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
func (testStackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
func (testStackDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
func (testStackDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
+9
View File
@@ -29,3 +29,12 @@ func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *h
router.ServeHTTP(w, redirectedRequest)
})
}
// DeprecatedSimple is a middleware that marks an API route as deprecated
//
// if needed, use Deprecated with a custom urlBuilder
func DeprecatedSimple(h http.Handler) http.Handler {
return Deprecated(h, func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "", nil
})
}
+316
View File
@@ -0,0 +1,316 @@
package middlewares
import (
"io"
"net/http"
"net/http/httptest"
"testing"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeprecated(t *testing.T) {
tests := []struct {
name string
urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)
requestPath string
expectedStatusCode int
expectedPath string
expectRedirect bool
}{
{
name: "empty URL - no redirect",
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "", nil
},
requestPath: "/api/old",
expectedStatusCode: http.StatusOK,
expectedPath: "/api/old",
expectRedirect: false,
},
{
name: "new URL provided - redirects",
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/api/new", nil
},
requestPath: "/api/old",
expectedStatusCode: http.StatusOK,
expectedPath: "/api/new",
expectRedirect: true,
},
{
name: "urlBuilder returns error - returns error response",
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "", httperror.BadRequest("invalid request", nil)
},
requestPath: "/api/old",
expectedStatusCode: http.StatusBadRequest,
expectedPath: "",
expectRedirect: false,
},
{
name: "urlBuilder returns server error",
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "", httperror.InternalServerError("server error", nil)
},
requestPath: "/api/old",
expectedStatusCode: http.StatusInternalServerError,
expectedPath: "",
expectRedirect: false,
},
{
name: "dynamic URL based on request path",
urlBuilder: func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/v2" + r.URL.Path, nil
},
requestPath: "/api/resource/123",
expectedStatusCode: http.StatusOK,
expectedPath: "/v2/api/resource/123",
expectRedirect: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a test handler that records the request path
var handledPath string
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handledPath = r.URL.Path
w.WriteHeader(http.StatusOK)
w.Write([]byte("success"))
})
// Wrap with Deprecated middleware
wrappedHandler := Deprecated(testHandler, tt.urlBuilder)
// Create test request
req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil)
rec := httptest.NewRecorder()
// Execute request
wrappedHandler.ServeHTTP(rec, req)
// Check status code
assert.Equal(t, tt.expectedStatusCode, rec.Code, "unexpected status code")
// For error cases, don't check the path
if tt.expectedStatusCode >= 400 {
return
}
// Check that the correct path was handled
if tt.expectRedirect {
assert.Equal(t, tt.expectedPath, handledPath, "path was not redirected correctly")
} else {
assert.Equal(t, tt.requestPath, handledPath, "original path was not preserved")
}
// Check response body for success cases
body, err := io.ReadAll(rec.Body)
require.NoError(t, err)
assert.Equal(t, "success", string(body), "unexpected response body")
})
}
}
func TestDeprecatedSimple(t *testing.T) {
// Create a test handler
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("test response"))
})
// Wrap with DeprecatedSimple middleware
wrappedHandler := DeprecatedSimple(testHandler)
// Create test request
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
rec := httptest.NewRecorder()
// Execute request
wrappedHandler.ServeHTTP(rec, req)
// Check that request was successful
assert.Equal(t, http.StatusOK, rec.Code, "unexpected status code")
// Check response body
body, err := io.ReadAll(rec.Body)
require.NoError(t, err)
assert.Equal(t, "test response", string(body), "unexpected response body")
}
func TestDeprecated_PreservesRequestContext(t *testing.T) {
// Test that the middleware preserves request context when redirecting
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/new-path", nil
}
var receivedRequest *http.Request
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedRequest = r
w.WriteHeader(http.StatusOK)
})
wrappedHandler := Deprecated(testHandler, urlBuilder)
req := httptest.NewRequest(http.MethodGet, "/old-path", nil)
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
require.NotNil(t, receivedRequest, "request was not passed to handler")
assert.Equal(t, req.Context(), receivedRequest.Context(), "request context was not preserved")
}
func TestDeprecated_PreservesRequestMethod(t *testing.T) {
methods := []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch}
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/new-path", nil
}
for _, method := range methods {
t.Run(method, func(t *testing.T) {
var receivedMethod string
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedMethod = r.Method
w.WriteHeader(http.StatusOK)
})
wrappedHandler := Deprecated(testHandler, urlBuilder)
req := httptest.NewRequest(method, "/old-path", nil)
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
assert.Equal(t, method, receivedMethod, "HTTP method was not preserved")
})
}
}
func TestDeprecated_PreservesRequestHeaders(t *testing.T) {
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/new-path", nil
}
var receivedHeaders http.Header
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header
w.WriteHeader(http.StatusOK)
})
wrappedHandler := Deprecated(testHandler, urlBuilder)
req := httptest.NewRequest(http.MethodGet, "/old-path", nil)
req.Header.Set("Authorization", "Bearer token123")
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
assert.Equal(t, "Bearer token123", receivedHeaders.Get("Authorization"), "Authorization header was not preserved")
assert.Equal(t, "application/json", receivedHeaders.Get("Content-Type"), "Content-Type header was not preserved")
}
func TestDeprecated_PreservesRequestBody(t *testing.T) {
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/new-path", nil
}
var receivedBody string
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK)
})
wrappedHandler := Deprecated(testHandler, urlBuilder)
req := httptest.NewRequest(http.MethodPost, "/old-path", http.NoBody)
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
// Body should be preserved (empty in this case since we used http.NoBody)
assert.Empty(t, receivedBody, "expected empty body")
}
func TestDeprecated_ErrorResponseFormat(t *testing.T) {
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "", httperror.BadRequest("test error message", nil)
}
handlerCalled := false
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handlerCalled = true
w.WriteHeader(http.StatusOK)
})
wrappedHandler := Deprecated(testHandler, urlBuilder)
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
assert.False(t, handlerCalled, "handler should not be called when urlBuilder returns error")
assert.Equal(t, http.StatusBadRequest, rec.Code, "unexpected status code")
// The httperror.WriteError function should have written the error response
body, err := io.ReadAll(rec.Body)
require.NoError(t, err)
assert.NotEmpty(t, body, "expected error response body")
}
func TestDeprecated_WithQueryParameters(t *testing.T) {
urlBuilder := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/api/v2/resource", nil
}
var receivedQuery string
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedQuery = r.URL.RawQuery
w.WriteHeader(http.StatusOK)
})
wrappedHandler := Deprecated(testHandler, urlBuilder)
req := httptest.NewRequest(http.MethodGet, "/api/v1/resource?filter=active&sort=name", nil)
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
assert.Equal(t, "filter=active&sort=name", receivedQuery, "query parameters were not preserved")
}
func TestDeprecated_WithMultipleRedirects(t *testing.T) {
// Test that multiple deprecated middleware can be chained
urlBuilder1 := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/v2" + r.URL.Path, nil
}
urlBuilder2 := func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
return "/api" + r.URL.Path, nil
}
var finalPath string
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
finalPath = r.URL.Path
w.WriteHeader(http.StatusOK)
})
// Chain two deprecated middlewares
wrappedHandler := Deprecated(Deprecated(testHandler, urlBuilder2), urlBuilder1)
req := httptest.NewRequest(http.MethodGet, "/old", nil)
rec := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rec, req)
// First middleware redirects to /v2/old
// Second middleware redirects to /api/v2/old
assert.Equal(t, "/api/v2/old", finalPath, "chained redirects did not work correctly")
}
+5 -2
View File
@@ -12,7 +12,7 @@ type K8sApplication struct {
Name string `json:"Name"`
Image string `json:"Image"`
Containers []interface{} `json:"Containers,omitempty"`
Services []corev1.Service `json:"Services"`
Services []corev1.Service `json:"Services" swaggerignore:"true"`
CreationDate time.Time `json:"CreationDate"`
ApplicationOwner string `json:"ApplicationOwner,omitempty"`
StackName string `json:"StackName,omitempty"`
@@ -38,8 +38,9 @@ type K8sApplication struct {
Labels map[string]string `json:"Labels,omitempty"`
Annotations map[string]string `json:"Annotations,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty" swaggerignore:"true"`
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
StackKind string `json:"StackKind,omitempty"`
}
type Metadata struct {
@@ -48,7 +49,9 @@ type Metadata struct {
}
type CustomResourceMetadata struct {
Name string `json:"name"`
Kind string `json:"kind"`
Scope string `json:"scope"`
APIVersion string `json:"apiVersion"`
Plural string `json:"plural"`
}
+1 -1
View File
@@ -111,7 +111,7 @@ var prefixProxyFuncMap = map[string]func(*Transport, *http.Request, string) (*ht
// ProxyDockerRequest intercepts a Docker API request and apply logic based
// on the requested operation.
func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Response, error) {
// from : /v1.41/containers/{id}/json
// from : /v1.44/containers/{id}/json
// or : /containers/{id}/json
// to : /containers/{id}/json
unversionedPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
+19 -15
View File
@@ -1,7 +1,6 @@
package factory
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
@@ -23,36 +22,41 @@ var allowedHeaders = map[string]struct{}{
"X-Portainer-Volumename": {},
"X-Registry-Auth": {},
"X-Stream-Protocol-Version": {},
// WebSocket headers those are required for kubectl exec/attach/port-forward operations
"Sec-Websocket-Key": {},
"Sec-Websocket-Version": {},
"Sec-Websocket-Protocol": {},
"Sec-Websocket-Extensions": {},
}
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
return &httputil.ReverseProxy{Director: createDirector(target)}
return &httputil.ReverseProxy{Rewrite: createRewriteFn(target)}
}
func createDirector(target *url.URL) func(*http.Request) {
func createRewriteFn(target *url.URL) func(*httputil.ProxyRequest) {
targetQuery := target.RawQuery
return func(req *http.Request) {
req.URL.Scheme = target.Scheme
req.URL.Host = target.Host
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
req.Host = req.URL.Host
if targetQuery == "" || req.URL.RawQuery == "" {
req.URL.RawQuery = targetQuery + req.URL.RawQuery
return func(proxyReq *httputil.ProxyRequest) {
proxyReq.Out.URL.Scheme = target.Scheme
proxyReq.Out.URL.Host = target.Host
proxyReq.Out.URL.Path = singleJoiningSlash(target.Path, proxyReq.In.URL.Path)
proxyReq.Out.Host = proxyReq.Out.URL.Host
if targetQuery == "" || proxyReq.Out.URL.RawQuery == "" {
proxyReq.Out.URL.RawQuery = targetQuery + proxyReq.Out.URL.RawQuery
} else {
req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
proxyReq.Out.URL.RawQuery = targetQuery + "&" + proxyReq.Out.URL.RawQuery
}
if _, ok := req.Header["User-Agent"]; !ok {
if _, ok := proxyReq.Out.Header["User-Agent"]; !ok {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
proxyReq.Out.Header.Set("User-Agent", "")
}
for k := range req.Header {
for k := range proxyReq.Out.Header {
if _, ok := allowedHeaders[k]; !ok {
// We use delete here instead of req.Header.Del because we want to delete non canonical headers.
delete(req.Header, k)
delete(proxyReq.Out.Header, k)
}
}
}
+14 -4
View File
@@ -1,7 +1,9 @@
package factory
import (
"context"
"net/http"
"net/http/httputil"
"net/url"
"testing"
@@ -9,7 +11,7 @@ import (
portainer "github.com/portainer/portainer/api"
)
func Test_createDirector(t *testing.T) {
func Test_createRewriteFn(t *testing.T) {
testCases := []struct {
name string
target *url.URL
@@ -143,10 +145,18 @@ func Test_createDirector(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
director := createDirector(tc.target)
director(tc.req)
rewriteFn := createRewriteFn(tc.target)
proxyRequest := httputil.ProxyRequest{
In: tc.req.Clone(context.Background()),
Out: tc.req.Clone(context.Background()),
}
rewriteFn(&proxyRequest)
if diff := cmp.Diff(tc.req, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
if diff := cmp.Diff(proxyRequest.In, tc.req, cmp.Comparer(compareRequests)); diff != "" {
t.Fatalf("rewriteFn modified in request: \n%s", diff)
}
if diff := cmp.Diff(proxyRequest.Out, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
t.Fatalf("requests are different: \n%s", diff)
}
})
+1 -1
View File
@@ -535,7 +535,7 @@ func MWSecureHeaders(next http.Handler, hsts, csp bool) http.Handler {
}
if csp {
w.Header().Set("Content-Security-Policy", "script-src 'self' cdn.matomo.cloud js.hsforms.net https://www.google.com/recaptcha/, https://www.gstatic.com/recaptcha/; object-src 'none'; frame-ancestors 'none'; frame-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/")
w.Header().Set("Content-Security-Policy", "script-src 'self' https://js.hsforms.net https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; object-src 'none'; frame-ancestors 'none'; frame-src https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -0,0 +1,11 @@
package registryutils
import (
"strconv"
portainer "github.com/portainer/portainer/api"
)
func RegistrySecretName(registryID portainer.RegistryID) string {
return "registry-" + strconv.Itoa(int(registryID))
}
+1 -1
View File
@@ -113,7 +113,7 @@ type datastoreOption = func(d *testDatastore)
// NewDatastore creates new instance of testDatastore.
// Will apply options before returning, opts will be applied from left to right.
func NewDatastore(options ...datastoreOption) *testDatastore {
conn, _ := database.NewDatabase("boltdb", "", nil)
conn, _ := database.NewDatabase("boltdb", "", nil, false)
d := testDatastore{connection: conn}
for _, o := range options {
@@ -0,0 +1,12 @@
package testhelpers
type userActivityService struct {
}
func NewUserActivityService() *userActivityService {
return &userActivityService{}
}
func (service *userActivityService) LogUserActivity(username string, context string, action string, payload []byte) error {
return nil
}
+20 -8
View File
@@ -145,21 +145,33 @@ func (kcl *KubeClient) GetNonAdminNamespaces(userID int, teamIDs []int, isRestri
}
// GetIsKubeAdmin retrieves true if client is admin
func (client *KubeClient) GetIsKubeAdmin() bool {
return client.IsKubeAdmin
func (kcl *KubeClient) GetIsKubeAdmin() bool {
kcl.mu.Lock()
defer kcl.mu.Unlock()
return kcl.isKubeAdmin
}
// UpdateIsKubeAdmin sets whether the kube client is admin
func (client *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
client.IsKubeAdmin = isKubeAdmin
func (kcl *KubeClient) SetIsKubeAdmin(isKubeAdmin bool) {
kcl.mu.Lock()
defer kcl.mu.Unlock()
kcl.isKubeAdmin = isKubeAdmin
}
// GetClientNonAdminNamespaces retrieves non-admin namespaces
func (client *KubeClient) GetClientNonAdminNamespaces() []string {
return client.NonAdminNamespaces
func (kcl *KubeClient) GetClientNonAdminNamespaces() []string {
kcl.mu.Lock()
defer kcl.mu.Unlock()
return kcl.nonAdminNamespaces
}
// UpdateClientNonAdminNamespaces sets the client non admin namespace list
func (client *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
client.NonAdminNamespaces = nonAdminNamespaces
func (kcl *KubeClient) SetClientNonAdminNamespaces(nonAdminNamespaces []string) {
kcl.mu.Lock()
defer kcl.mu.Unlock()
kcl.nonAdminNamespaces = nonAdminNamespaces
}
+24
View File
@@ -67,3 +67,27 @@ func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConf
})
}
}
func TestKubeAdmin(t *testing.T) {
kcl := &KubeClient{}
require.False(t, kcl.GetIsKubeAdmin())
kcl.SetIsKubeAdmin(true)
require.True(t, kcl.GetIsKubeAdmin())
kcl.SetIsKubeAdmin(false)
require.False(t, kcl.GetIsKubeAdmin())
}
func TestClientNonAdminNamespaces(t *testing.T) {
kcl := &KubeClient{}
require.Empty(t, kcl.GetClientNonAdminNamespaces())
nss := []string{"ns1", "ns2"}
kcl.SetClientNonAdminNamespaces(nss)
require.Equal(t, nss, kcl.GetClientNonAdminNamespaces())
kcl.SetClientNonAdminNamespaces([]string{})
require.Empty(t, kcl.GetClientNonAdminNamespaces())
}
+11 -3
View File
@@ -28,7 +28,7 @@ type PortainerApplicationResources struct {
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchApplications function.
// otherwise, namespaces the non-admin user has access to will be used to filter the applications based on the allowed namespaces.
func (kcl *KubeClient) GetApplications(namespace, nodeName string) ([]models.K8sApplication, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchApplications(namespace, nodeName)
}
@@ -64,9 +64,13 @@ func (kcl *KubeClient) fetchApplications(namespace, nodeName string) ([]models.K
// fetchApplicationsForNonAdmin fetches the applications in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchApplicationsForNonAdmin(namespace, nodeName string) ([]models.K8sApplication, error) {
log.Debug().Msgf("Fetching applications for non-admin user: %v", kcl.NonAdminNamespaces)
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
if len(kcl.NonAdminNamespaces) == 0 {
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching applications for non-admin user")
if len(nonAdminNamespaces) == 0 {
return nil, nil
}
@@ -259,6 +263,7 @@ func populateApplicationFromDeployment(application *models.K8sApplication, deplo
application.ApplicationOwner = deployment.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = deployment.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = deployment.Labels["io.portainer.kubernetes.application.stack"]
application.StackKind = deployment.Labels["io.portainer.kubernetes.application.stackKind"]
application.Labels = deployment.Labels
application.MatchLabels = deployment.Spec.Selector.MatchLabels
application.CreationDate = deployment.CreationTimestamp.Time
@@ -288,6 +293,7 @@ func populateApplicationFromStatefulSet(application *models.K8sApplication, stat
application.ApplicationOwner = statefulSet.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = statefulSet.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = statefulSet.Labels["io.portainer.kubernetes.application.stack"]
application.StackKind = statefulSet.Labels["io.portainer.kubernetes.application.stackKind"]
application.Labels = statefulSet.Labels
application.MatchLabels = statefulSet.Spec.Selector.MatchLabels
application.CreationDate = statefulSet.CreationTimestamp.Time
@@ -317,6 +323,7 @@ func populateApplicationFromDaemonSet(application *models.K8sApplication, daemon
application.ApplicationOwner = daemonSet.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = daemonSet.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = daemonSet.Labels["io.portainer.kubernetes.application.stack"]
application.StackKind = daemonSet.Labels["io.portainer.kubernetes.application.stackKind"]
application.Labels = daemonSet.Labels
application.MatchLabels = daemonSet.Spec.Selector.MatchLabels
application.CreationDate = daemonSet.CreationTimestamp.Time
@@ -347,6 +354,7 @@ func populateApplicationFromPod(application *models.K8sApplication, pod corev1.P
application.ApplicationOwner = pod.Labels["io.portainer.kubernetes.application.owner"]
application.StackID = pod.Labels["io.portainer.kubernetes.application.stackid"]
application.StackName = pod.Labels["io.portainer.kubernetes.application.stack"]
application.StackKind = pod.Labels["io.portainer.kubernetes.application.stackKind"]
application.Labels = pod.Labels
application.MatchLabels = pod.Labels
application.CreationDate = pod.CreationTimestamp.Time
+4 -4
View File
@@ -312,7 +312,7 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: true,
isKubeAdmin: true,
}
// Test cases
@@ -387,8 +387,8 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{namespace1},
isKubeAdmin: false,
nonAdminNamespaces: []string{namespace1},
}
// Test that only resources from allowed namespace are returned
@@ -447,7 +447,7 @@ func TestGetApplications(t *testing.T) {
kubeClient := &KubeClient{
cli: fakeClient,
instanceID: "test-instance",
IsKubeAdmin: true,
isKubeAdmin: true,
}
// Test filtering by node name
+7 -5
View File
@@ -42,8 +42,8 @@ type (
cli kubernetes.Interface
instanceID string
mu sync.Mutex
IsKubeAdmin bool
NonAdminNamespaces []string
isKubeAdmin bool
nonAdminNamespaces []string
}
)
@@ -147,6 +147,7 @@ func (factory *ClientFactory) GetProxyKubeClient(endpointID, userID string) (*Ku
if ok {
return client.(*KubeClient), true
}
return nil, false
}
@@ -179,8 +180,8 @@ func (factory *ClientFactory) CreateKubeClientFromKubeConfig(clusterID string, k
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
IsKubeAdmin: IsKubeAdmin,
NonAdminNamespaces: NonAdminNamespaces,
isKubeAdmin: IsKubeAdmin,
nonAdminNamespaces: NonAdminNamespaces,
}, nil
}
@@ -193,7 +194,7 @@ func (factory *ClientFactory) createCachedPrivilegedKubeClient(endpoint *portain
return &KubeClient{
cli: cli,
instanceID: factory.instanceID,
IsKubeAdmin: true,
isKubeAdmin: true,
}, nil
}
@@ -371,6 +372,7 @@ func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint, da
log.Error().Err(err).Msgf("Error getting ingresses in environment %d", environment.ID)
return err
}
for _, ingress := range ingresses {
oldController, ok := ingress.Annotations["ingress.portainer.io/ingress-type"]
if !ok {
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// GetClusterRoles gets all the clusterRoles for at the cluster level in a k8s endpoint.
// It returns a list of K8sClusterRole objects.
func (kcl *KubeClient) GetClusterRoles() ([]models.K8sClusterRole, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchClusterRoles()
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// GetClusterRoleBindings gets all the clusterRoleBindings for at the cluster level in a k8s endpoint.
// It returns a list of K8sClusterRoleBinding objects.
func (kcl *KubeClient) GetClusterRoleBindings() ([]models.K8sClusterRoleBinding, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchClusterRoleBindings()
}
+8 -3
View File
@@ -16,18 +16,23 @@ import (
// if the user is an admin, all configMaps in the current k8s environment(endpoint) are fetched using the fetchConfigMaps function.
// otherwise, namespaces the non-admin user has access to will be used to filter the configMaps based on the allowed namespaces.
func (kcl *KubeClient) GetConfigMaps(namespace string) ([]models.K8sConfigMap, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchConfigMaps(namespace)
}
return kcl.fetchConfigMapsForNonAdmin(namespace)
}
// fetchConfigMapsForNonAdmin fetches the configMaps in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) fetchConfigMapsForNonAdmin(namespace string) ([]models.K8sConfigMap, error) {
log.Debug().Msgf("Fetching configMaps for non-admin user: %v", kcl.NonAdminNamespaces)
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
if len(kcl.NonAdminNamespaces) == 0 {
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching configMaps for non-admin user")
if len(nonAdminNamespaces) == 0 {
return nil, nil
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// If the user is a kube admin, it returns all cronjobs in the namespace
// Otherwise, it returns only the cronjobs in the non-admin namespaces
func (kcl *KubeClient) GetCronJobs(namespace string) ([]models.K8sCronJob, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchCronJobs(namespace)
}
+3 -3
View File
@@ -18,7 +18,7 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Run("admin client can fetch Cron Jobs from all namespaces", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = true
kcl.isKubeAdmin = true
cronJobs, err := kcl.GetCronJobs("")
if err != nil {
@@ -31,8 +31,8 @@ func (kcl *KubeClient) TestFetchCronJobs(t *testing.T) {
t.Run("non-admin client can fetch Cron Jobs from the default namespace only", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = false
kcl.NonAdminNamespaces = []string{"default"}
kcl.isKubeAdmin = false
kcl.SetClientNonAdminNamespaces([]string{"default"})
cronJobs, err := kcl.GetCronJobs("")
if err != nil {
+2 -2
View File
@@ -12,7 +12,7 @@ import (
// If the user is a kube admin, it returns all events in the namespace
// Otherwise, it returns only the events in the non-admin namespaces
func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.K8sEvent, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchAllEvents(namespace, resourceId)
}
@@ -22,7 +22,7 @@ func (kcl *KubeClient) GetEvents(namespace string, resourceId string) ([]models.
// fetchEventsForNonAdmin returns all events in the given namespace and resource
// It returns only the events in the non-admin namespaces
func (kcl *KubeClient) fetchEventsForNonAdmin(namespace string, resourceId string) ([]models.K8sEvent, error) {
if len(kcl.NonAdminNamespaces) == 0 {
if len(kcl.GetClientNonAdminNamespaces()) == 0 {
return nil, nil
}
+5 -5
View File
@@ -19,7 +19,7 @@ func TestGetEvents(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: true,
isKubeAdmin: true,
}
event := corev1.Event{
@@ -47,8 +47,8 @@ func TestGetEvents(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{"nonAdmin"},
isKubeAdmin: false,
nonAdminNamespaces: []string{"nonAdmin"},
}
event := corev1.Event{
@@ -77,8 +77,8 @@ func TestGetEvents(t *testing.T) {
kcl := &KubeClient{
cli: kfake.NewSimpleClientset(),
instanceID: "instance",
IsKubeAdmin: false,
NonAdminNamespaces: []string{"nonAdmin"},
isKubeAdmin: false,
nonAdminNamespaces: []string{"nonAdmin"},
}
event := corev1.Event{
+21 -3
View File
@@ -12,6 +12,16 @@ import (
utilexec "k8s.io/client-go/util/exec"
)
var (
channelProtocolList = []string{
"v5.channel.k8s.io",
"v4.channel.k8s.io",
"v3.channel.k8s.io",
"v2.channel.k8s.io",
"channel.k8s.io",
}
)
// StartExecProcess will start an exec process inside a container located inside a pod inside a specific namespace
// using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write
// to the stdout parameter.
@@ -45,10 +55,18 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
TTY: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
exec, err := remotecommand.NewWebSocketExecutorForProtocols(
config,
"GET", // WebSocket uses GET for the upgrade request
req.URL().String(),
channelProtocolList...,
)
if err != nil {
errChan <- err
return
exec, err = remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
errChan <- err
return
}
}
err = exec.StreamWithContext(context.TODO(), remotecommand.StreamOptions{
+8 -3
View File
@@ -87,17 +87,22 @@ func (kcl *KubeClient) GetIngress(namespace, ingressName string) (models.K8sIngr
// GetIngresses gets all the ingresses for a given namespace in a k8s endpoint.
func (kcl *KubeClient) GetIngresses(namespace string) ([]models.K8sIngressInfo, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchIngresses(namespace)
}
return kcl.fetchIngressesForNonAdmin(namespace)
}
// fetchIngressesForNonAdmin gets all the ingresses for non-admin users in a k8s endpoint.
func (kcl *KubeClient) fetchIngressesForNonAdmin(namespace string) ([]models.K8sIngressInfo, error) {
log.Debug().Msgf("Fetching ingresses for non-admin user: %v", kcl.NonAdminNamespaces)
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
if len(kcl.NonAdminNamespaces) == 0 {
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching ingresses for non-admin user")
if len(nonAdminNamespaces) == 0 {
return nil, nil
}
+15
View File
@@ -0,0 +1,15 @@
package cli
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetIngresses(t *testing.T) {
kcl := &KubeClient{}
ingresses, err := kcl.GetIngresses("default")
require.NoError(t, err)
require.Empty(t, ingresses)
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
// If the user is a kube admin, it returns all jobs in the namespace
// Otherwise, it returns only the jobs in the non-admin namespaces
func (kcl *KubeClient) GetJobs(namespace string, includeCronJobChildren bool) ([]models.K8sJob, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchJobs(namespace, includeCronJobChildren)
}
+3 -3
View File
@@ -21,7 +21,7 @@ func (kcl *KubeClient) TestFetchJobs(t *testing.T) {
t.Run("admin client can fetch jobs from all namespaces", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = true
kcl.isKubeAdmin = true
jobs, err := kcl.GetJobs("", false)
if err != nil {
@@ -34,8 +34,8 @@ func (kcl *KubeClient) TestFetchJobs(t *testing.T) {
t.Run("non-admin client can fetch jobs from the default namespace only", func(t *testing.T) {
kcl.cli = kfake.NewSimpleClientset()
kcl.instanceID = "test"
kcl.IsKubeAdmin = false
kcl.NonAdminNamespaces = []string{"default"}
kcl.isKubeAdmin = false
kcl.SetClientNonAdminNamespaces([]string{"default"})
jobs, err := kcl.GetJobs("", false)
if err != nil {
+9 -5
View File
@@ -40,9 +40,10 @@ func defaultSystemNamespaces() map[string]struct{} {
// if the user is an admin, all namespaces in the current k8s environment(endpoint) are fetched using the fetchNamespaces function.
// otherwise, namespaces the non-admin user has access to will be used to filter the namespaces based on the allowed namespaces.
func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchNamespaces()
}
return kcl.fetchNamespacesForNonAdmin()
}
@@ -52,7 +53,7 @@ func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNam
Str("context", "fetchNamespacesForNonAdmin").
Msg("Fetching namespaces for non-admin user")
if len(kcl.NonAdminNamespaces) == 0 {
if len(kcl.GetClientNonAdminNamespaces()) == 0 {
return nil, nil
}
@@ -142,6 +143,7 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1
Str("context", "CreateNamespace").
Str("Namespace", info.Name).
Msg("Failed to create the namespace")
return nil, err
}
@@ -157,7 +159,7 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1
return namespace, nil
}
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
// UpdateNamespace updates a namespace in a k8s endpoint.
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
portainerLabels := map[string]string{
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
@@ -420,8 +422,10 @@ func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8s
// buildNonAdminNamespacesMap builds a map of non-admin namespaces.
// the map is used to filter the namespaces based on the allowed namespaces.
func (kcl *KubeClient) buildNonAdminNamespacesMap() map[string]struct{} {
nonAdminNamespaceSet := make(map[string]struct{}, len(kcl.NonAdminNamespaces))
for _, namespace := range kcl.NonAdminNamespaces {
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
nonAdminNamespaceSet := make(map[string]struct{}, len(nonAdminNamespaces))
for _, namespace := range nonAdminNamespaces {
if !isSystemDefaultNamespace(namespace) {
nonAdminNamespaceSet[namespace] = struct{}{}
}
+1
View File
@@ -178,6 +178,7 @@ func Test_ToggleSystemState(t *testing.T) {
expectedPolicies := map[string]portainer.K8sNamespaceAccessPolicy{
"ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
}
actualPolicies, err := kcl.GetNamespaceAccessPolicies()
require.NoError(t, err, "failed to fetch policies")
assert.Equal(t, expectedPolicies, actualPolicies)
+4 -4
View File
@@ -46,9 +46,9 @@ func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
// GetMaxResourceLimits gets the maximum CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection, minus the accumulated resourcequotas for all namespaces except the one we're editing (skipNamespace)
// if skipNamespace is set to "" then all namespaces are considered
func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (portainer.K8sNodeLimits, error) {
func (kcl *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (portainer.K8sNodeLimits, error) {
limits := portainer.K8sNodeLimits{}
nodes, err := client.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
nodes, err := kcl.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return limits, err
}
@@ -62,7 +62,7 @@ func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitE
limits.Memory = memory / 1000000 // B to MB
if !overCommitEnabled {
namespaces, err := client.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return limits, err
}
@@ -77,7 +77,7 @@ func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitE
}
// minus accumulated resourcequotas for all namespaces except the one we're editing
resourceQuota, err := client.cli.CoreV1().ResourceQuotas(namespace.Name).List(context.TODO(), metav1.ListOptions{})
resourceQuota, err := kcl.cli.CoreV1().ResourceQuotas(namespace.Name).List(context.TODO(), metav1.ListOptions{})
if err != nil {
log.Debug().Msgf("error getting resourcequota for namespace %s: %s", namespace.Name, err)
continue // skip it
+1
View File
@@ -59,6 +59,7 @@ func Test_waitForPodStatus(t *testing.T) {
ctx, cancelFunc := context.WithTimeout(context.TODO(), 0*time.Second)
defer cancelFunc()
err = k.waitForPodStatus(ctx, v1.PodRunning, podSpec)
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("waitForPodStatus should throw deadline exceeded error; err=%s", err)
+8 -9
View File
@@ -2,7 +2,6 @@ package cli
import (
"context"
"fmt"
"strconv"
portainer "github.com/portainer/portainer/api"
@@ -34,7 +33,7 @@ type (
)
func (kcl *KubeClient) DeleteRegistrySecret(registry portainer.RegistryID, namespace string) error {
if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), kcl.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) {
if err := kcl.cli.CoreV1().Secrets(namespace).Delete(context.TODO(), registryutils.RegistrySecretName(registry), metav1.DeleteOptions{}); err != nil && !k8serrors.IsNotFound(err) {
return errors.Wrap(err, "failed removing secret")
}
@@ -62,11 +61,15 @@ func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namesp
}
secret := &v1.Secret{
TypeMeta: metav1.TypeMeta{},
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Name: kcl.RegistrySecretName(registry.ID),
Name: registryutils.RegistrySecretName(registry.ID),
Labels: map[string]string{
labelRegistryType: strconv.Itoa(int(registry.Type)),
labelRegistryType: strconv.Itoa(int(registry.Type)),
"app.kubernetes.io/managed-by": "portainer",
},
Annotations: map[string]string{
annotationRegistryID: strconv.Itoa(int(registry.ID)),
@@ -99,7 +102,3 @@ func (cli *KubeClient) IsRegistrySecret(namespace, secretName string) (bool, err
return isSecret, nil
}
func (*KubeClient) RegistrySecretName(registryID portainer.RegistryID) string {
return fmt.Sprintf("registry-%d", registryID)
}
+8 -3
View File
@@ -15,18 +15,23 @@ import (
// if the user is an admin, all resource quotas in all namespaces are fetched.
// otherwise, namespaces the non-admin user has access to will be used to filter the resource quotas.
func (kcl *KubeClient) GetResourceQuotas(namespace string) (*[]corev1.ResourceQuota, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchResourceQuotas(namespace)
}
return kcl.fetchResourceQuotasForNonAdmin(namespace)
}
// fetchResourceQuotasForNonAdmin gets the resource quotas in the current k8s environment(endpoint) for a non-admin user.
// the role of the user must have read access to the resource quotas in the defined namespaces.
func (kcl *KubeClient) fetchResourceQuotasForNonAdmin(namespace string) (*[]corev1.ResourceQuota, error) {
log.Debug().Msgf("Fetching resource quotas for non-admin user: %v", kcl.NonAdminNamespaces)
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
if len(kcl.NonAdminNamespaces) == 0 {
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching resource quotas for non-admin user")
if len(nonAdminNamespaces) == 0 {
return nil, nil
}
+15
View File
@@ -0,0 +1,15 @@
package cli
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetResourceQuotas(t *testing.T) {
kcl := &KubeClient{}
resourceQuotas, err := kcl.GetResourceQuotas("default")
require.NoError(t, err)
require.Empty(t, resourceQuotas)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// GetRoles gets all the roles for either at the cluster level or a given namespace in a k8s endpoint.
// It returns a list of K8sRole objects.
func (kcl *KubeClient) GetRoles(namespace string) ([]models.K8sRole, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchRoles(namespace)
}
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// GetRoleBindings gets all the roleBindings for either at the cluster level or a given namespace in a k8s endpoint.
// It returns a list of K8sRoleBinding objects.
func (kcl *KubeClient) GetRoleBindings(namespace string) ([]models.K8sRoleBinding, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchRoleBindings(namespace)
}
+8 -3
View File
@@ -23,18 +23,23 @@ const (
// if the user is an admin, all secrets in the current k8s environment(endpoint) are fetched using the getSecrets function.
// otherwise, namespaces the non-admin user has access to will be used to filter the secrets based on the allowed namespaces.
func (kcl *KubeClient) GetSecrets(namespace string) ([]models.K8sSecret, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.getSecrets(namespace)
}
return kcl.getSecretsForNonAdmin(namespace)
}
// getSecretsForNonAdmin fetches the secrets in the namespaces the user has access to.
// This function is called when the user is not an admin.
func (kcl *KubeClient) getSecretsForNonAdmin(namespace string) ([]models.K8sSecret, error) {
log.Debug().Msgf("Fetching secrets for non-admin user: %v", kcl.NonAdminNamespaces)
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
if len(kcl.NonAdminNamespaces) == 0 {
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching secrets for non-admin user")
if len(nonAdminNamespaces) == 0 {
return nil, nil
}
+8 -3
View File
@@ -15,9 +15,10 @@ import (
// GetServices gets all the services for either at the cluster level or a given namespace in a k8s endpoint.
// It returns a list of K8sServiceInfo objects.
func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchServices(namespace)
}
return kcl.fetchServicesForNonAdmin(namespace)
}
@@ -25,9 +26,13 @@ func (kcl *KubeClient) GetServices(namespace string) ([]models.K8sServiceInfo, e
// the namespace will be coming from NonAdminNamespaces as non-admin users are restricted to certain namespaces.
// it returns a list of K8sServiceInfo objects.
func (kcl *KubeClient) fetchServicesForNonAdmin(namespace string) ([]models.K8sServiceInfo, error) {
log.Debug().Msgf("Fetching services for non-admin user: %v", kcl.NonAdminNamespaces)
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
if len(kcl.NonAdminNamespaces) == 0 {
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching services for non-admin user")
if len(nonAdminNamespaces) == 0 {
return nil, nil
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// GetServiceAccounts gets all the service accounts for either at the cluster level or a given namespace in a k8s endpoint.
// It returns a list of K8sServiceAccount objects.
func (kcl *KubeClient) GetServiceAccounts(namespace string) ([]models.K8sServiceAccount, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchServiceAccounts(namespace)
}
+15
View File
@@ -0,0 +1,15 @@
package cli
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetServices(t *testing.T) {
kcl := &KubeClient{}
services, err := kcl.GetServices("default")
require.NoError(t, err)
require.Empty(t, services)
}
+8 -3
View File
@@ -18,9 +18,10 @@ import (
// If the user is not an admin, it fetches the volumes in the namespaces the user has access to.
// It returns a list of K8sVolumeInfo.
func (kcl *KubeClient) GetVolumes(namespace string) ([]models.K8sVolumeInfo, error) {
if kcl.IsKubeAdmin {
if kcl.GetIsKubeAdmin() {
return kcl.fetchVolumes(namespace)
}
return kcl.fetchVolumesForNonAdmin(namespace)
}
@@ -48,9 +49,13 @@ func (kcl *KubeClient) GetVolume(namespace, volumeName string) (*models.K8sVolum
// This function is called when the user is not an admin.
// It fetches all the persistent volume claims, persistent volumes and storage classes in the namespaces the user has access to.
func (kcl *KubeClient) fetchVolumesForNonAdmin(namespace string) ([]models.K8sVolumeInfo, error) {
log.Debug().Msgf("Fetching volumes for non-admin user: %v", kcl.NonAdminNamespaces)
nonAdminNamespaces := kcl.GetClientNonAdminNamespaces()
if len(kcl.NonAdminNamespaces) == 0 {
log.Debug().
Strs("non_admin_namespaces", nonAdminNamespaces).
Msg("fetching volumes for non-admin user")
if len(nonAdminNamespaces) == 0 {
return nil, nil
}
+15
View File
@@ -0,0 +1,15 @@
package cli
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestGetVolumes(t *testing.T) {
kcl := &KubeClient{}
volumes, err := kcl.GetVolumes("default")
require.NoError(t, err)
require.Empty(t, volumes)
}
+18 -6
View File
@@ -13,11 +13,13 @@ import (
)
const (
labelPortainerAppStack = "io.portainer.kubernetes.application.stack"
labelPortainerAppStackID = "io.portainer.kubernetes.application.stackid"
labelPortainerAppName = "io.portainer.kubernetes.application.name"
labelPortainerAppOwner = "io.portainer.kubernetes.application.owner"
labelPortainerAppKind = "io.portainer.kubernetes.application.kind"
labelPortainerAppStack = "io.portainer.kubernetes.application.stack"
labelPortainerAppStackID = "io.portainer.kubernetes.application.stackid"
labelPortainerAppName = "io.portainer.kubernetes.application.name"
labelPortainerAppOwner = "io.portainer.kubernetes.application.owner"
labelPortainerAppOwnerId = "io.portainer.kubernetes.application.owner.id"
labelPortainerAppKind = "io.portainer.kubernetes.application.kind"
labelPortainerAppStackKind = "io.portainer.kubernetes.application.stackKind"
)
// KubeAppLabels are labels applied to all resources deployed in a kubernetes stack
@@ -25,18 +27,28 @@ type KubeAppLabels struct {
StackID int
StackName string
Owner string
OwnerId string
Kind string
StackKind string
}
// ToMap converts KubeAppLabels to a map[string]string
func (kal *KubeAppLabels) ToMap() map[string]string {
return map[string]string{
labels := map[string]string{
labelPortainerAppStackID: strconv.Itoa(kal.StackID),
labelPortainerAppStack: stackutils.SanitizeLabel(kal.StackName),
labelPortainerAppName: stackutils.SanitizeLabel(kal.StackName),
labelPortainerAppOwner: stackutils.SanitizeLabel(kal.Owner),
labelPortainerAppKind: kal.Kind,
labelPortainerAppOwnerId: kal.OwnerId,
}
// Add optional labels only if they are non-empty
if kal.StackKind != "" {
labels[labelPortainerAppStackKind] = kal.StackKind
}
return labels
}
// GetHelmAppLabels returns the labels to be applied to portainer deployed helm applications
+11
View File
@@ -40,6 +40,7 @@ metadata:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: busybox
@@ -88,6 +89,7 @@ metadata:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: busybox
@@ -177,6 +179,7 @@ items:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: web
@@ -198,6 +201,7 @@ items:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: redis
@@ -221,6 +225,7 @@ items:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: web
@@ -303,6 +308,7 @@ metadata:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: busybox
@@ -329,6 +335,7 @@ metadata:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: web
@@ -348,6 +355,7 @@ metadata:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: busybox
@@ -397,6 +405,7 @@ metadata:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
name: web
@@ -619,6 +628,7 @@ metadata:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
---
@@ -630,6 +640,7 @@ metadata:
io.portainer.kubernetes.application.kind: git
io.portainer.kubernetes.application.name: best-name
io.portainer.kubernetes.application.owner: best-owner
io.portainer.kubernetes.application.owner.id: ""
io.portainer.kubernetes.application.stack: best-name
io.portainer.kubernetes.application.stackid: "123"
`
+104 -3
View File
@@ -29,6 +29,8 @@ type (
AccessPolicy struct {
// Role identifier. Reference the role that will be associated to this access policy
RoleID RoleID `json:"RoleId" example:"1"`
// Namespaces is a list of namespaces that this access policy applies to. Only used for namespaced level roles
Namespaces []string `json:"Namespaces,omitempty"`
}
// AgentPlatform represents a platform type for an Agent
@@ -112,6 +114,7 @@ type (
AdminPasswordFile *string
Assets *string
CSP *bool
CompactDB *bool
Data *string
FeatureFlags *[]string
EnableEdgeComputeFeatures *bool
@@ -346,6 +349,10 @@ type (
DeploymentType EdgeStackDeploymentType `json:"DeploymentType"`
// Uses the manifest's namespaces instead of the default one
UseManifestNamespaces bool
// The username id which created this stack
CreatedByUserId string `example:"1"`
// The username which created this stack
CreatedBy string `example:"admin"`
}
EdgeStackStatusForEnv struct {
@@ -353,6 +360,14 @@ type (
Status []EdgeStackDeploymentStatus
// EE only feature
DeploymentInfo StackDeploymentInfo
// RePullImage is a flag to indicate whether the auto update is trigger to re-pull image
RePullImage bool `json:"RePullImage,omitempty"`
// ForceRedeploy is a flag to indicate whether the force redeployment is set for the current
// deployment of the edge stack. The redeployment could be triggered by GitOps Update or manually by user.
ForceRedeploy bool `json:"ForceRedeploy,omitempty"`
// Deprecated(2.36): use ForceRedeploy and RePullImage instead for cleaner
// responsibility, but keep it for backward compatibility. To remove in future versions (2.44+)
// ReadyRePullImage is a flag to indicate whether the auto update is trigger to re-pull image
ReadyRePullImage bool `json:"ReadyRePullImage,omitempty"`
}
@@ -523,6 +538,65 @@ type (
Tags []string `json:"Tags,omitempty"`
}
PolicyChartSummary struct {
ChartName string `json:"ChartName"`
Fingerprint string `json:"Fingerprint"`
}
PolicyChartStatus struct {
ChartName string `json:"chartName"`
Fingerprint string `json:"fingerprint"`
Status HelmInstallStatus `json:"status"`
Message string `json:"message"`
Namespace string `json:"namespace"`
}
ImageBundle struct {
FileName string `json:"FileName"`
EncodedTarGz string `json:"EncodedTarGz"`
}
PolicyChartBundle struct {
PolicyChartSummary
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
EncodedValues string `json:"EncodedValues"`
PreInstallDeletions []ResourceDeletion `json:"PreInstallDeletions,omitempty"`
PreInstallAdoptions []ResourceAdoption `json:"PreInstallAdoptions,omitempty"`
}
// ResourceDeletion identifies an existing Kubernetes resource to delete before policy install
ResourceDeletion struct {
APIVersion string `json:"apiVersion" example:"v1" yaml:"apiVersion"`
Kind string `json:"kind" example:"Secret" yaml:"kind"`
Name string `json:"name" example:"registry-1" yaml:"name"`
Namespace string `json:"namespace,omitempty" example:"default" yaml:"namespace,omitempty"`
}
// ResourceAdoption identifies an existing Kubernetes resource to adopt into a Helm release
ResourceAdoption struct {
APIVersion string `json:"apiVersion" example:"v1" yaml:"apiVersion"`
Kind string `json:"kind" example:"Secret" yaml:"kind"`
Name string `json:"name" example:"registry-1" yaml:"name"`
Namespace string `json:"namespace,omitempty" example:"default" yaml:"namespace,omitempty"`
}
// RestoreSettings contains instructions for restoring environment-level settings
RestoreSettings struct {
Manifest string `json:"manifest"` // Base64-encoded Kubernetes YAML manifest
}
// RestoreSettingsBundle maps restore type to restoration instructions
RestoreSettingsBundle map[PolicyType]RestoreSettings
PolicyID int
// PolicyType represents the type of policy
PolicyType string
)
type (
// EndpointGroupID represents an environment(endpoint) group identifier
EndpointGroupID int
@@ -853,9 +927,11 @@ type (
RegistryAccesses map[EndpointID]RegistryAccessPolicies
RegistryAccessPolicies struct {
// Docker specific fields (with docker, users/teams have access to a registry)
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
Namespaces []string `json:"Namespaces"`
// Kubernetes specific fields (with kubernetes, namespaces have access to a registry, if users/teams have access to the same namespace, they have access to the registry)
Namespaces []string `json:"Namespaces"`
}
// RegistryID represents a registry identifier
@@ -1111,6 +1187,8 @@ type (
StackOption struct {
// Prune services that are no longer referenced
Prune bool `example:"false"`
// Enable atomic rollback on failure (Helm --atomic flag for Kubernetes Helm stacks)
HelmAtomic bool `example:"false"`
}
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
@@ -1779,9 +1857,9 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.34.0"
APIVersion = "2.37.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "LTS"
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
@@ -1844,6 +1922,8 @@ const (
TrustedOriginsEnvVar = "TRUSTED_ORIGINS"
// CSPEnvVar is the environment variable used to enable/disable the Content Security Policy
CSPEnvVar = "CSP"
// CompactDBEnvVar is the environment variable used to enable/disable the startup compaction of the database
CompactDBEnvVar = "COMPACT_DB"
)
// List of supported features
@@ -2350,3 +2430,24 @@ const (
ContainerEngineDocker = "docker"
ContainerEnginePodman = "podman"
)
const (
// PolicyType constants
RbacK8s PolicyType = "rbac-k8s"
SecurityK8s PolicyType = "security-k8s"
SetupK8s PolicyType = "setup-k8s"
RegistryK8s PolicyType = "registry-k8s"
RbacDocker PolicyType = "rbac-docker"
SecurityDocker PolicyType = "security-docker"
SetupDocker PolicyType = "setup-docker"
RegistryDocker PolicyType = "registry-docker"
)
type HelmInstallStatus string
const (
HelmInstallStatusInstalling HelmInstallStatus = "installing"
HelmInstallStatusInstalled HelmInstallStatus = "installed"
HelmInstallStatusFailed HelmInstallStatus = "failed"
HelmInstallStatusUninstalling HelmInstallStatus = "uninstalling"
)
+102 -87
View File
@@ -5,6 +5,7 @@ import (
"errors"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
@@ -18,136 +19,150 @@ func requireNoShutdownErr(t *testing.T, fn func() error) {
}
func Test_ScheduledJobRuns(t *testing.T) {
s := NewScheduler(context.Background())
defer requireNoShutdownErr(t, s.Shutdown)
synctest.Test(t, func(t *testing.T) {
s := NewScheduler(t.Context())
defer requireNoShutdownErr(t, s.Shutdown)
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval)
var workDone bool
s.StartJobEvery(jobInterval, func() error {
workDone = true
var workDone bool
s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
cancel()
return nil
return nil
})
<-ctx.Done()
assert.True(t, workDone, "value should been set in the job")
})
<-ctx.Done()
assert.True(t, workDone, "value should been set in the job")
}
func Test_JobCanBeStopped(t *testing.T) {
s := NewScheduler(context.Background())
defer requireNoShutdownErr(t, s.Shutdown)
synctest.Test(t, func(t *testing.T) {
s := NewScheduler(t.Context())
defer requireNoShutdownErr(t, s.Shutdown)
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval)
var workDone bool
jobID := s.StartJobEvery(jobInterval, func() error {
workDone = true
var workDone bool
jobID := s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
cancel()
return nil
return nil
})
err := s.StopJob(jobID)
require.NoError(t, err)
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
})
err := s.StopJob(jobID)
require.NoError(t, err)
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
}
func Test_JobShouldStop_UponPermError(t *testing.T) {
s := NewScheduler(context.Background())
defer requireNoShutdownErr(t, s.Shutdown)
synctest.Test(t, func(t *testing.T) {
s := NewScheduler(t.Context())
defer requireNoShutdownErr(t, s.Shutdown)
var acc int
var acc int
ch := make(chan struct{})
s.StartJobEvery(jobInterval, func() error {
acc++
close(ch)
return NewPermanentError(errors.New("failed"))
})
<-time.After(3 * jobInterval)
<-ch
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
}
func Test_JobShouldNotStop_UponError(t *testing.T) {
s := NewScheduler(context.Background())
defer requireNoShutdownErr(t, s.Shutdown)
var acc atomic.Int64
ch := make(chan struct{})
s.StartJobEvery(jobInterval, func() error {
if acc.Add(1) == 2 {
ch := make(chan struct{})
s.StartJobEvery(jobInterval, func() error {
acc++
close(ch)
return NewPermanentError(errors.New("failed"))
}
})
return errors.New("non-permanent error")
<-time.After(3 * jobInterval)
<-ch
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
})
}
<-time.After(3 * jobInterval)
<-ch
assert.Equal(t, int64(2), acc.Load())
func Test_JobShouldNotStop_UponError(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
s := NewScheduler(t.Context())
defer requireNoShutdownErr(t, s.Shutdown)
var acc atomic.Int64
ch := make(chan struct{})
s.StartJobEvery(jobInterval, func() error {
if acc.Add(1) == 2 {
close(ch)
return NewPermanentError(errors.New("failed"))
}
return errors.New("non-permanent error")
})
<-time.After(3 * jobInterval)
<-ch
assert.Equal(t, int64(2), acc.Load())
})
}
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
s := NewScheduler(context.Background())
synctest.Test(t, func(t *testing.T) {
s := NewScheduler(t.Context())
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval)
var workDone bool
s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
var workDone bool
s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
return nil
return nil
})
requireNoShutdownErr(t, s.Shutdown)
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
})
requireNoShutdownErr(t, s.Shutdown)
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
}
func Test_CanTerminateAllJobs_ByCancellingParentContext(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
s := NewScheduler(ctx)
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 2*jobInterval)
s := NewScheduler(ctx)
var workDone bool
s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
return nil
})
var workDone bool
s.StartJobEvery(jobInterval, func() error {
workDone = true
cancel()
return nil
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
})
cancel()
<-ctx.Done()
assert.False(t, workDone, "job shouldn't had a chance to run")
}
func Test_StartJobEvery_Concurrently(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
s := NewScheduler(ctx)
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*jobInterval)
s := NewScheduler(ctx)
f := func() error {
return errors.New("error")
}
f := func() error {
return errors.New("error")
}
go s.StartJobEvery(jobInterval, f)
s.StartJobEvery(jobInterval, f)
go s.StartJobEvery(jobInterval, f)
s.StartJobEvery(jobInterval, f)
cancel()
cancel()
<-ctx.Done()
<-ctx.Done()
})
}
+5 -1
View File
@@ -121,7 +121,7 @@ func redeployWhenChangedSecondStage(
var gitCommitChangedOrForceUpdate bool
if !stack.FromAppTemplate {
updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stack.ID), stack.GitConfig, false, false, stack.ProjectPath)
updated, newHash, err := update.UpdateGitObject(gitService, fmt.Sprintf("stack:%d", stack.ID), stack.GitConfig, false, stack.ProjectPath)
if err != nil {
return err
}
@@ -131,6 +131,10 @@ func redeployWhenChangedSecondStage(
stack.UpdateDate = time.Now().Unix()
gitCommitChangedOrForceUpdate = updated
}
if stack.AutoUpdate != nil && stack.AutoUpdate.ForceUpdate {
gitCommitChangedOrForceUpdate = true
}
}
if !gitCommitChangedOrForceUpdate {

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