Compare commits
38 Commits
2.6.1
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114073ae59 | ||
|
|
f1e2bb14a9 | ||
|
|
ed2c65c1e6 | ||
|
|
51ef2c2aa9 | ||
|
|
5652bac004 | ||
|
|
ce31de5e9e | ||
|
|
cee7ac26e9 | ||
|
|
c943ac498f | ||
|
|
49f25e9c4c | ||
|
|
7d6b1edd48 | ||
|
|
c26af1449c | ||
|
|
09c5bada3e | ||
|
|
fe07815fc7 | ||
|
|
c56c236e3a | ||
|
|
68453482af | ||
|
|
7b2269fbba | ||
|
|
bd47bb8cdc | ||
|
|
f9ffb1a712 | ||
|
|
592f7024e1 | ||
|
|
00fc629c1c | ||
|
|
6a9b386df8 | ||
|
|
8aa3bfc59c | ||
|
|
308f828446 | ||
|
|
89756b2e01 | ||
|
|
db16299aab | ||
|
|
72117693fb | ||
|
|
179df06267 | ||
|
|
0f5407da40 | ||
|
|
2fd95d87eb | ||
|
|
33b428eb7f | ||
|
|
c6b770d697 | ||
|
|
d48f6bd02c | ||
|
|
340805f880 | ||
|
|
f6c5c552aa | ||
|
|
90a472c08b | ||
|
|
8b80eb1731 | ||
|
|
d2404458ea | ||
|
|
6a39a5cf44 |
4
.vscode.example/settings.json
Normal file
4
.vscode.example/settings.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintFlags": ["--fast", "-E", "exportloopref"]
|
||||
}
|
||||
40
README.md
40
README.md
@@ -1,16 +1,12 @@
|
||||
<p align="center">
|
||||
<img title="portainer" src='https://github.com/portainer/portainer/blob/develop/app/assets/images/logo_alt.png?raw=true' />
|
||||
<img title="portainer" src='https://github.com/portainer/portainer/blob/develop/app/assets/images/portainer-github-banner.png?raw=true' />
|
||||
</p>
|
||||
|
||||
[](https://hub.docker.com/r/portainer/portainer/)
|
||||
[](http://microbadger.com/images/portainer/portainer 'Image size')
|
||||
[](https://portainer.visualstudio.com/Portainer%20CI/_build/latest?definitionId=3&branchName=develop)
|
||||
[](https://codeclimate.com/github/portainer/portainer)
|
||||
[](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6)
|
||||
**Portainer CE** is a lightweight ‘universal’ management GUI that can be used to **easily** manage Docker, Swarm, Kubernetes and ACI environments. It is designed to be as **simple** to deploy as it is to use.
|
||||
|
||||
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
|
||||
**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container, supports other platforms too).
|
||||
**_Portainer_** allows you to manage all your Docker resources (containers, images, volumes, networks and more!) It is compatible with the _standalone Docker_ engine and with _Docker Swarm mode_.
|
||||
Portainer consists of a single container that can run on any cluster. It can be deployed as a Linux container or a Windows native container.
|
||||
|
||||
**Portainer** allows you to manage all your orchestrator resources (containers, images, volumes, networks and more) through a super-simple graphical interface.
|
||||
|
||||
## Demo
|
||||
|
||||
@@ -18,30 +14,38 @@ You can try out the public demo instance: http://demo.portainer.io/ (login with
|
||||
|
||||
Please note that the public demo cluster is **reset every 15min**.
|
||||
|
||||
Alternatively, you can deploy a copy of the demo stack inside a [play-with-docker (PWD)](https://labs.play-with-docker.com) playground:
|
||||
## Latest Version
|
||||
|
||||
- Browse [PWD/?stack=portainer-demo/play-with-docker/docker-stack.yml](http://play-with-docker.com/?stack=https://raw.githubusercontent.com/portainer/portainer-demo/master/play-with-docker/docker-stack.yml)
|
||||
- Sign in with your [Docker ID](https://docs.docker.com/docker-id)
|
||||
- Follow [these](https://github.com/portainer/portainer-demo/blob/master/play-with-docker/docker-stack.yml#L5-L8) steps.
|
||||
Portainer CE is updated regularly. We aim to do an update release every couple of months.
|
||||
|
||||
Unlike the public demo, the playground sessions are deleted after 4 hours. Apart from that, all the settings are the same, including default credentials.
|
||||
**The latest version of Portainer is 2.6.x** And you can find the release notes [here.](https://www.portainer.io/blog/new-portainer-ce-2.6.0-release)
|
||||
Portainer is on version 2, the second number denotes the month of release.
|
||||
|
||||
## Getting started
|
||||
|
||||
- [Deploy Portainer](https://documentation.portainer.io/quickstart/)
|
||||
- [Documentation](https://documentation.portainer.io)
|
||||
- [Building Portainer](https://documentation.portainer.io/contributing/instructions/)
|
||||
- [Contribute to the project](https://documentation.portainer.io/contributing/instructions/)
|
||||
|
||||
## Features & Functions
|
||||
|
||||
View [this](https://www.portainer.io/products) table to see all of the Portainer CE functionality and compare to Portainer Business.
|
||||
|
||||
- [Portainer CE for Docker / Docker Swarm](https://www.portainer.io/solutions/docker)
|
||||
- [Portainer CE for Kubernetes](https://www.portainer.io/solutions/kubernetes-ui)
|
||||
- [Portainer CE for Azure ACI](https://www.portainer.io/solutions/serverless-containers)
|
||||
|
||||
## Getting help
|
||||
|
||||
For FORMAL Support, please purchase a support subscription from here: https://www.portainer.io/products/portainer-business
|
||||
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
|
||||
|
||||
For community support: You can find more information about Portainer's community support framework policy here: https://www.portainer.io/products/community-edition/customer-success
|
||||
Learn more about Portainers community support channels [here.](https://www.portainer.io/help_about)
|
||||
|
||||
- Issues: https://github.com/portainer/portainer/issues
|
||||
- FAQ: https://documentation.portainer.io
|
||||
- Slack (chat): https://portainer.io/slack/
|
||||
|
||||
You can join the Portainer Community by visiting community.portainer.io. This will give you advance notice of events, content and other related Portainer content.
|
||||
|
||||
## Reporting bugs and contributing
|
||||
|
||||
- Want to report a bug or request a feature? Please open [an issue](https://github.com/portainer/portainer/issues/new).
|
||||
|
||||
@@ -169,6 +169,7 @@ func (store *Store) MigrateData(force bool) error {
|
||||
UserService: store.UserService,
|
||||
VersionService: store.VersionService,
|
||||
FileService: store.fileService,
|
||||
DockerhubService: store.DockerHubService,
|
||||
AuthorizationService: authorization.NewService(store),
|
||||
}
|
||||
migrator := migrator.NewMigrator(migratorParams)
|
||||
|
||||
@@ -55,22 +55,6 @@ func (store *Store) Init() error {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = store.DockerHubService.DockerHub()
|
||||
if err == errors.ErrObjectNotFound {
|
||||
defaultDockerHub := &portainer.DockerHub{
|
||||
Authentication: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
}
|
||||
|
||||
err := store.DockerHubService.UpdateDockerHub(defaultDockerHub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
groups, err := store.EndpointGroupService.EndpointGroups()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
41
api/bolt/log/log.go
Normal file
41
api/bolt/log/log.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
const (
|
||||
INFO = "INFO"
|
||||
ERROR = "ERROR"
|
||||
DEBUG = "DEBUG"
|
||||
FATAL = "FATAL"
|
||||
)
|
||||
|
||||
type ScopedLog struct {
|
||||
scope string
|
||||
}
|
||||
|
||||
func NewScopedLog(scope string) *ScopedLog {
|
||||
return &ScopedLog{scope: scope}
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) print(kind string, message string) {
|
||||
log.Printf("[%s] [%s] %s", kind, slog.scope, message)
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) Debug(message string) {
|
||||
slog.print(DEBUG, fmt.Sprintf("[message: %s]", message))
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) Info(message string) {
|
||||
slog.print(INFO, fmt.Sprintf("[message: %s]", message))
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) Error(message string, err error) {
|
||||
slog.print(ERROR, fmt.Sprintf("[message: %s] [error: %s]", message, err))
|
||||
}
|
||||
|
||||
func (slog *ScopedLog) NotImplemented(method string) {
|
||||
log.Fatalf("[%s] [%s] [%s]", FATAL, slog.scope, fmt.Sprintf("%s is not yet implemented", method))
|
||||
}
|
||||
1
api/bolt/log/log.test.go
Normal file
1
api/bolt/log/log.test.go
Normal file
@@ -0,0 +1 @@
|
||||
package log
|
||||
@@ -1,13 +1,14 @@
|
||||
package migrator
|
||||
|
||||
func (m *Migrator) migrateDBVersionTo30() error {
|
||||
if err := m.migrateSettings(); err != nil {
|
||||
func (m *Migrator) migrateDBVersionToDB30() error {
|
||||
if err := m.migrateSettingsToDB30(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) migrateSettings() error {
|
||||
func (m *Migrator) migrateSettingsToDB30() error {
|
||||
legacySettings, err := m.settingsService.Settings()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -76,7 +76,7 @@ func TestMigrateSettings(t *testing.T) {
|
||||
db: dbConn,
|
||||
settingsService: settingsService,
|
||||
}
|
||||
if err := m.migrateSettings(); err != nil {
|
||||
if err := m.migrateSettingsToDB30(); err != nil {
|
||||
t.Errorf("failed to update settings: %v", err)
|
||||
}
|
||||
updatedSettings, err := m.settingsService.Settings()
|
||||
213
api/bolt/migrator/migrate_dbversion31.go
Normal file
213
api/bolt/migrator/migrate_dbversion31.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package migrator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
endpointutils "github.com/portainer/portainer/api/internal/endpoint"
|
||||
snapshotutils "github.com/portainer/portainer/api/internal/snapshot"
|
||||
)
|
||||
|
||||
func (m *Migrator) migrateDBVersionToDB32() error {
|
||||
err := m.updateRegistriesToDB32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.updateDockerhubToDB32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := m.updateVolumeResourceControlToDB32(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateRegistriesToDB32() error {
|
||||
registries, err := m.registryService.Registries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, registry := range registries {
|
||||
|
||||
registry.RegistryAccesses = portainer.RegistryAccesses{}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
|
||||
filteredUserAccessPolicies := portainer.UserAccessPolicies{}
|
||||
for userId, registryPolicy := range registry.UserAccessPolicies {
|
||||
if _, found := endpoint.UserAccessPolicies[userId]; found {
|
||||
filteredUserAccessPolicies[userId] = registryPolicy
|
||||
}
|
||||
}
|
||||
|
||||
filteredTeamAccessPolicies := portainer.TeamAccessPolicies{}
|
||||
for teamId, registryPolicy := range registry.TeamAccessPolicies {
|
||||
if _, found := endpoint.TeamAccessPolicies[teamId]; found {
|
||||
filteredTeamAccessPolicies[teamId] = registryPolicy
|
||||
}
|
||||
}
|
||||
|
||||
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{
|
||||
UserAccessPolicies: filteredUserAccessPolicies,
|
||||
TeamAccessPolicies: filteredTeamAccessPolicies,
|
||||
Namespaces: []string{},
|
||||
}
|
||||
}
|
||||
m.registryService.UpdateRegistry(registry.ID, ®istry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) updateDockerhubToDB32() error {
|
||||
dockerhub, err := m.dockerhubService.DockerHub()
|
||||
if err == errors.ErrObjectNotFound {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !dockerhub.Authentication {
|
||||
return nil
|
||||
}
|
||||
|
||||
registry := &portainer.Registry{
|
||||
Type: portainer.DockerHubRegistry,
|
||||
Name: "Dockerhub (authenticated - migrated)",
|
||||
URL: "docker.io",
|
||||
Authentication: true,
|
||||
Username: dockerhub.Username,
|
||||
Password: dockerhub.Password,
|
||||
RegistryAccesses: portainer.RegistryAccesses{},
|
||||
}
|
||||
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
|
||||
if endpoint.Type != portainer.KubernetesLocalEnvironment &&
|
||||
endpoint.Type != portainer.AgentOnKubernetesEnvironment &&
|
||||
endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
|
||||
userAccessPolicies := portainer.UserAccessPolicies{}
|
||||
for userId := range endpoint.UserAccessPolicies {
|
||||
if _, found := endpoint.UserAccessPolicies[userId]; found {
|
||||
userAccessPolicies[userId] = portainer.AccessPolicy{
|
||||
RoleID: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teamAccessPolicies := portainer.TeamAccessPolicies{}
|
||||
for teamId := range endpoint.TeamAccessPolicies {
|
||||
if _, found := endpoint.TeamAccessPolicies[teamId]; found {
|
||||
teamAccessPolicies[teamId] = portainer.AccessPolicy{
|
||||
RoleID: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{
|
||||
UserAccessPolicies: userAccessPolicies,
|
||||
TeamAccessPolicies: teamAccessPolicies,
|
||||
Namespaces: []string{},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m.registryService.CreateRegistry(registry)
|
||||
}
|
||||
|
||||
func (m *Migrator) updateVolumeResourceControlToDB32() error {
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching endpoints: %w", err)
|
||||
}
|
||||
|
||||
resourceControls, err := m.resourceControlService.ResourceControls()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching resource controls: %w", err)
|
||||
}
|
||||
|
||||
toUpdate := map[portainer.ResourceControlID]string{}
|
||||
volumeResourceControls := map[string]*portainer.ResourceControl{}
|
||||
|
||||
for i := range resourceControls {
|
||||
resourceControl := resourceControls[i]
|
||||
if resourceControl.Type == portainer.VolumeResourceControl {
|
||||
volumeResourceControls[resourceControl.ResourceID] = &resourceControl
|
||||
}
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if !endpointutils.IsDockerEndpoint(&endpoint) {
|
||||
continue
|
||||
}
|
||||
|
||||
totalSnapshots := len(endpoint.Snapshots)
|
||||
if totalSnapshots == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshot := endpoint.Snapshots[totalSnapshots-1]
|
||||
|
||||
endpointDockerID, err := snapshotutils.FetchDockerID(snapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching endpoint docker id: %w", err)
|
||||
}
|
||||
|
||||
if volumesData, done := snapshot.SnapshotRaw.Volumes.(map[string]interface{}); done {
|
||||
if volumesData["Volumes"] == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
findResourcesToUpdateForDB32(endpointDockerID, volumesData, toUpdate, volumeResourceControls)
|
||||
}
|
||||
}
|
||||
|
||||
for _, resourceControl := range volumeResourceControls {
|
||||
if newResourceID, ok := toUpdate[resourceControl.ID]; ok {
|
||||
resourceControl.ResourceID = newResourceID
|
||||
err := m.resourceControlService.UpdateResourceControl(resourceControl.ID, resourceControl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed updating resource control %d: %w", resourceControl.ID, err)
|
||||
}
|
||||
|
||||
} else {
|
||||
err := m.resourceControlService.DeleteResourceControl(resourceControl.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed deleting resource control %d: %w", resourceControl.ID, err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findResourcesToUpdateForDB32(dockerID string, volumesData map[string]interface{}, toUpdate map[portainer.ResourceControlID]string, volumeResourceControls map[string]*portainer.ResourceControl) {
|
||||
volumes := volumesData["Volumes"].([]interface{})
|
||||
for _, volumeMeta := range volumes {
|
||||
volume := volumeMeta.(map[string]interface{})
|
||||
volumeName := volume["Name"].(string)
|
||||
oldResourceID := fmt.Sprintf("%s%s", volumeName, volume["CreatedAt"].(string))
|
||||
resourceControl, ok := volumeResourceControls[oldResourceID]
|
||||
|
||||
if ok {
|
||||
toUpdate[resourceControl.ID] = fmt.Sprintf("%s_%s", volumeName, dockerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package migrator
|
||||
import (
|
||||
"github.com/boltdb/bolt"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/dockerhub"
|
||||
"github.com/portainer/portainer/api/bolt/endpoint"
|
||||
"github.com/portainer/portainer/api/bolt/endpointgroup"
|
||||
"github.com/portainer/portainer/api/bolt/endpointrelation"
|
||||
"github.com/portainer/portainer/api/bolt/extension"
|
||||
plog "github.com/portainer/portainer/api/bolt/log"
|
||||
"github.com/portainer/portainer/api/bolt/registry"
|
||||
"github.com/portainer/portainer/api/bolt/resourcecontrol"
|
||||
"github.com/portainer/portainer/api/bolt/role"
|
||||
@@ -20,6 +22,8 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
var migrateLog = plog.NewScopedLog("bolt, migrate")
|
||||
|
||||
type (
|
||||
// Migrator defines a service to migrate data after a Portainer version update.
|
||||
Migrator struct {
|
||||
@@ -41,6 +45,7 @@ type (
|
||||
versionService *version.Service
|
||||
fileService portainer.FileService
|
||||
authorizationService *authorization.Service
|
||||
dockerhubService *dockerhub.Service
|
||||
}
|
||||
|
||||
// Parameters represents the required parameters to create a new Migrator instance.
|
||||
@@ -63,6 +68,7 @@ type (
|
||||
VersionService *version.Service
|
||||
FileService portainer.FileService
|
||||
AuthorizationService *authorization.Service
|
||||
DockerhubService *dockerhub.Service
|
||||
}
|
||||
)
|
||||
|
||||
@@ -87,6 +93,7 @@ func NewMigrator(parameters *Parameters) *Migrator {
|
||||
versionService: parameters.VersionService,
|
||||
fileService: parameters.FileService,
|
||||
authorizationService: parameters.AuthorizationService,
|
||||
dockerhubService: parameters.DockerhubService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +367,15 @@ func (m *Migrator) Migrate() error {
|
||||
|
||||
// Portainer 2.6.0
|
||||
if m.currentDBVersion < 30 {
|
||||
err := m.migrateDBVersionTo30()
|
||||
err := m.migrateDBVersionToDB30()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Portainer 2.9.0
|
||||
if m.currentDBVersion < 32 {
|
||||
err := m.migrateDBVersionToDB32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -167,11 +167,6 @@ func (store *Store) CustomTemplate() portainer.CustomTemplateService {
|
||||
return store.CustomTemplateService
|
||||
}
|
||||
|
||||
// DockerHub gives access to the DockerHub data management layer
|
||||
func (store *Store) DockerHub() portainer.DockerHubService {
|
||||
return store.DockerHubService
|
||||
}
|
||||
|
||||
// EdgeGroup gives access to the EdgeGroup data management layer
|
||||
func (store *Store) EdgeGroup() portainer.EdgeGroupService {
|
||||
return store.EdgeGroupService
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
19
api/cmd/portainer/log.go
Normal file
19
api/cmd/portainer/log.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func configureLogger() {
|
||||
logger := logrus.New() // logger is to implicitly substitute stdlib's log
|
||||
log.SetOutput(logger.Writer())
|
||||
|
||||
formatter := &logrus.TextFormatter{DisableTimestamp: true, DisableLevelTruncation: true}
|
||||
logger.SetFormatter(formatter)
|
||||
logrus.SetFormatter(formatter)
|
||||
|
||||
logger.SetLevel(logrus.DebugLevel)
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
wrapper "github.com/portainer/docker-compose-wrapper"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt"
|
||||
"github.com/portainer/portainer/api/chisel"
|
||||
@@ -77,20 +78,25 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port
|
||||
}
|
||||
|
||||
func initComposeStackManager(assetsPath string, dataStorePath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper := exec.NewComposeWrapper(assetsPath, dataStorePath, proxyManager)
|
||||
if composeWrapper != nil {
|
||||
return composeWrapper
|
||||
composeWrapper, err := exec.NewComposeStackManager(assetsPath, dataStorePath, proxyManager)
|
||||
if err != nil {
|
||||
if err == wrapper.ErrBinaryNotFound {
|
||||
log.Printf("[INFO] [message: docker-compose binary not found, falling back to libcompose]")
|
||||
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
|
||||
}
|
||||
|
||||
log.Fatalf("failed initalizing compose stack manager; err=%s", err)
|
||||
}
|
||||
|
||||
return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
|
||||
return composeWrapper
|
||||
}
|
||||
|
||||
func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) {
|
||||
return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService)
|
||||
}
|
||||
|
||||
func initKubernetesDeployer(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(dataStore, reverseTunnelService, signatureService, assetsPath)
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, assetsPath)
|
||||
}
|
||||
|
||||
func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) {
|
||||
@@ -134,8 +140,8 @@ func initDockerClientFactory(signatureService portainer.DigitalSignatureService,
|
||||
return docker.NewClientFactory(signatureService, reverseTunnelService)
|
||||
}
|
||||
|
||||
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *kubecli.ClientFactory {
|
||||
return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID)
|
||||
func initKubernetesClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *kubecli.ClientFactory {
|
||||
return kubecli.NewClientFactory(signatureService, reverseTunnelService, instanceID, dataStore)
|
||||
}
|
||||
|
||||
func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, dockerClientFactory *docker.ClientFactory, kubernetesClientFactory *kubecli.ClientFactory, shutdownCtx context.Context) (portainer.SnapshotService, error) {
|
||||
@@ -382,7 +388,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
|
||||
dockerClientFactory := initDockerClientFactory(digitalSignatureService, reverseTunnelService)
|
||||
kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID)
|
||||
kubernetesClientFactory := initKubernetesClientFactory(digitalSignatureService, reverseTunnelService, instanceID, dataStore)
|
||||
|
||||
snapshotService, err := initSnapshotService(*flags.SnapshotInterval, dataStore, dockerClientFactory, kubernetesClientFactory, shutdownCtx)
|
||||
if err != nil {
|
||||
@@ -402,7 +408,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
|
||||
composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager)
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets)
|
||||
|
||||
if dataStore.IsNew() {
|
||||
err = updateSettingsFromFlags(dataStore, flags)
|
||||
@@ -497,6 +503,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
func main() {
|
||||
flags := initCLI()
|
||||
|
||||
configureLogger()
|
||||
|
||||
for {
|
||||
server := buildServer(flags)
|
||||
log.Printf("Starting Portainer %s on %s\n", portainer.APIVersion, *flags.Addr)
|
||||
|
||||
122
api/exec/compose_stack.go
Normal file
122
api/exec/compose_stack.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
wrapper "github.com/portainer/docker-compose-wrapper"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
)
|
||||
|
||||
// ComposeStackManager is a wrapper for docker-compose binary
|
||||
type ComposeStackManager struct {
|
||||
wrapper *wrapper.ComposeWrapper
|
||||
configPath string
|
||||
proxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil
|
||||
func NewComposeStackManager(binaryPath string, configPath string, proxyManager *proxy.Manager) (*ComposeStackManager, error) {
|
||||
wrap, err := wrapper.NewComposeWrapper(binaryPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ComposeStackManager{
|
||||
wrapper: wrap,
|
||||
proxyManager: proxyManager,
|
||||
configPath: configPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (w *ComposeStackManager) NormalizeStackName(name string) string {
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
|
||||
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
|
||||
func (w *ComposeStackManager) ComposeSyntaxMaxVersion() string {
|
||||
return portainer.ComposeSyntaxMaxVersion
|
||||
}
|
||||
|
||||
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
|
||||
func (w *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := w.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
envFilePath, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filePath := stackFilePath(stack)
|
||||
|
||||
_, err = w.wrapper.Up([]string{filePath}, url, stack.Name, envFilePath, w.configPath)
|
||||
return err
|
||||
}
|
||||
|
||||
// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command
|
||||
func (w *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
url, proxy, err := w.fetchEndpointProxy(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
filePath := stackFilePath(stack)
|
||||
|
||||
_, err = w.wrapper.Down([]string{filePath}, url, stack.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func stackFilePath(stack *portainer.Stack) string {
|
||||
return path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
}
|
||||
|
||||
func (w *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
|
||||
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("http://127.0.0.1:%d", proxy.Port), proxy, nil
|
||||
}
|
||||
|
||||
func createEnvFile(stack *portainer.Stack) (string, error) {
|
||||
if stack.Env == nil || len(stack.Env) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
envFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
|
||||
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
return envFilePath, nil
|
||||
}
|
||||
@@ -33,7 +33,9 @@ func setup(t *testing.T) (*portainer.Stack, *portainer.Endpoint) {
|
||||
Name: "project-name",
|
||||
}
|
||||
|
||||
endpoint := &portainer.Endpoint{}
|
||||
endpoint := &portainer.Endpoint{
|
||||
URL: "unix://",
|
||||
}
|
||||
|
||||
return stack, endpoint
|
||||
}
|
||||
@@ -42,14 +44,17 @@ func Test_UpAndDown(t *testing.T) {
|
||||
|
||||
stack, endpoint := setup(t)
|
||||
|
||||
w := NewComposeWrapper("", "", nil)
|
||||
w, err := NewComposeStackManager("", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed creating manager: %s", err)
|
||||
}
|
||||
|
||||
err := w.Up(stack, endpoint)
|
||||
err = w.Up(stack, endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("Error calling docker-compose up: %s", err)
|
||||
}
|
||||
|
||||
if containerExists(composedContainerName) == false {
|
||||
if !containerExists(composedContainerName) {
|
||||
t.Fatal("container should exist")
|
||||
}
|
||||
|
||||
@@ -63,13 +68,13 @@ func Test_UpAndDown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func containerExists(contaierName string) bool {
|
||||
cmd := exec.Command(osProgram("docker"), "ps", "-a", "-f", fmt.Sprintf("name=%s", contaierName))
|
||||
func containerExists(containerName string) bool {
|
||||
cmd := exec.Command("docker", "ps", "-a", "-f", fmt.Sprintf("name=%s", containerName))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to list containers: %s", err)
|
||||
}
|
||||
|
||||
return strings.Contains(string(out), contaierName)
|
||||
return strings.Contains(string(out), containerName)
|
||||
}
|
||||
112
api/exec/compose_stack_test.go
Normal file
112
api/exec/compose_stack_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_stackFilePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected string
|
||||
}{
|
||||
// {
|
||||
// name: "should return empty result if stack is missing",
|
||||
// stack: nil,
|
||||
// expected: "",
|
||||
// },
|
||||
// {
|
||||
// name: "should return empty result if stack don't have entrypoint",
|
||||
// stack: &portainer.Stack{},
|
||||
// expected: "",
|
||||
// },
|
||||
{
|
||||
name: "should allow file name and dir",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: "dir",
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: path.Join("dir", "file"),
|
||||
},
|
||||
{
|
||||
name: "should allow file name only",
|
||||
stack: &portainer.Stack{
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: "file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := stackFilePath(tt.stack)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createEnvFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected string
|
||||
expectedFile bool
|
||||
}{
|
||||
// {
|
||||
// name: "should not add env file option if stack is missing",
|
||||
// stack: nil,
|
||||
// expected: "",
|
||||
// },
|
||||
{
|
||||
name: "should not add env file option if stack doesn't have env variables",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack's env variables are empty",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{},
|
||||
},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "should add env file option if stack has env variables",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{
|
||||
{Name: "var1", Value: "value1"},
|
||||
{Name: "var2", Value: "value2"},
|
||||
},
|
||||
},
|
||||
expected: "var1=value1\nvar2=value2\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, _ := createEnvFile(tt.stack)
|
||||
|
||||
if tt.expected != "" {
|
||||
assert.Equal(t, path.Join(tt.stack.ProjectPath, "stack.env"), result)
|
||||
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
assert.Equal(t, tt.expected, string(content))
|
||||
} else {
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
)
|
||||
|
||||
// ComposeWrapper is a wrapper for docker-compose binary
|
||||
type ComposeWrapper struct {
|
||||
binaryPath string
|
||||
dataPath string
|
||||
proxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewComposeWrapper returns a docker-compose wrapper if corresponding binary present, otherwise nil
|
||||
func NewComposeWrapper(binaryPath, dataPath string, proxyManager *proxy.Manager) *ComposeWrapper {
|
||||
if !IsBinaryPresent(programPath(binaryPath, "docker-compose")) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ComposeWrapper{
|
||||
binaryPath: binaryPath,
|
||||
dataPath: dataPath,
|
||||
proxyManager: proxyManager,
|
||||
}
|
||||
}
|
||||
|
||||
// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax
|
||||
func (w *ComposeWrapper) ComposeSyntaxMaxVersion() string {
|
||||
return portainer.ComposeSyntaxMaxVersion
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced
|
||||
func (w *ComposeWrapper) NormalizeStackName(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command
|
||||
func (w *ComposeWrapper) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
_, err := w.command([]string{"up", "-d"}, stack, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command
|
||||
func (w *ComposeWrapper) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
_, err := w.command([]string{"down", "--remove-orphans"}, stack, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *ComposeWrapper) command(command []string, stack *portainer.Stack, endpoint *portainer.Endpoint) ([]byte, error) {
|
||||
if endpoint == nil {
|
||||
return nil, errors.New("cannot call a compose command on an empty endpoint")
|
||||
}
|
||||
|
||||
program := programPath(w.binaryPath, "docker-compose")
|
||||
|
||||
options := setComposeFile(stack)
|
||||
|
||||
options = addProjectNameOption(options, stack)
|
||||
options, err := addEnvFileOption(options, stack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !(endpoint.URL == "" || strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://")) {
|
||||
|
||||
proxy, err := w.proxyManager.CreateComposeProxyServer(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer proxy.Close()
|
||||
|
||||
options = append(options, "-H", fmt.Sprintf("http://127.0.0.1:%d", proxy.Port))
|
||||
}
|
||||
|
||||
args := append(options, command...)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(program, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("DOCKER_CONFIG=%s", w.dataPath))
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return out, errors.New(stderr.String())
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func setComposeFile(stack *portainer.Stack) []string {
|
||||
options := make([]string, 0)
|
||||
|
||||
if stack == nil || stack.EntryPoint == "" {
|
||||
return options
|
||||
}
|
||||
|
||||
composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
|
||||
options = append(options, "-f", composeFilePath)
|
||||
return options
|
||||
}
|
||||
|
||||
func addProjectNameOption(options []string, stack *portainer.Stack) []string {
|
||||
if stack == nil || stack.Name == "" {
|
||||
return options
|
||||
}
|
||||
|
||||
options = append(options, "-p", stack.Name)
|
||||
return options
|
||||
}
|
||||
|
||||
func addEnvFileOption(options []string, stack *portainer.Stack) ([]string, error) {
|
||||
if stack == nil || stack.Env == nil || len(stack.Env) == 0 {
|
||||
return options, nil
|
||||
}
|
||||
|
||||
envFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
|
||||
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
options = append(options, "--env-file", envFilePath)
|
||||
return options, nil
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_setComposeFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "should return empty result if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should return empty result if stack don't have entrypoint",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should allow file name and dir",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: "dir",
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: []string{"-f", path.Join("dir", "file")},
|
||||
},
|
||||
{
|
||||
name: "should allow file name only",
|
||||
stack: &portainer.Stack{
|
||||
EntryPoint: "file",
|
||||
},
|
||||
expected: []string{"-f", "file"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := setComposeFile(tt.stack)
|
||||
assert.ElementsMatch(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_addProjectNameOption(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "should not add project option if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add project option if stack doesn't have name",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should add project name option if stack has a name",
|
||||
stack: &portainer.Stack{
|
||||
Name: "project-name",
|
||||
},
|
||||
expected: []string{"-p", "project-name"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := []string{"-a", "b"}
|
||||
result := addProjectNameOption(options, tt.stack)
|
||||
assert.ElementsMatch(t, append(options, tt.expected...), result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_addEnvFileOption(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stack *portainer.Stack
|
||||
expected []string
|
||||
expectedContent string
|
||||
}{
|
||||
{
|
||||
name: "should not add env file option if stack is missing",
|
||||
stack: nil,
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack doesn't have env variables",
|
||||
stack: &portainer.Stack{},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should not add env file option if stack's env variables are empty",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{},
|
||||
},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "should add env file option if stack has env variables",
|
||||
stack: &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
Env: []portainer.Pair{
|
||||
{Name: "var1", Value: "value1"},
|
||||
{Name: "var2", Value: "value2"},
|
||||
},
|
||||
},
|
||||
expected: []string{"--env-file", path.Join(dir, "stack.env")},
|
||||
expectedContent: "var1=value1\nvar2=value2\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
options := []string{"-a", "b"}
|
||||
result, _ := addEnvFileOption(options, tt.stack)
|
||||
assert.ElementsMatch(t, append(options, tt.expected...), result)
|
||||
|
||||
if tt.expectedContent != "" {
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
assert.Equal(t, tt.expectedContent, string(content))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -20,27 +23,64 @@ import (
|
||||
|
||||
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment.
|
||||
type KubernetesDeployer struct {
|
||||
binaryPath string
|
||||
dataStore portainer.DataStore
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
signatureService portainer.DigitalSignatureService
|
||||
binaryPath string
|
||||
dataStore portainer.DataStore
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
signatureService portainer.DigitalSignatureService
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
}
|
||||
|
||||
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
|
||||
func NewKubernetesDeployer(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
|
||||
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, binaryPath string) *KubernetesDeployer {
|
||||
return &KubernetesDeployer{
|
||||
binaryPath: binaryPath,
|
||||
dataStore: datastore,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
signatureService: signatureService,
|
||||
binaryPath: binaryPath,
|
||||
dataStore: datastore,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
signatureService: signatureService,
|
||||
kubernetesClientFactory: kubernetesClientFactory,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
kubecli, err := deployer.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tokenCache := deployer.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID))
|
||||
|
||||
tokenManager, err := kubernetes.NewTokenManager(kubecli, deployer.dataStore, tokenCache, setLocalAdminToken)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return tokenManager.GetAdminServiceAccountToken(), nil
|
||||
}
|
||||
|
||||
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("can not get a valid user service account token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint.
|
||||
// Otherwise it will use kubectl to deploy the manifest.
|
||||
func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
|
||||
func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment {
|
||||
token, err := ioutil.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
||||
token, err := deployer.getToken(request, endpoint, true);
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -53,7 +93,7 @@ func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackCo
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--server", endpoint.URL)
|
||||
args = append(args, "--insecure-skip-tls-verify")
|
||||
args = append(args, "--token", string(token))
|
||||
args = append(args, "--token", token)
|
||||
args = append(args, "--namespace", namespace)
|
||||
args = append(args, "apply", "-f", "-")
|
||||
|
||||
@@ -139,8 +179,14 @@ func (deployer *KubernetesDeployer) Deploy(endpoint *portainer.Endpoint, stackCo
|
||||
return "", err
|
||||
}
|
||||
|
||||
token, err := deployer.getToken(request, endpoint, false);
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set(portainer.PortainerAgentPublicKeyHeader, deployer.signatureService.EncodedPublicKey())
|
||||
req.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
req.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
resp, err := httpCli.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// SwarmStackManager represents a service for managing stacks.
|
||||
@@ -42,7 +44,7 @@ func NewSwarmStackManager(binaryPath, dataPath string, signatureService portaine
|
||||
}
|
||||
|
||||
// Login executes the docker login command against a list of registries (including DockerHub).
|
||||
func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) {
|
||||
func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) {
|
||||
command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
|
||||
for _, registry := range registries {
|
||||
if registry.Authentication {
|
||||
@@ -50,11 +52,6 @@ func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registri
|
||||
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
if dockerhub.Authentication {
|
||||
dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password)
|
||||
runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Logout executes the docker logout command.
|
||||
@@ -189,3 +186,8 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (manager *SwarmStackManager) NormalizeStackName(name string) string {
|
||||
r := regexp.MustCompile("[^a-z0-9]+")
|
||||
return r.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func osProgram(program string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
program += ".exe"
|
||||
}
|
||||
return program
|
||||
}
|
||||
|
||||
func programPath(rootPath, program string) string {
|
||||
return filepath.Join(rootPath, osProgram(program))
|
||||
}
|
||||
|
||||
// IsBinaryPresent returns true if corresponding program exists on PATH
|
||||
func IsBinaryPresent(program string) bool {
|
||||
_, err := exec.LookPath(program)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_isBinaryPresent(t *testing.T) {
|
||||
|
||||
if !IsBinaryPresent("docker") {
|
||||
t.Error("expect docker binary to exist on the path")
|
||||
}
|
||||
|
||||
if IsBinaryPresent("executable-with-this-name-should-not-exist") {
|
||||
t.Error("expect binary with a random name to be missing on the path")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package gittypes
|
||||
|
||||
type RepoConfig struct {
|
||||
URL string
|
||||
ReferenceName string
|
||||
ConfigFilePath string
|
||||
// The repo url
|
||||
URL string `example:"https://github.com/portainer/portainer-ee.git"`
|
||||
// The reference name
|
||||
ReferenceName string `example:"refs/heads/branch_name"`
|
||||
// Path to where the config file is in this url/refName
|
||||
ConfigFilePath string `example:"docker-compose.yml"`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/portainer/portainer/api
|
||||
|
||||
go 1.13
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.4.16
|
||||
@@ -27,13 +27,16 @@ require (
|
||||
github.com/mitchellh/mapstructure v1.1.2 // indirect
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92
|
||||
github.com/portainer/libcompose v0.5.3
|
||||
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2
|
||||
github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33
|
||||
github.com/stretchr/testify v1.6.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
|
||||
k8s.io/api v0.17.2
|
||||
k8s.io/apimachinery v0.17.2
|
||||
k8s.io/client-go v0.17.2
|
||||
|
||||
17
api/go.sum
17
api/go.sum
@@ -80,6 +80,7 @@ github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e/go.mod h1:/Zj4wYkg
|
||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
|
||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible h1:fUDGZCv/7iAN7u0puUVhvKCcsR6vRfwrJatElLBEf0I=
|
||||
github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
@@ -153,6 +154,7 @@ github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||
github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
|
||||
@@ -184,7 +186,6 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c h1:N7A4JCA2G+j5fuFxCsJqjFU/sZe0mj8H0sSoSwbaikw=
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c/go.mod h1:Nn5wlyECw3iJrzi0AhIWg+AJUb4PlRQVW4/3XHH1LZA=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -219,8 +220,10 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.1 h1:q/mM8GF/n0shIN8SaAZ0V+jnLPzen6WIVZdiwrRlMlo=
|
||||
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
|
||||
github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420 h1:Yu3681ykYHDfLoI6XVjL4JWmkE+3TX9yfIWwRCh1kFM=
|
||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
|
||||
@@ -238,6 +241,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92 h1:Hh7SHCf3SJblVywU0TTn5lpTKsH5W23LAKH5sqWggig=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20210527221011-0a1418224b92/go.mod h1:PF2O2O4UNYWdtPcp6n/mIKpKk+f1jhFTezS8txbf+XM=
|
||||
github.com/portainer/libcompose v0.5.3 h1:tE4WcPuGvo+NKeDkDWpwNavNLZ5GHIJ4RvuZXsI9uI8=
|
||||
github.com/portainer/libcompose v0.5.3/go.mod h1:7SKd/ho69rRKHDFSDUwkbMcol2TMKU5OslDsajr8Ro8=
|
||||
github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 h1:0PfgGLys9yHr4rtnirg0W0Cjvv6/DzxBIZk5sV59208=
|
||||
@@ -261,8 +266,9 @@ github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDa
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.1 h1:GL2rEmy6nsikmW0r8opw9JIRScdMF5hA8cOYLH7In1k=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
|
||||
github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
@@ -273,8 +279,8 @@ github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRci
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc=
|
||||
github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4=
|
||||
github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ=
|
||||
@@ -366,9 +372,11 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
@@ -395,6 +403,7 @@ k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUc
|
||||
k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
|
||||
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=
|
||||
k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a h1:UcxjrRMyNx/i/y8G7kPvLyy7rfbeuf1PYyBf973pgyU=
|
||||
k8s.io/kube-openapi v0.0.0-20191107075043-30be4d16710a/go.mod h1:1TqjTSzOxsLGIKfj0lK8EeCP7K1iUG65v09OM0/WG5E=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f h1:GiPwtSzdP43eI1hpPCbROQCCIgCuiMMNF8YUVLF3vJo=
|
||||
k8s.io/utils v0.0.0-20191114184206-e782cd3c129f/go.mod h1:sZAwmy6armz5eXlNoLmJcl4F1QuKu7sr+mFQ0byX7Ew=
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package dockerhub
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
)
|
||||
|
||||
// @id DockerHubInspect
|
||||
// @summary Retrieve DockerHub information
|
||||
// @description Use this endpoint to retrieve the information used to connect to the DockerHub
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags dockerhub
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @success 200 {object} portainer.DockerHub
|
||||
// @failure 500 "Server error"
|
||||
// @router /dockerhub [get]
|
||||
func (handler *Handler) dockerhubInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
|
||||
}
|
||||
|
||||
hideFields(dockerhub)
|
||||
return response.JSON(w, dockerhub)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package dockerhub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type dockerhubUpdatePayload struct {
|
||||
// Enable authentication against DockerHub
|
||||
Authentication bool `validate:"required" example:"false"`
|
||||
// Username used to authenticate against the DockerHub
|
||||
Username string `validate:"required" example:"hub_user"`
|
||||
// Password used to authenticate against the DockerHub
|
||||
Password string `validate:"required" example:"hub_password"`
|
||||
}
|
||||
|
||||
func (payload *dockerhubUpdatePayload) Validate(r *http.Request) error {
|
||||
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
|
||||
return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id DockerHubUpdate
|
||||
// @summary Update DockerHub information
|
||||
// @description Use this endpoint to update the information used to connect to the DockerHub
|
||||
// @description **Access policy**: administrator
|
||||
// @tags dockerhub
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param body body dockerhubUpdatePayload true "DockerHub information"
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /dockerhub [put]
|
||||
func (handler *Handler) dockerhubUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload dockerhubUpdatePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
dockerhub := &portainer.DockerHub{
|
||||
Authentication: false,
|
||||
Username: "",
|
||||
Password: "",
|
||||
}
|
||||
|
||||
if payload.Authentication {
|
||||
dockerhub.Authentication = true
|
||||
dockerhub.Username = payload.Username
|
||||
dockerhub.Password = payload.Password
|
||||
}
|
||||
|
||||
err = handler.DataStore.DockerHub().UpdateDockerHub(dockerhub)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the Dockerhub changes inside the database", err}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package dockerhub
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
func hideFields(dockerHub *portainer.DockerHub) {
|
||||
dockerHub.Password = ""
|
||||
}
|
||||
|
||||
// Handler is the HTTP handler used to handle DockerHub operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore portainer.DataStore
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage Dockerhub operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
}
|
||||
h.Handle("/dockerhub",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.dockerhubInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/dockerhub",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.dockerhubUpdate))).Methods(http.MethodPut)
|
||||
|
||||
return h
|
||||
}
|
||||
56
api/http/handler/endpoints/endpoint_association_delete.go
Normal file
56
api/http/handler/endpoints/endpoint_association_delete.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
)
|
||||
|
||||
// @id EndpointAssociationDelete
|
||||
// @summary De-association an edge endpoint
|
||||
// @description De-association an edge endpoint.
|
||||
// @description **Access policy**: administrator
|
||||
// @security jwt
|
||||
// @tags endpoints
|
||||
// @produce json
|
||||
// @param id path int true "Endpoint identifier"
|
||||
// @success 200 {object} portainer.Endpoint "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 404 "Endpoint not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /api/endpoints/:id/association [put]
|
||||
func (handler *Handler) endpointAssociationDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint type", errors.New("Invalid endpoint type")}
|
||||
}
|
||||
|
||||
endpoint.EdgeID = ""
|
||||
endpoint.Snapshots = []portainer.DockerSnapshot{}
|
||||
endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{}
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting endpoint in database", err}
|
||||
}
|
||||
|
||||
handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID)
|
||||
|
||||
return response.JSON(w, endpoint)
|
||||
}
|
||||
@@ -322,8 +322,8 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
},
|
||||
AuthorizedUsers: []portainer.UserID{},
|
||||
AuthorizedTeams: []portainer.TeamID{},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: payload.TagIDs,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
|
||||
@@ -103,6 +103,22 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
|
||||
}
|
||||
|
||||
for idx := range registries {
|
||||
registry := ®istries[idx]
|
||||
if _, ok := registry.RegistryAccesses[endpoint.ID]; ok {
|
||||
delete(registry.RegistryAccesses, endpoint.ID)
|
||||
err = handler.DataStore.Registry().UpdateRegistry(registry.ID, registry)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update registry accesses", Err: err}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ type dockerhubStatusResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// GET request on /api/endpoints/{id}/dockerhub/status
|
||||
// GET request on /api/endpoints/{id}/dockerhub/{registryId}
|
||||
func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
@@ -40,13 +40,30 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment type", errors.New("Invalid environment type")}
|
||||
}
|
||||
|
||||
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
|
||||
}
|
||||
|
||||
var registry *portainer.Registry
|
||||
|
||||
if registryID == 0 {
|
||||
registry = &portainer.Registry{}
|
||||
} else {
|
||||
registry, err = handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
if registry.Type != portainer.DockerHubRegistry {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry type", errors.New("Invalid registry type")}
|
||||
}
|
||||
}
|
||||
|
||||
httpClient := client.NewHTTPClient()
|
||||
token, err := getDockerHubToken(httpClient, dockerhub)
|
||||
token, err := getDockerHubToken(httpClient, registry)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub token from DockerHub", err}
|
||||
}
|
||||
@@ -59,7 +76,7 @@ func (handler *Handler) endpointDockerhubStatus(w http.ResponseWriter, r *http.R
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.DockerHub) (string, error) {
|
||||
func getDockerHubToken(httpClient *client.HTTPClient, registry *portainer.Registry) (string, error) {
|
||||
type dockerhubTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@@ -71,8 +88,8 @@ func getDockerHubToken(httpClient *client.HTTPClient, dockerhub *portainer.Docke
|
||||
return "", err
|
||||
}
|
||||
|
||||
if dockerhub.Authentication {
|
||||
req.SetBasicAuth(dockerhub.Username, dockerhub.Password)
|
||||
if registry.Authentication {
|
||||
req.SetBasicAuth(registry.Username, registry.Password)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
@@ -115,8 +132,18 @@ func getDockerHubLimits(httpClient *client.HTTPClient, token string) (*dockerhub
|
||||
return nil, errors.New("failed fetching dockerhub limits")
|
||||
}
|
||||
|
||||
// An error with rateLimit-Limit or RateLimit-Remaining is likely for dockerhub pro accounts where there is no rate limit.
|
||||
// In that specific case the headers will not be present. Don't bubble up the error as its normal
|
||||
// See: https://docs.docker.com/docker-hub/download-rate-limit/
|
||||
rateLimit, err := parseRateLimitHeader(resp.Header, "RateLimit-Limit")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rateLimitRemaining, err := parseRateLimitHeader(resp.Header, "RateLimit-Remaining")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &dockerhubStatusResponse{
|
||||
Limit: rateLimit,
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// @param search query string false "Search query"
|
||||
// @param groupId query int false "List endpoints of this group"
|
||||
// @param limit query int false "Limit results to this value"
|
||||
// @param type query int false "List endpoints of this type"
|
||||
// @param types query []int false "List endpoints of this type"
|
||||
// @param tagIds query []int false "search endpoints with these tags (depends on tagsPartialMatch)"
|
||||
// @param tagsPartialMatch query bool false "If true, will return endpoint which has one of tagIds, if false (or missing) will return only endpoints that has all the tags"
|
||||
// @param endpointIds query []int false "will return only these endpoints"
|
||||
@@ -46,7 +46,9 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
|
||||
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
|
||||
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
|
||||
endpointType, _ := request.RetrieveNumericQueryParameter(r, "type", true)
|
||||
|
||||
var endpointTypes []int
|
||||
request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true)
|
||||
|
||||
var tagIDs []portainer.TagID
|
||||
request.RetrieveJSONQueryParameter(r, "tagIds", &tagIDs, true)
|
||||
@@ -98,8 +100,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
|
||||
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, endpointGroups, tagsMap, search)
|
||||
}
|
||||
|
||||
if endpointType != 0 {
|
||||
filteredEndpoints = filterEndpointsByType(filteredEndpoints, portainer.EndpointType(endpointType))
|
||||
if endpointTypes != nil {
|
||||
filteredEndpoints = filterEndpointsByTypes(filteredEndpoints, endpointTypes)
|
||||
}
|
||||
|
||||
if tagIDs != nil {
|
||||
@@ -212,11 +214,16 @@ func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGrou
|
||||
return false
|
||||
}
|
||||
|
||||
func filterEndpointsByType(endpoints []portainer.Endpoint, endpointType portainer.EndpointType) []portainer.Endpoint {
|
||||
func filterEndpointsByTypes(endpoints []portainer.Endpoint, endpointTypes []int) []portainer.Endpoint {
|
||||
filteredEndpoints := make([]portainer.Endpoint, 0)
|
||||
|
||||
typeSet := map[portainer.EndpointType]bool{}
|
||||
for _, endpointType := range endpointTypes {
|
||||
typeSet[portainer.EndpointType(endpointType)] = true
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Type == endpointType {
|
||||
if typeSet[endpoint.Type] {
|
||||
filteredEndpoints = append(filteredEndpoints, endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
50
api/http/handler/endpoints/endpoint_registries_inspect.go
Normal file
50
api/http/handler/endpoints/endpoint_registries_inspect.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
// GET request on /endpoints/{id}/registries/{registryId}
|
||||
func (handler *Handler) endpointRegistryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a registry with the specified identifier inside the database", Err: err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a registry with the specified identifier inside the database", Err: err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user from the database", Err: err}
|
||||
}
|
||||
|
||||
if !security.AuthorizedRegistryAccess(registry, user, securityContext.UserMemberships, portainer.EndpointID(endpointID)) {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
hideRegistryFields(registry, !securityContext.IsAdmin)
|
||||
return response.JSON(w, registry)
|
||||
}
|
||||
130
api/http/handler/endpoints/endpoint_registries_list.go
Normal file
130
api/http/handler/endpoints/endpoint_registries_list.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
endpointutils "github.com/portainer/portainer/api/internal/endpoint"
|
||||
)
|
||||
|
||||
// GET request on /endpoints/{id}/registries?namespace
|
||||
func (handler *Handler) endpointRegistriesList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user from the database", err}
|
||||
}
|
||||
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
isAdmin := securityContext.IsAdmin
|
||||
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
|
||||
}
|
||||
|
||||
if endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
|
||||
if namespace == "" && !isAdmin {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Missing namespace query parameter", Err: errors.New("missing namespace query parameter")}
|
||||
}
|
||||
|
||||
if namespace != "" {
|
||||
|
||||
authorized, err := handler.isNamespaceAuthorized(endpoint, namespace, user.ID, securityContext.UserMemberships, isAdmin)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to check for namespace authorization", err}
|
||||
}
|
||||
|
||||
if !authorized {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized to use namespace", Err: errors.New("user is not authorized to use namespace")}
|
||||
}
|
||||
|
||||
registries = filterRegistriesByNamespace(registries, endpoint.ID, namespace)
|
||||
}
|
||||
|
||||
} else if !isAdmin {
|
||||
registries = security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||
}
|
||||
|
||||
for idx := range registries {
|
||||
hideRegistryFields(®istries[idx], !isAdmin)
|
||||
}
|
||||
|
||||
return response.JSON(w, registries)
|
||||
}
|
||||
|
||||
func (handler *Handler) isNamespaceAuthorized(endpoint *portainer.Endpoint, namespace string, userId portainer.UserID, memberships []portainer.TeamMembership, isAdmin bool) (bool, error) {
|
||||
if isAdmin || namespace == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if namespace == "default" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
kcl, err := handler.K8sClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "unable to retrieve kubernetes client")
|
||||
}
|
||||
|
||||
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "unable to retrieve endpoint's namespaces policies")
|
||||
}
|
||||
|
||||
namespacePolicy, ok := accessPolicies[namespace]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return security.AuthorizedAccess(userId, memberships, namespacePolicy.UserAccessPolicies, namespacePolicy.TeamAccessPolicies), nil
|
||||
}
|
||||
|
||||
func filterRegistriesByNamespace(registries []portainer.Registry, endpointId portainer.EndpointID, namespace string) []portainer.Registry {
|
||||
if namespace == "" {
|
||||
return registries
|
||||
}
|
||||
|
||||
filteredRegistries := []portainer.Registry{}
|
||||
|
||||
for _, registry := range registries {
|
||||
for _, authorizedNamespace := range registry.RegistryAccesses[endpointId].Namespaces {
|
||||
if authorizedNamespace == namespace {
|
||||
filteredRegistries = append(filteredRegistries, registry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filteredRegistries
|
||||
}
|
||||
|
||||
func hideRegistryFields(registry *portainer.Registry, hideAccesses bool) {
|
||||
registry.Password = ""
|
||||
registry.ManagementConfiguration = nil
|
||||
if hideAccesses {
|
||||
registry.RegistryAccesses = nil
|
||||
}
|
||||
}
|
||||
149
api/http/handler/endpoints/endpoint_registry_access.go
Normal file
149
api/http/handler/endpoints/endpoint_registry_access.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package endpoints
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type registryAccessPayload struct {
|
||||
UserAccessPolicies portainer.UserAccessPolicies
|
||||
TeamAccessPolicies portainer.TeamAccessPolicies
|
||||
Namespaces []string
|
||||
}
|
||||
|
||||
func (payload *registryAccessPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PUT request on /endpoints/{id}/registries/{registryId}
|
||||
func (handler *Handler) endpointRegistryAccess(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid endpoint identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "registryId")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid registry identifier route variable", Err: err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "User is not authorized", Err: err}
|
||||
}
|
||||
|
||||
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an endpoint with the specified identifier inside the database", Err: err}
|
||||
}
|
||||
|
||||
var payload registryAccessPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||
}
|
||||
|
||||
if registry.RegistryAccesses == nil {
|
||||
registry.RegistryAccesses = portainer.RegistryAccesses{}
|
||||
}
|
||||
|
||||
if _, ok := registry.RegistryAccesses[endpoint.ID]; !ok {
|
||||
registry.RegistryAccesses[endpoint.ID] = portainer.RegistryAccessPolicies{}
|
||||
}
|
||||
|
||||
registryAccess := registry.RegistryAccesses[endpoint.ID]
|
||||
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
err := handler.updateKubeAccess(endpoint, registry, registryAccess.Namespaces, payload.Namespaces)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to update kube access policies", Err: err}
|
||||
}
|
||||
|
||||
registryAccess.Namespaces = payload.Namespaces
|
||||
} else {
|
||||
registryAccess.UserAccessPolicies = payload.UserAccessPolicies
|
||||
registryAccess.TeamAccessPolicies = payload.TeamAccessPolicies
|
||||
}
|
||||
|
||||
registry.RegistryAccesses[portainer.EndpointID(endpointID)] = registryAccess
|
||||
|
||||
handler.DataStore.Registry().UpdateRegistry(registry.ID, registry)
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateKubeAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, oldNamespaces, newNamespaces []string) error {
|
||||
oldNamespacesSet := toSet(oldNamespaces)
|
||||
newNamespacesSet := toSet(newNamespaces)
|
||||
|
||||
namespacesToRemove := setDifference(oldNamespacesSet, newNamespacesSet)
|
||||
namespacesToAdd := setDifference(newNamespacesSet, oldNamespacesSet)
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for namespace := range namespacesToRemove {
|
||||
err := cli.DeleteRegistrySecret(registry, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for namespace := range namespacesToAdd {
|
||||
err := cli.CreateRegistrySecret(registry, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type stringSet map[string]bool
|
||||
|
||||
func toSet(list []string) stringSet {
|
||||
set := stringSet{}
|
||||
for _, el := range list {
|
||||
set[el] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// setDifference returns the set difference tagsA - tagsB
|
||||
func setDifference(setA stringSet, setB stringSet) stringSet {
|
||||
set := stringSet{}
|
||||
|
||||
for el := range setA {
|
||||
if !setB[el] {
|
||||
set[el] = true
|
||||
}
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
@@ -151,11 +151,17 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
updateAuthorizations := false
|
||||
|
||||
if payload.Kubernetes != nil {
|
||||
if payload.Kubernetes.Configuration.RestrictDefaultNamespace !=
|
||||
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace {
|
||||
updateAuthorizations = true
|
||||
}
|
||||
|
||||
endpoint.Kubernetes = *payload.Kubernetes
|
||||
}
|
||||
|
||||
updateAuthorizations := false
|
||||
if payload.UserAccessPolicies != nil && !reflect.DeepEqual(payload.UserAccessPolicies, endpoint.UserAccessPolicies) {
|
||||
updateAuthorizations = true
|
||||
endpoint.UserAccessPolicies = payload.UserAccessPolicies
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
"net/http"
|
||||
|
||||
@@ -28,6 +29,7 @@ type Handler struct {
|
||||
ProxyManager *proxy.Manager
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
SnapshotService portainer.SnapshotService
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
AuthorizationService *authorization.Service
|
||||
}
|
||||
@@ -43,6 +45,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints/{id}/settings",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSettingsUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/endpoints/{id}/association",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointAssociationDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/endpoints/snapshot",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints",
|
||||
@@ -53,7 +57,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/endpoints/{id}",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/endpoints/{id}/dockerhub",
|
||||
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}/extensions",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost)
|
||||
@@ -63,5 +67,11 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints/{id}/status",
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}/registries",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistriesList))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}/registries/{registryId}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}/registries/{registryId}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointRegistryAccess))).Methods(http.MethodPut)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/auth"
|
||||
"github.com/portainer/portainer/api/http/handler/backup"
|
||||
"github.com/portainer/portainer/api/http/handler/customtemplates"
|
||||
"github.com/portainer/portainer/api/http/handler/dockerhub"
|
||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
@@ -39,7 +38,6 @@ type Handler struct {
|
||||
AuthHandler *auth.Handler
|
||||
BackupHandler *backup.Handler
|
||||
CustomTemplatesHandler *customtemplates.Handler
|
||||
DockerHubHandler *dockerhub.Handler
|
||||
EdgeGroupsHandler *edgegroups.Handler
|
||||
EdgeJobsHandler *edgejobs.Handler
|
||||
EdgeStacksHandler *edgestacks.Handler
|
||||
@@ -88,8 +86,6 @@ type Handler struct {
|
||||
// @tag.description Authenticate against Portainer HTTP API
|
||||
// @tag.name custom_templates
|
||||
// @tag.description Manage Custom Templates
|
||||
// @tag.name dockerhub
|
||||
// @tag.description Manage how Portainer connects to the DockerHub
|
||||
// @tag.name edge_groups
|
||||
// @tag.description Manage Edge Groups
|
||||
// @tag.name edge_jobs
|
||||
@@ -146,8 +142,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/restore"):
|
||||
http.StripPrefix("/api", h.BackupHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
|
||||
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/custom_templates"):
|
||||
http.StripPrefix("/api", h.CustomTemplatesHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/edge_stacks"):
|
||||
|
||||
@@ -5,32 +5,46 @@ import (
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
func hideFields(registry *portainer.Registry) {
|
||||
func hideFields(registry *portainer.Registry, hideAccesses bool) {
|
||||
registry.Password = ""
|
||||
registry.ManagementConfiguration = nil
|
||||
if hideAccesses {
|
||||
registry.RegistryAccesses = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Handler is the HTTP handler used to handle registry operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer *security.RequestBouncer
|
||||
DataStore portainer.DataStore
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
requestBouncer *security.RequestBouncer
|
||||
DataStore portainer.DataStore
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage registry operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
h := newHandler(bouncer)
|
||||
h.initRouter(bouncer)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func newHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
return &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) initRouter(bouncer accessGuard) {
|
||||
h.Handle("/registries",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.registryCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/registries",
|
||||
@@ -45,5 +59,21 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete)
|
||||
h.PathPrefix("/registries/proxies/gitlab").Handler(
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry)))
|
||||
return h
|
||||
}
|
||||
|
||||
type accessGuard interface {
|
||||
AdminAccess(h http.Handler) http.Handler
|
||||
RestrictedAccess(h http.Handler) http.Handler
|
||||
AuthenticatedAccess(h http.Handler) http.Handler
|
||||
}
|
||||
|
||||
func (handler *Handler) registriesHaveSameURLAndCredentials(r1, r2 *portainer.Registry) bool {
|
||||
hasSameUrl := r1.URL == r2.URL
|
||||
hasSameCredentials := r1.Authentication == r2.Authentication && (!r1.Authentication || (r1.Authentication && r1.Username == r2.Username))
|
||||
|
||||
if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry {
|
||||
return hasSameUrl && hasSameCredentials
|
||||
}
|
||||
|
||||
return hasSameUrl && hasSameCredentials && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type registryConfigurePayload struct {
|
||||
@@ -93,9 +95,12 @@ func (payload *registryConfigurePayload) Validate(r *http.Request) error {
|
||||
// @failure 500 "Server error"
|
||||
// @router /registries/{id}/configure [post]
|
||||
func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to configure registry", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
payload := ®istryConfigurePayload{}
|
||||
@@ -104,6 +109,11 @@ func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
|
||||
}
|
||||
|
||||
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
|
||||
@@ -2,6 +2,7 @@ package registries
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
@@ -9,15 +10,19 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type registryCreatePayload struct {
|
||||
// Name that will be used to identify this registry
|
||||
Name string `example:"my-registry" validate:"required"`
|
||||
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)
|
||||
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4"`
|
||||
// Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)
|
||||
Type portainer.RegistryType `example:"1" validate:"required" enums:"1,2,3,4,5,6"`
|
||||
// URL or IP address of the Docker registry
|
||||
URL string `example:"registry.mydomain.tld:2375" validate:"required"`
|
||||
URL string `example:"registry.mydomain.tld:2375/feed" validate:"required"`
|
||||
// BaseURL required for ProGet registry
|
||||
BaseURL string `example:"registry.mydomain.tld:2375"`
|
||||
// Is authentication against this registry enabled
|
||||
Authentication bool `example:"false" validate:"required"`
|
||||
// Username used to authenticate against this registry. Required when Authentication is true
|
||||
@@ -30,7 +35,7 @@ type registryCreatePayload struct {
|
||||
Quay portainer.QuayRegistryData
|
||||
}
|
||||
|
||||
func (payload *registryCreatePayload) Validate(r *http.Request) error {
|
||||
func (payload *registryCreatePayload) Validate(_ *http.Request) error {
|
||||
if govalidator.IsNull(payload.Name) {
|
||||
return errors.New("Invalid registry name")
|
||||
}
|
||||
@@ -40,9 +45,17 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
|
||||
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
|
||||
return errors.New("Invalid credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry {
|
||||
return errors.New("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)")
|
||||
|
||||
switch payload.Type {
|
||||
case portainer.QuayRegistry, portainer.AzureRegistry, portainer.CustomRegistry, portainer.GitlabRegistry, portainer.ProGetRegistry, portainer.DockerHubRegistry:
|
||||
default:
|
||||
return errors.New("invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry), 4 (Gitlab registry), 5 (ProGet registry) or 6 (DockerHub)")
|
||||
}
|
||||
|
||||
if payload.Type == portainer.ProGetRegistry && payload.BaseURL == "" {
|
||||
return fmt.Errorf("BaseURL is required for registry type %d (ProGet)", portainer.ProGetRegistry)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -60,23 +73,41 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
|
||||
// @failure 500 "Server error"
|
||||
// @router /registries [post]
|
||||
func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to create registry", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
var payload registryCreatePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
registry := &portainer.Registry{
|
||||
Type: portainer.RegistryType(payload.Type),
|
||||
Name: payload.Name,
|
||||
URL: payload.URL,
|
||||
Authentication: payload.Authentication,
|
||||
Username: payload.Username,
|
||||
Password: payload.Password,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Gitlab: payload.Gitlab,
|
||||
Quay: payload.Quay,
|
||||
Type: portainer.RegistryType(payload.Type),
|
||||
Name: payload.Name,
|
||||
URL: payload.URL,
|
||||
BaseURL: payload.BaseURL,
|
||||
Authentication: payload.Authentication,
|
||||
Username: payload.Username,
|
||||
Password: payload.Password,
|
||||
RegistryAccesses: portainer.RegistryAccesses{},
|
||||
Gitlab: payload.Gitlab,
|
||||
Quay: payload.Quay,
|
||||
}
|
||||
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
|
||||
}
|
||||
for _, r := range registries {
|
||||
if handler.registriesHaveSameURLAndCredentials(&r, registry) {
|
||||
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")}
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Registry().CreateRegistry(registry)
|
||||
@@ -84,6 +115,6 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the registry inside the database", err}
|
||||
}
|
||||
|
||||
hideFields(registry)
|
||||
hideFields(registry, true)
|
||||
return response.JSON(w, registry)
|
||||
}
|
||||
|
||||
113
api/http/handler/registries/registry_create_test.go
Normal file
113
api/http/handler/registries/registry_create_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_registryCreatePayload_Validate(t *testing.T) {
|
||||
basePayload := registryCreatePayload{Name: "Test registry", URL: "http://example.com"}
|
||||
t.Run("Can't create a ProGet registry if BaseURL is empty", func(t *testing.T) {
|
||||
payload := basePayload
|
||||
payload.Type = portainer.ProGetRegistry
|
||||
err := payload.Validate(nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
t.Run("Can create a GitLab registry if BaseURL is empty", func(t *testing.T) {
|
||||
payload := basePayload
|
||||
payload.Type = portainer.GitlabRegistry
|
||||
err := payload.Validate(nil)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
t.Run("Can create a ProGet registry if BaseURL is not empty", func(t *testing.T) {
|
||||
payload := basePayload
|
||||
payload.Type = portainer.ProGetRegistry
|
||||
payload.BaseURL = "http://example.com"
|
||||
err := payload.Validate(nil)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
type testRegistryService struct {
|
||||
portainer.RegistryService
|
||||
createRegistry func(r *portainer.Registry) error
|
||||
updateRegistry func(ID portainer.RegistryID, r *portainer.Registry) error
|
||||
getRegistry func(ID portainer.RegistryID) (*portainer.Registry, error)
|
||||
}
|
||||
|
||||
type testDataStore struct {
|
||||
portainer.DataStore
|
||||
registry *testRegistryService
|
||||
}
|
||||
|
||||
func (t testDataStore) Registry() portainer.RegistryService {
|
||||
return t.registry
|
||||
}
|
||||
|
||||
func (t testRegistryService) CreateRegistry(r *portainer.Registry) error {
|
||||
return t.createRegistry(r)
|
||||
}
|
||||
|
||||
func (t testRegistryService) UpdateRegistry(ID portainer.RegistryID, r *portainer.Registry) error {
|
||||
return t.updateRegistry(ID, r)
|
||||
}
|
||||
|
||||
func (t testRegistryService) Registry(ID portainer.RegistryID) (*portainer.Registry, error) {
|
||||
return t.getRegistry(ID)
|
||||
}
|
||||
|
||||
func (t testRegistryService) Registries() ([]portainer.Registry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestHandler_registryCreate(t *testing.T) {
|
||||
payload := registryCreatePayload{
|
||||
Name: "Test registry",
|
||||
Type: portainer.ProGetRegistry,
|
||||
URL: "http://example.com",
|
||||
BaseURL: "http://example.com",
|
||||
Authentication: false,
|
||||
Username: "username",
|
||||
Password: "password",
|
||||
Gitlab: portainer.GitlabRegistryData{},
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
assert.NoError(t, err)
|
||||
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(payloadBytes))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
restrictedContext := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: portainer.UserID(1),
|
||||
}
|
||||
|
||||
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
registry := portainer.Registry{}
|
||||
handler := Handler{}
|
||||
handler.DataStore = testDataStore{
|
||||
registry: &testRegistryService{
|
||||
createRegistry: func(r *portainer.Registry) error {
|
||||
registry = *r
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
handlerError := handler.registryCreate(w, r)
|
||||
assert.Nil(t, handlerError)
|
||||
assert.Equal(t, payload.Name, registry.Name)
|
||||
assert.Equal(t, payload.Type, registry.Type)
|
||||
assert.Equal(t, payload.URL, registry.URL)
|
||||
assert.Equal(t, payload.BaseURL, registry.BaseURL)
|
||||
assert.Equal(t, payload.Authentication, registry.Authentication)
|
||||
assert.Equal(t, payload.Username, registry.Username)
|
||||
assert.Equal(t, payload.Password, registry.Password)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
// @id RegistryDelete
|
||||
@@ -23,6 +25,14 @@ import (
|
||||
// @failure 500 "Server error"
|
||||
// @router /registries/{id} [delete]
|
||||
func (handler *Handler) registryDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to delete registry", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/errors"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
@@ -27,6 +26,7 @@ import (
|
||||
// @failure 500 "Server error"
|
||||
// @router /registries/{id} [get]
|
||||
func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
|
||||
@@ -39,11 +39,6 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
err = handler.requestBouncer.RegistryAccess(r, registry)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", errors.ErrEndpointAccessDenied}
|
||||
}
|
||||
|
||||
hideFields(registry)
|
||||
hideFields(registry, false)
|
||||
return response.JSON(w, registry)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/response"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -21,21 +22,18 @@ import (
|
||||
// @failure 500 "Server error"
|
||||
// @router /registries [get]
|
||||
func (handler *Handler) registryList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to list registries, use /endpoints/:endpointId/registries route instead", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
filteredRegistries := security.FilterRegistries(registries, securityContext)
|
||||
|
||||
for idx := range filteredRegistries {
|
||||
hideFields(&filteredRegistries[idx])
|
||||
}
|
||||
|
||||
return response.JSON(w, filteredRegistries)
|
||||
return response.JSON(w, registries)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type registryUpdatePayload struct {
|
||||
@@ -16,15 +18,16 @@ type registryUpdatePayload struct {
|
||||
Name *string `validate:"required" example:"my-registry"`
|
||||
// URL or IP address of the Docker registry
|
||||
URL *string `validate:"required" example:"registry.mydomain.tld:2375"`
|
||||
// BaseURL is used for quay registry
|
||||
BaseURL *string `json:",omitempty" example:"registry.mydomain.tld:2375"`
|
||||
// Is authentication against this registry enabled
|
||||
Authentication *bool `example:"false" validate:"required"`
|
||||
// Username used to authenticate against this registry. Required when Authentication is true
|
||||
Username *string `example:"registry_user"`
|
||||
// Password used to authenticate against this registry. required when Authentication is true
|
||||
Password *string `example:"registry_password"`
|
||||
UserAccessPolicies portainer.UserAccessPolicies
|
||||
TeamAccessPolicies portainer.TeamAccessPolicies
|
||||
Quay *portainer.QuayRegistryData
|
||||
Password *string `example:"registry_password"`
|
||||
RegistryAccesses *portainer.RegistryAccesses
|
||||
Quay *portainer.QuayRegistryData
|
||||
}
|
||||
|
||||
func (payload *registryUpdatePayload) Validate(r *http.Request) error {
|
||||
@@ -48,17 +51,19 @@ func (payload *registryUpdatePayload) Validate(r *http.Request) error {
|
||||
// @failure 500 "Server error"
|
||||
// @router /registries/{id} [put]
|
||||
func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to update registry", httperrors.ErrResourceAccessDenied}
|
||||
}
|
||||
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
|
||||
}
|
||||
|
||||
var payload registryUpdatePayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
registry, err := handler.DataStore.Registry().Registry(portainer.RegistryID(registryID))
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
@@ -66,27 +71,26 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
var payload registryUpdatePayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
if payload.Name != nil {
|
||||
registry.Name = *payload.Name
|
||||
}
|
||||
|
||||
if payload.URL != nil {
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
|
||||
}
|
||||
for _, r := range registries {
|
||||
if r.ID != registry.ID && hasSameURL(&r, registry) {
|
||||
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL already exists", errors.New("A registry is already defined for this URL")}
|
||||
}
|
||||
}
|
||||
shouldUpdateSecrets := false
|
||||
|
||||
registry.URL = *payload.URL
|
||||
if registry.Type == portainer.ProGetRegistry && payload.BaseURL != nil {
|
||||
registry.BaseURL = *payload.BaseURL
|
||||
}
|
||||
|
||||
if payload.Authentication != nil {
|
||||
if *payload.Authentication {
|
||||
registry.Authentication = true
|
||||
shouldUpdateSecrets = shouldUpdateSecrets || (payload.Username != nil && *payload.Username != registry.Username) || (payload.Password != nil && *payload.Password != registry.Password)
|
||||
|
||||
if payload.Username != nil {
|
||||
registry.Username = *payload.Username
|
||||
@@ -103,12 +107,35 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
}
|
||||
|
||||
if payload.UserAccessPolicies != nil {
|
||||
registry.UserAccessPolicies = payload.UserAccessPolicies
|
||||
if payload.URL != nil {
|
||||
shouldUpdateSecrets = shouldUpdateSecrets || (*payload.URL != registry.URL)
|
||||
|
||||
registry.URL = *payload.URL
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
|
||||
}
|
||||
for _, r := range registries {
|
||||
if r.ID != registry.ID && handler.registriesHaveSameURLAndCredentials(&r, registry) {
|
||||
return &httperror.HandlerError{http.StatusConflict, "Another registry with the same URL and credentials already exists", errors.New("A registry is already defined for this URL and credentials")}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if payload.TeamAccessPolicies != nil {
|
||||
registry.TeamAccessPolicies = payload.TeamAccessPolicies
|
||||
if shouldUpdateSecrets {
|
||||
for endpointID, endpointAccess := range registry.RegistryAccesses {
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err}
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
err = handler.updateEndpointRegistryAccess(endpoint, registry, endpointAccess)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update access to registry", err}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if payload.Quay != nil {
|
||||
@@ -123,10 +150,24 @@ func (handler *Handler) registryUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
return response.JSON(w, registry)
|
||||
}
|
||||
|
||||
func hasSameURL(r1, r2 *portainer.Registry) bool {
|
||||
if r1.Type != portainer.GitlabRegistry || r2.Type != portainer.GitlabRegistry {
|
||||
return r1.URL == r2.URL
|
||||
func (handler *Handler) updateEndpointRegistryAccess(endpoint *portainer.Endpoint, registry *portainer.Registry, endpointAccess portainer.RegistryAccessPolicies) error {
|
||||
|
||||
cli, err := handler.K8sClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r1.URL == r2.URL && r1.Gitlab.ProjectPath == r2.Gitlab.ProjectPath
|
||||
for _, namespace := range endpointAccess.Namespaces {
|
||||
err := cli.DeleteRegistrySecret(registry, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cli.CreateRegistrySecret(registry, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
88
api/http/handler/registries/registry_update_test.go
Normal file
88
api/http/handler/registries/registry_update_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package registries
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func ps(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func pb(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
type TestBouncer struct{}
|
||||
|
||||
func (t TestBouncer) AdminAccess(h http.Handler) http.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
func (t TestBouncer) RestrictedAccess(h http.Handler) http.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
func (t TestBouncer) AuthenticatedAccess(h http.Handler) http.Handler {
|
||||
return h
|
||||
}
|
||||
|
||||
func TestHandler_registryUpdate(t *testing.T) {
|
||||
payload := registryUpdatePayload{
|
||||
Name: ps("Updated test registry"),
|
||||
URL: ps("http://example.org/feed"),
|
||||
BaseURL: ps("http://example.org"),
|
||||
Authentication: pb(true),
|
||||
Username: ps("username"),
|
||||
Password: ps("password"),
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
assert.NoError(t, err)
|
||||
registry := portainer.Registry{Type: portainer.ProGetRegistry, ID: 5}
|
||||
r := httptest.NewRequest(http.MethodPut, "/registries/5", bytes.NewReader(payloadBytes))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
restrictedContext := &security.RestrictedRequestContext{
|
||||
IsAdmin: true,
|
||||
UserID: portainer.UserID(1),
|
||||
}
|
||||
|
||||
ctx := security.StoreRestrictedRequestContext(r, restrictedContext)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
updatedRegistry := portainer.Registry{}
|
||||
handler := newHandler(nil)
|
||||
handler.initRouter(TestBouncer{})
|
||||
handler.DataStore = testDataStore{
|
||||
registry: &testRegistryService{
|
||||
getRegistry: func(_ portainer.RegistryID) (*portainer.Registry, error) {
|
||||
return ®istry, nil
|
||||
},
|
||||
updateRegistry: func(ID portainer.RegistryID, r *portainer.Registry) error {
|
||||
assert.Equal(t, ID, r.ID)
|
||||
updatedRegistry = *r
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler.Router.ServeHTTP(w, r)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
// Registry type should remain intact
|
||||
assert.Equal(t, registry.Type, updatedRegistry.Type)
|
||||
|
||||
assert.Equal(t, *payload.Name, updatedRegistry.Name)
|
||||
assert.Equal(t, *payload.URL, updatedRegistry.URL)
|
||||
assert.Equal(t, *payload.BaseURL, updatedRegistry.BaseURL)
|
||||
assert.Equal(t, *payload.Authentication, updatedRegistry.Authentication)
|
||||
assert.Equal(t, *payload.Username, updatedRegistry.Username)
|
||||
assert.Equal(t, *payload.Password, updatedRegistry.Password)
|
||||
|
||||
}
|
||||
@@ -290,7 +290,6 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
|
||||
type composeStackDeploymentConfig struct {
|
||||
stack *portainer.Stack
|
||||
endpoint *portainer.Endpoint
|
||||
dockerhub *portainer.DockerHub
|
||||
registries []portainer.Registry
|
||||
isAdmin bool
|
||||
user *portainer.User
|
||||
@@ -302,26 +301,20 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
|
||||
}
|
||||
|
||||
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve DockerHub details from the database", Err: err}
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
|
||||
}
|
||||
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve registries from the database", Err: err}
|
||||
}
|
||||
filteredRegistries := security.FilterRegistries(registries, securityContext)
|
||||
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
|
||||
}
|
||||
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||
|
||||
config := &composeStackDeploymentConfig{
|
||||
stack: stack,
|
||||
endpoint: endpoint,
|
||||
dockerhub: dockerhub,
|
||||
registries: filteredRegistries,
|
||||
isAdmin: securityContext.IsAdmin,
|
||||
user: user,
|
||||
@@ -366,7 +359,7 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
|
||||
handler.stackCreationMutex.Lock()
|
||||
defer handler.stackCreationMutex.Unlock()
|
||||
|
||||
handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint)
|
||||
handler.SwarmStackManager.Login(config.registries, config.endpoint)
|
||||
|
||||
err = handler.ComposeStackManager.Up(config.stack, config.endpoint)
|
||||
if err != nil {
|
||||
|
||||
@@ -95,7 +95,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
|
||||
doCleanUp := true
|
||||
defer handler.cleanUp(stack, &doCleanUp)
|
||||
|
||||
output, err := handler.deployKubernetesStack(endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err}
|
||||
}
|
||||
|
||||
output, err := handler.deployKubernetesStack(endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||
return response.JSON(w, resp)
|
||||
}
|
||||
|
||||
func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) {
|
||||
func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) {
|
||||
handler.stackCreationMutex.Lock()
|
||||
defer handler.stackCreationMutex.Unlock()
|
||||
|
||||
@@ -167,7 +167,7 @@ func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stac
|
||||
stackConfig = string(convertedConfig)
|
||||
}
|
||||
|
||||
return handler.KubernetesDeployer.Deploy(endpoint, stackConfig, namespace)
|
||||
return handler.KubernetesDeployer.Deploy(request, endpoint, stackConfig, namespace)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
@@ -148,6 +150,8 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
@@ -244,6 +248,8 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
payload.Name = handler.SwarmStackManager.NormalizeStackName(payload.Name)
|
||||
|
||||
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, true)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
|
||||
@@ -300,7 +306,6 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r
|
||||
type swarmStackDeploymentConfig struct {
|
||||
stack *portainer.Stack
|
||||
endpoint *portainer.Endpoint
|
||||
dockerhub *portainer.DockerHub
|
||||
registries []portainer.Registry
|
||||
prune bool
|
||||
isAdmin bool
|
||||
@@ -313,26 +318,20 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||
}
|
||||
|
||||
dockerhub, err := handler.DataStore.DockerHub().DockerHub()
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve DockerHub details from the database", err}
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
|
||||
}
|
||||
|
||||
registries, err := handler.DataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err}
|
||||
}
|
||||
filteredRegistries := security.FilterRegistries(registries, securityContext)
|
||||
|
||||
user, err := handler.DataStore.User().User(securityContext.UserID)
|
||||
if err != nil {
|
||||
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to load user information from the database", err}
|
||||
}
|
||||
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||
|
||||
config := &swarmStackDeploymentConfig{
|
||||
stack: stack,
|
||||
endpoint: endpoint,
|
||||
dockerhub: dockerhub,
|
||||
registries: filteredRegistries,
|
||||
prune: prune,
|
||||
isAdmin: securityContext.IsAdmin,
|
||||
@@ -367,7 +366,7 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err
|
||||
handler.stackCreationMutex.Lock()
|
||||
defer handler.stackCreationMutex.Unlock()
|
||||
|
||||
handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint)
|
||||
handler.SwarmStackManager.Login(config.registries, config.endpoint)
|
||||
|
||||
err = handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint)
|
||||
if err != nil {
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// @success 204 "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id} [delete]
|
||||
func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/start [post]
|
||||
func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/stop [post]
|
||||
func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
||||
@@ -61,7 +61,7 @@ func (payload *updateSwarmStackPayload) Validate(r *http.Request) error {
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 " not found"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id} [put]
|
||||
func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
@@ -160,6 +160,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
stack.UpdatedBy = config.user.Username
|
||||
stack.Status = portainer.StackStatusActive
|
||||
|
||||
err = handler.deployComposeStack(config)
|
||||
if err != nil {
|
||||
|
||||
@@ -33,7 +33,23 @@ func (payload *updateStackGitPayload) Validate(r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PUT request on /api/stacks/:id/git?endpointId=<endpointId>
|
||||
// @id StackUpdateGit
|
||||
// @summary Redeploy a stack
|
||||
// @description Pull and redeploy a stack via Git
|
||||
// @description **Access policy**: restricted
|
||||
// @tags stacks
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Stack identifier"
|
||||
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this optional parameter to set the endpoint identifier used by the stack."
|
||||
// @param body body updateStackGitPayload true "Git configs for pull and redeploy a stack"
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
// @failure 404 "Not found"
|
||||
// @failure 500 "Server error"
|
||||
// @router /stacks/{id}/git [put]
|
||||
func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
|
||||
@@ -3,11 +3,12 @@ package teams
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/bolt/errors"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
)
|
||||
|
||||
// @id TeamDelete
|
||||
@@ -29,7 +30,7 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http
|
||||
}
|
||||
|
||||
_, err = handler.DataStore.Team().Team(portainer.TeamID(teamID))
|
||||
if err == errors.ErrObjectNotFound {
|
||||
if err == bolterrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a team with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a team with the specified identifier inside the database", err}
|
||||
@@ -45,5 +46,27 @@ func (handler *Handler) teamDelete(w http.ResponseWriter, r *http.Request) *http
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete associated team memberships from the database", err}
|
||||
}
|
||||
|
||||
// update default team if deleted team was default
|
||||
err = handler.updateDefaultTeamIfDeleted(portainer.TeamID(teamID))
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to reset default team", err}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
// updateDefaultTeamIfDeleted resets the default team to nil if default team was the deleted team
|
||||
func (handler *Handler) updateDefaultTeamIfDeleted(teamID portainer.TeamID) error {
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to fetch settings")
|
||||
}
|
||||
|
||||
if teamID != settings.OAuthSettings.DefaultTeamID {
|
||||
return nil
|
||||
}
|
||||
|
||||
settings.OAuthSettings.DefaultTeamID = 0
|
||||
err = handler.DataStore.Settings().UpdateSettings(settings)
|
||||
return errors.Wrap(err, "failed to update settings")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
@@ -12,20 +13,22 @@ import (
|
||||
// Handler is the HTTP handler used to handle websocket operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
DataStore portainer.DataStore
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
requestBouncer *security.RequestBouncer
|
||||
connectionUpgrader websocket.Upgrader
|
||||
DataStore portainer.DataStore
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
ReverseTunnelService portainer.ReverseTunnelService
|
||||
KubernetesClientFactory *cli.ClientFactory
|
||||
requestBouncer *security.RequestBouncer
|
||||
connectionUpgrader websocket.Upgrader
|
||||
kubernetesTokenCacheManager *kubernetes.TokenCacheManager
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage websocket operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
func NewHandler(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
connectionUpgrader: websocket.Upgrader{},
|
||||
requestBouncer: bouncer,
|
||||
kubernetesTokenCacheManager: kubernetesTokenCacheManager,
|
||||
}
|
||||
h.PathPrefix("/websocket/exec").Handler(
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketExec)))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
)
|
||||
|
||||
// @summary Execute a websocket on pod
|
||||
@@ -70,8 +73,14 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
|
||||
}
|
||||
|
||||
token, useAdminToken, err := handler.getToken(r, endpoint, false)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user service account token", err}
|
||||
}
|
||||
|
||||
params := &webSocketRequestParams{
|
||||
endpoint: endpoint,
|
||||
token: token,
|
||||
}
|
||||
|
||||
r.Header.Del("Origin")
|
||||
@@ -112,7 +121,7 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err}
|
||||
}
|
||||
|
||||
err = cli.StartExecProcess(namespace, podName, containerName, commandArray, stdinReader, stdoutWriter)
|
||||
err = cli.StartExecProcess(token, useAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err}
|
||||
}
|
||||
@@ -124,3 +133,37 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
kubecli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
tokenCache := handler.kubernetesTokenCacheManager.GetOrCreateTokenCache(int(endpoint.ID))
|
||||
|
||||
tokenManager, err := kubernetes.NewTokenManager(kubecli, handler.DataStore, tokenCache, setLocalAdminToken)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return tokenManager.GetAdminServiceAccountToken(), true, nil
|
||||
}
|
||||
|
||||
token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return "", false, fmt.Errorf("can not get a valid user service account token")
|
||||
}
|
||||
|
||||
return token, false, nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r
|
||||
|
||||
proxy.Director = func(incoming *http.Request, out http.Header) {
|
||||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
|
||||
}
|
||||
|
||||
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
|
||||
@@ -64,6 +65,7 @@ func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *htt
|
||||
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
|
||||
out.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
|
||||
out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token)
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(w, r)
|
||||
|
||||
@@ -8,4 +8,5 @@ type webSocketRequestParams struct {
|
||||
ID string
|
||||
nodeName string
|
||||
endpoint *portainer.Endpoint
|
||||
token string
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
)
|
||||
|
||||
// proxy for /subscriptions/*/resourceGroups/*/providers/Microsoft.ContainerInstance/containerGroups/*
|
||||
@@ -49,7 +49,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request)
|
||||
errObj := map[string]string{
|
||||
"message": "A container instance with the same name already exists inside the selected resource group",
|
||||
}
|
||||
err = responseutils.RewriteResponse(resp, errObj, http.StatusConflict)
|
||||
err = utils.RewriteResponse(resp, errObj, http.StatusConflict)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request)
|
||||
return response, err
|
||||
}
|
||||
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func (transport *Transport) proxyContainerGroupPutRequest(request *http.Request)
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
err = responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
err = utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request)
|
||||
return response, err
|
||||
}
|
||||
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func (transport *Transport) proxyContainerGroupGetRequest(request *http.Request)
|
||||
|
||||
responseObject = transport.decorateContainerGroup(responseObject, context)
|
||||
|
||||
responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Reque
|
||||
}
|
||||
|
||||
if !transport.userCanDeleteContainerGroup(request, context) {
|
||||
return responseutils.WriteAccessDeniedResponse()
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
response, err := http.DefaultTransport.RoundTrip(request)
|
||||
@@ -126,14 +126,14 @@ func (transport *Transport) proxyContainerGroupDeleteRequest(request *http.Reque
|
||||
return response, err
|
||||
}
|
||||
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport.removeResourceControl(responseObject, context)
|
||||
|
||||
responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
)
|
||||
|
||||
// proxy for /subscriptions/*/providers/Microsoft.ContainerInstance/containerGroups
|
||||
@@ -23,7 +23,7 @@ func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -39,10 +39,10 @@ func (transport *Transport) proxyContainerGroupsGetRequest(request *http.Request
|
||||
filteredValue := transport.filterContainerGroups(decoratedValue, context)
|
||||
responseObject["value"] = filteredValue
|
||||
|
||||
responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
} else {
|
||||
return nil, fmt.Errorf("The container groups response has no value property")
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/portainer/portainer/api/internal/stackutils"
|
||||
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -162,7 +162,7 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe
|
||||
systemResourceControl := findSystemNetworkResourceControl(responseObject)
|
||||
if systemResourceControl != nil {
|
||||
responseObject = decorateObject(responseObject, systemResourceControl)
|
||||
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,15 +175,15 @@ func (transport *Transport) applyAccessControlOnResource(parameters *resourceOpe
|
||||
}
|
||||
|
||||
if resourceControl == nil && (executor.operationContext.isAdmin) {
|
||||
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
if executor.operationContext.isAdmin || (resourceControl != nil && authorization.UserCanAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl)) {
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
return responseutils.RewriteAccessDeniedResponse(response)
|
||||
return utils.RewriteAccessDeniedResponse(response)
|
||||
}
|
||||
|
||||
func (transport *Transport) applyAccessControlOnResourceList(parameters *resourceOperationParameters, resourceData []interface{}, executor *operationExecutor) ([]interface{}, error) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ func getInheritedResourceControlFromConfigLabels(dockerClient *client.Client, en
|
||||
func (transport *Transport) configListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// ConfigList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigList
|
||||
responseArray, err := responseutils.GetResponseAsJSONArray(response)
|
||||
responseArray, err := utils.GetResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (transport *Transport) configListOperation(response *http.Response, executo
|
||||
return err
|
||||
}
|
||||
|
||||
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// configInspectOperation extracts the response as a JSON object, verify that the user
|
||||
@@ -58,7 +58,7 @@ func (transport *Transport) configListOperation(response *http.Response, executo
|
||||
func (transport *Transport) configInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// ConfigInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.30/#operation/ConfigInspect
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -78,9 +78,9 @@ func (transport *Transport) configInspectOperation(response *http.Response, exec
|
||||
// https://docs.docker.com/engine/api/v1.37/#operation/ConfigList
|
||||
// https://docs.docker.com/engine/api/v1.37/#operation/ConfigInspect
|
||||
func selectorConfigLabels(responseObject map[string]interface{}) map[string]interface{} {
|
||||
secretSpec := responseutils.GetJSONObject(responseObject, "Spec")
|
||||
secretSpec := utils.GetJSONObject(responseObject, "Spec")
|
||||
if secretSpec != nil {
|
||||
secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels")
|
||||
secretLabelsObject := utils.GetJSONObject(secretSpec, "Labels")
|
||||
return secretLabelsObject
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
@@ -46,7 +46,7 @@ func getInheritedResourceControlFromContainerLabels(dockerClient *client.Client,
|
||||
func (transport *Transport) containerListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// ContainerList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
responseArray, err := responseutils.GetResponseAsJSONArray(response)
|
||||
responseArray, err := utils.GetResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec
|
||||
}
|
||||
}
|
||||
|
||||
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// containerInspectOperation extracts the response as a JSON object, verify that the user
|
||||
@@ -77,7 +77,7 @@ func (transport *Transport) containerListOperation(response *http.Response, exec
|
||||
func (transport *Transport) containerInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
//ContainerInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,9 +96,9 @@ func (transport *Transport) containerInspectOperation(response *http.Response, e
|
||||
// Labels are available under the "Config.Labels" property.
|
||||
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerInspect
|
||||
func selectorContainerLabelsFromContainerInspectOperation(responseObject map[string]interface{}) map[string]interface{} {
|
||||
containerConfigObject := responseutils.GetJSONObject(responseObject, "Config")
|
||||
containerConfigObject := utils.GetJSONObject(responseObject, "Config")
|
||||
if containerConfigObject != nil {
|
||||
containerLabelsObject := responseutils.GetJSONObject(containerConfigObject, "Labels")
|
||||
containerLabelsObject := utils.GetJSONObject(containerConfigObject, "Labels")
|
||||
return containerLabelsObject
|
||||
}
|
||||
return nil
|
||||
@@ -109,7 +109,7 @@ func selectorContainerLabelsFromContainerInspectOperation(responseObject map[str
|
||||
// Labels are available under the "Labels" property.
|
||||
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
|
||||
func selectorContainerLabelsFromContainerListOperation(responseObject map[string]interface{}) map[string]interface{} {
|
||||
containerLabelsObject := responseutils.GetJSONObject(responseObject, "Labels")
|
||||
containerLabelsObject := utils.GetJSONObject(responseObject, "Labels")
|
||||
return containerLabelsObject
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ func getInheritedResourceControlFromNetworkLabels(dockerClient *client.Client, e
|
||||
func (transport *Transport) networkListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// NetworkList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
|
||||
responseArray, err := responseutils.GetResponseAsJSONArray(response)
|
||||
responseArray, err := utils.GetResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (transport *Transport) networkListOperation(response *http.Response, execut
|
||||
return err
|
||||
}
|
||||
|
||||
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// networkInspectOperation extracts the response as a JSON object, verify that the user
|
||||
@@ -62,7 +62,7 @@ func (transport *Transport) networkListOperation(response *http.Response, execut
|
||||
func (transport *Transport) networkInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// NetworkInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -99,5 +99,5 @@ func findSystemNetworkResourceControl(networkObject map[string]interface{}) *por
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
|
||||
func selectorNetworkLabels(responseObject map[string]interface{}) map[string]interface{} {
|
||||
return responseutils.GetJSONObject(responseObject, "Labels")
|
||||
return utils.GetJSONObject(responseObject, "Labels")
|
||||
}
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
type (
|
||||
registryAccessContext struct {
|
||||
isAdmin bool
|
||||
userID portainer.UserID
|
||||
user *portainer.User
|
||||
endpointID portainer.EndpointID
|
||||
teamMemberships []portainer.TeamMembership
|
||||
registries []portainer.Registry
|
||||
dockerHub *portainer.DockerHub
|
||||
}
|
||||
|
||||
registryAuthenticationHeader struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Serveraddress string `json:"serveraddress"`
|
||||
}
|
||||
|
||||
portainerRegistryAuthenticationHeader struct {
|
||||
RegistryId portainer.RegistryID `json:"registryId"`
|
||||
}
|
||||
)
|
||||
|
||||
func createRegistryAuthenticationHeader(serverAddress string, accessContext *registryAccessContext) *registryAuthenticationHeader {
|
||||
func createRegistryAuthenticationHeader(registryId portainer.RegistryID, accessContext *registryAccessContext) *registryAuthenticationHeader {
|
||||
var authenticationHeader *registryAuthenticationHeader
|
||||
|
||||
if serverAddress == "" {
|
||||
if registryId == 0 { // dockerhub (anonymous)
|
||||
authenticationHeader = ®istryAuthenticationHeader{
|
||||
Username: accessContext.dockerHub.Username,
|
||||
Password: accessContext.dockerHub.Password,
|
||||
Serveraddress: "docker.io",
|
||||
}
|
||||
} else {
|
||||
} else { // any "custom" registry
|
||||
var matchingRegistry *portainer.Registry
|
||||
for _, registry := range accessContext.registries {
|
||||
if registry.URL == serverAddress &&
|
||||
(accessContext.isAdmin || (!accessContext.isAdmin && security.AuthorizedRegistryAccess(®istry, accessContext.userID, accessContext.teamMemberships))) {
|
||||
if registry.ID == registryId &&
|
||||
(accessContext.isAdmin ||
|
||||
security.AuthorizedRegistryAccess(®istry, accessContext.user, accessContext.teamMemberships, accessContext.endpointID)) {
|
||||
matchingRegistry = ®istry
|
||||
break
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ func getInheritedResourceControlFromSecretLabels(dockerClient *client.Client, en
|
||||
func (transport *Transport) secretListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// SecretList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/SecretList
|
||||
responseArray, err := responseutils.GetResponseAsJSONArray(response)
|
||||
responseArray, err := utils.GetResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func (transport *Transport) secretListOperation(response *http.Response, executo
|
||||
return err
|
||||
}
|
||||
|
||||
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// secretInspectOperation extracts the response as a JSON object, verify that the user
|
||||
@@ -58,7 +58,7 @@ func (transport *Transport) secretListOperation(response *http.Response, executo
|
||||
func (transport *Transport) secretInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// SecretInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/SecretInspect
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -78,9 +78,9 @@ func (transport *Transport) secretInspectOperation(response *http.Response, exec
|
||||
// https://docs.docker.com/engine/api/v1.37/#operation/SecretList
|
||||
// https://docs.docker.com/engine/api/v1.37/#operation/SecretInspect
|
||||
func selectorSecretLabels(responseObject map[string]interface{}) map[string]interface{} {
|
||||
secretSpec := responseutils.GetJSONObject(responseObject, "Spec")
|
||||
secretSpec := utils.GetJSONObject(responseObject, "Spec")
|
||||
if secretSpec != nil {
|
||||
secretLabelsObject := responseutils.GetJSONObject(secretSpec, "Labels")
|
||||
secretLabelsObject := utils.GetJSONObject(secretSpec, "Labels")
|
||||
return secretLabelsObject
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/docker/docker/client"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ func getInheritedResourceControlFromServiceLabels(dockerClient *client.Client, e
|
||||
func (transport *Transport) serviceListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// ServiceList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
responseArray, err := responseutils.GetResponseAsJSONArray(response)
|
||||
responseArray, err := utils.GetResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func (transport *Transport) serviceListOperation(response *http.Response, execut
|
||||
return err
|
||||
}
|
||||
|
||||
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// serviceInspectOperation extracts the response as a JSON object, verify that the user
|
||||
@@ -63,7 +63,7 @@ func (transport *Transport) serviceListOperation(response *http.Response, execut
|
||||
func (transport *Transport) serviceInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
//ServiceInspect response is a JSON object
|
||||
//https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -83,9 +83,9 @@ func (transport *Transport) serviceInspectOperation(response *http.Response, exe
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
|
||||
func selectorServiceLabels(responseObject map[string]interface{}) map[string]interface{} {
|
||||
serviceSpecObject := responseutils.GetJSONObject(responseObject, "Spec")
|
||||
serviceSpecObject := utils.GetJSONObject(responseObject, "Spec")
|
||||
if serviceSpecObject != nil {
|
||||
return responseutils.GetJSONObject(serviceSpecObject, "Labels")
|
||||
return utils.GetJSONObject(serviceSpecObject, "Labels")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package docker
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
)
|
||||
|
||||
// swarmInspectOperation extracts the response as a JSON object and rewrites the response based
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func swarmInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// SwarmInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.30/#operation/SwarmInspect
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -21,5 +21,5 @@ func swarmInspectOperation(response *http.Response, executor *operationExecutor)
|
||||
delete(responseObject, "TLSInfo")
|
||||
}
|
||||
|
||||
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -16,7 +16,7 @@ const (
|
||||
func (transport *Transport) taskListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// TaskList response is a JSON array
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/TaskList
|
||||
responseArray, err := responseutils.GetResponseAsJSONArray(response)
|
||||
responseArray, err := utils.GetResponseAsJSONArray(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -32,18 +32,18 @@ func (transport *Transport) taskListOperation(response *http.Response, executor
|
||||
return err
|
||||
}
|
||||
|
||||
return responseutils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseArray, http.StatusOK)
|
||||
}
|
||||
|
||||
// selectorServiceLabels retrieve the labels object associated to the task object.
|
||||
// Labels are available under the "Spec.ContainerSpec.Labels" property.
|
||||
// API schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
|
||||
func selectorTaskLabels(responseObject map[string]interface{}) map[string]interface{} {
|
||||
taskSpecObject := responseutils.GetJSONObject(responseObject, "Spec")
|
||||
taskSpecObject := utils.GetJSONObject(responseObject, "Spec")
|
||||
if taskSpecObject != nil {
|
||||
containerSpecObject := responseutils.GetJSONObject(taskSpecObject, "ContainerSpec")
|
||||
containerSpecObject := utils.GetJSONObject(taskSpecObject, "ContainerSpec")
|
||||
if containerSpecObject != nil {
|
||||
return responseutils.GetJSONObject(containerSpecObject, "Labels")
|
||||
return utils.GetJSONObject(containerSpecObject, "Labels")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -5,17 +5,19 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
)
|
||||
@@ -157,21 +159,41 @@ func (transport *Transport) proxyAgentRequest(r *http.Request) (*http.Response,
|
||||
return transport.administratorOperation(r)
|
||||
}
|
||||
|
||||
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
resourceID, err := transport.getVolumeResourceID(agentTargetHeader, volumeIDParameter[0])
|
||||
volumeName := volumeIDParameter[0]
|
||||
|
||||
resourceID, err := transport.getVolumeResourceID(volumeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// volume browser request
|
||||
return transport.restrictedResourceOperation(r, resourceID, portainer.VolumeResourceControl, true)
|
||||
return transport.restrictedResourceOperation(r, resourceID, volumeName, portainer.VolumeResourceControl, true)
|
||||
case strings.HasPrefix(requestPath, "/dockerhub"):
|
||||
dockerhub, err := transport.dataStore.DockerHub().DockerHub()
|
||||
requestPath, registryIdString := path.Split(r.URL.Path)
|
||||
|
||||
registryID, err := strconv.Atoi(registryIdString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("missing registry id: %w", err)
|
||||
}
|
||||
|
||||
newBody, err := json.Marshal(dockerhub)
|
||||
r.URL.Path = strings.TrimSuffix(requestPath, "/")
|
||||
|
||||
registry := &portainer.Registry{
|
||||
Type: portainer.DockerHubRegistry,
|
||||
}
|
||||
|
||||
if registryID != 0 {
|
||||
registry, err = transport.dataStore.Registry().Registry(portainer.RegistryID(registryID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed fetching registry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if registry.Type != portainer.DockerHubRegistry {
|
||||
return nil, errors.New("Invalid registry type")
|
||||
}
|
||||
|
||||
newBody, err := json.Marshal(registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -200,10 +222,10 @@ func (transport *Transport) proxyConfigRequest(request *http.Request) (*http.Res
|
||||
if request.Method == http.MethodGet {
|
||||
return transport.rewriteOperation(request, transport.configInspectOperation)
|
||||
} else if request.Method == http.MethodDelete {
|
||||
return transport.executeGenericResourceDeletionOperation(request, configID, portainer.ConfigResourceControl)
|
||||
return transport.executeGenericResourceDeletionOperation(request, configID, configID, portainer.ConfigResourceControl)
|
||||
}
|
||||
|
||||
return transport.restrictedResourceOperation(request, configID, portainer.ConfigResourceControl, false)
|
||||
return transport.restrictedResourceOperation(request, configID, configID, portainer.ConfigResourceControl, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,16 +250,16 @@ func (transport *Transport) proxyContainerRequest(request *http.Request) (*http.
|
||||
if action == "json" {
|
||||
return transport.rewriteOperation(request, transport.containerInspectOperation)
|
||||
}
|
||||
return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false)
|
||||
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
||||
} else if match, _ := path.Match("/containers/*", requestPath); match {
|
||||
// Handle /containers/{id} requests
|
||||
containerID := path.Base(requestPath)
|
||||
|
||||
if request.Method == http.MethodDelete {
|
||||
return transport.executeGenericResourceDeletionOperation(request, containerID, portainer.ContainerResourceControl)
|
||||
return transport.executeGenericResourceDeletionOperation(request, containerID, containerID, portainer.ContainerResourceControl)
|
||||
}
|
||||
|
||||
return transport.restrictedResourceOperation(request, containerID, portainer.ContainerResourceControl, false)
|
||||
return transport.restrictedResourceOperation(request, containerID, containerID, portainer.ContainerResourceControl, false)
|
||||
}
|
||||
return transport.executeDockerRequest(request)
|
||||
}
|
||||
@@ -256,7 +278,7 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re
|
||||
if match, _ := path.Match("/services/*/*", requestPath); match {
|
||||
// Handle /services/{id}/{action} requests
|
||||
serviceID := path.Base(path.Dir(requestPath))
|
||||
return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false)
|
||||
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
|
||||
} else if match, _ := path.Match("/services/*", requestPath); match {
|
||||
// Handle /services/{id} requests
|
||||
serviceID := path.Base(requestPath)
|
||||
@@ -265,9 +287,9 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re
|
||||
case http.MethodGet:
|
||||
return transport.rewriteOperation(request, transport.serviceInspectOperation)
|
||||
case http.MethodDelete:
|
||||
return transport.executeGenericResourceDeletionOperation(request, serviceID, portainer.ServiceResourceControl)
|
||||
return transport.executeGenericResourceDeletionOperation(request, serviceID, serviceID, portainer.ServiceResourceControl)
|
||||
}
|
||||
return transport.restrictedResourceOperation(request, serviceID, portainer.ServiceResourceControl, false)
|
||||
return transport.restrictedResourceOperation(request, serviceID, serviceID, portainer.ServiceResourceControl, false)
|
||||
}
|
||||
return transport.executeDockerRequest(request)
|
||||
}
|
||||
@@ -276,7 +298,7 @@ func (transport *Transport) proxyServiceRequest(request *http.Request) (*http.Re
|
||||
func (transport *Transport) proxyVolumeRequest(request *http.Request) (*http.Response, error) {
|
||||
switch requestPath := request.URL.Path; requestPath {
|
||||
case "/volumes/create":
|
||||
return transport.decorateVolumeResourceCreationOperation(request, volumeObjectIdentifier, portainer.VolumeResourceControl)
|
||||
return transport.decorateVolumeResourceCreationOperation(request, portainer.VolumeResourceControl)
|
||||
|
||||
case "/volumes/prune":
|
||||
return transport.administratorOperation(request)
|
||||
@@ -305,9 +327,9 @@ func (transport *Transport) proxyNetworkRequest(request *http.Request) (*http.Re
|
||||
if request.Method == http.MethodGet {
|
||||
return transport.rewriteOperation(request, transport.networkInspectOperation)
|
||||
} else if request.Method == http.MethodDelete {
|
||||
return transport.executeGenericResourceDeletionOperation(request, networkID, portainer.NetworkResourceControl)
|
||||
return transport.executeGenericResourceDeletionOperation(request, networkID, networkID, portainer.NetworkResourceControl)
|
||||
}
|
||||
return transport.restrictedResourceOperation(request, networkID, portainer.NetworkResourceControl, false)
|
||||
return transport.restrictedResourceOperation(request, networkID, networkID, portainer.NetworkResourceControl, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,9 +348,9 @@ func (transport *Transport) proxySecretRequest(request *http.Request) (*http.Res
|
||||
if request.Method == http.MethodGet {
|
||||
return transport.rewriteOperation(request, transport.secretInspectOperation)
|
||||
} else if request.Method == http.MethodDelete {
|
||||
return transport.executeGenericResourceDeletionOperation(request, secretID, portainer.SecretResourceControl)
|
||||
return transport.executeGenericResourceDeletionOperation(request, secretID, secretID, portainer.SecretResourceControl)
|
||||
}
|
||||
return transport.restrictedResourceOperation(request, secretID, portainer.SecretResourceControl, false)
|
||||
return transport.restrictedResourceOperation(request, secretID, secretID, portainer.SecretResourceControl, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,13 +416,13 @@ func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Re
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var originalHeaderData registryAuthenticationHeader
|
||||
var originalHeaderData portainerRegistryAuthenticationHeader
|
||||
err = json.Unmarshal(decodedHeaderData, &originalHeaderData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.Serveraddress, accessContext)
|
||||
authenticationHeader := createRegistryAuthenticationHeader(originalHeaderData.RegistryId, accessContext)
|
||||
|
||||
headerData, err := json.Marshal(authenticationHeader)
|
||||
if err != nil {
|
||||
@@ -415,7 +437,7 @@ func (transport *Transport) replaceRegistryAuthenticationHeader(request *http.Re
|
||||
return transport.decorateGenericResourceCreationOperation(request, serviceObjectIdentifier, portainer.ServiceResourceControl)
|
||||
}
|
||||
|
||||
func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) {
|
||||
func (transport *Transport) restrictedResourceOperation(request *http.Request, resourceID string, dockerResourceID string, resourceType portainer.ResourceControlType, volumeBrowseRestrictionCheck bool) (*http.Response, error) {
|
||||
var err error
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
@@ -430,7 +452,7 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r
|
||||
}
|
||||
|
||||
if !securitySettings.AllowVolumeBrowserForRegularUsers {
|
||||
return responseutils.WriteAccessDeniedResponse()
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,20 +475,24 @@ func (transport *Transport) restrictedResourceOperation(request *http.Request, r
|
||||
if resourceControl == nil {
|
||||
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
|
||||
if dockerResourceID == "" {
|
||||
dockerResourceID = resourceID
|
||||
}
|
||||
|
||||
// This resource was created outside of portainer,
|
||||
// is part of a Docker service or part of a Docker Swarm/Compose stack.
|
||||
inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(resourceID, agentTargetHeader, resourceType, resourceControls)
|
||||
inheritedResourceControl, err := transport.getInheritedResourceControlFromServiceOrStack(dockerResourceID, agentTargetHeader, resourceType, resourceControls)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if inheritedResourceControl == nil || !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, inheritedResourceControl) {
|
||||
return responseutils.WriteAccessDeniedResponse()
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
if resourceControl != nil && !authorization.UserCanAccessResource(tokenData.ID, userTeamIDs, resourceControl) {
|
||||
return responseutils.WriteAccessDeniedResponse()
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +556,7 @@ func (transport *Transport) interceptAndRewriteRequest(request *http.Request, op
|
||||
// https://docs.docker.com/engine/api/v1.37/#operation/SecretCreate
|
||||
// https://docs.docker.com/engine/api/v1.37/#operation/ConfigCreate
|
||||
func (transport *Transport) decorateGenericResourceCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -549,7 +575,7 @@ func (transport *Transport) decorateGenericResourceCreationResponse(response *ht
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateGenericResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
@@ -570,8 +596,8 @@ func (transport *Transport) decorateGenericResourceCreationOperation(request *ht
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (transport *Transport) executeGenericResourceDeletionOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
response, err := transport.restrictedResourceOperation(request, resourceIdentifierAttribute, resourceType, false)
|
||||
func (transport *Transport) executeGenericResourceDeletionOperation(request *http.Request, resourceIdentifierAttribute string, volumeName string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
response, err := transport.restrictedResourceOperation(request, resourceIdentifierAttribute, volumeName, resourceType, false)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
@@ -612,7 +638,7 @@ func (transport *Transport) administratorOperation(request *http.Request) (*http
|
||||
}
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
return responseutils.WriteAccessDeniedResponse()
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
return transport.executeDockerRequest(request)
|
||||
@@ -625,15 +651,15 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) (
|
||||
}
|
||||
|
||||
accessContext := ®istryAccessContext{
|
||||
isAdmin: true,
|
||||
userID: tokenData.ID,
|
||||
isAdmin: true,
|
||||
endpointID: transport.endpoint.ID,
|
||||
}
|
||||
|
||||
hub, err := transport.dataStore.DockerHub().DockerHub()
|
||||
user, err := transport.dataStore.User().User(tokenData.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accessContext.dockerHub = hub
|
||||
accessContext.user = user
|
||||
|
||||
registries, err := transport.dataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
@@ -641,7 +667,7 @@ func (transport *Transport) createRegistryAccessContext(request *http.Request) (
|
||||
}
|
||||
accessContext.registries = registries
|
||||
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
if user.Role != portainer.AdministratorRole {
|
||||
accessContext.isAdmin = false
|
||||
|
||||
teamMemberships, err := transport.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID)
|
||||
|
||||
@@ -3,19 +3,21 @@ package docker
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/docker/docker/client"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/responseutils"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/snapshot"
|
||||
)
|
||||
|
||||
const (
|
||||
volumeObjectIdentifier = "ID"
|
||||
volumeObjectIdentifier = "ResourceID"
|
||||
)
|
||||
|
||||
func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, endpointID portainer.EndpointID, volumeID string, resourceControls []portainer.ResourceControl) (*portainer.ResourceControl, error) {
|
||||
@@ -37,7 +39,7 @@ func getInheritedResourceControlFromVolumeLabels(dockerClient *client.Client, en
|
||||
func (transport *Transport) volumeListOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// VolumeList response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -48,10 +50,12 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
|
||||
for _, volumeObject := range volumeData {
|
||||
volume := volumeObject.(map[string]interface{})
|
||||
if volume["Name"] == nil || volume["CreatedAt"] == nil {
|
||||
return errors.New("missing identifier in Docker resource list response")
|
||||
|
||||
err = transport.decorateVolumeResponseWithResourceID(volume)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed decorating volume response: %w", err)
|
||||
}
|
||||
volume[volumeObjectIdentifier] = volume["Name"].(string) + volume["CreatedAt"].(string)
|
||||
|
||||
}
|
||||
|
||||
resourceOperationParameters := &resourceOperationParameters{
|
||||
@@ -68,7 +72,7 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
responseObject["Volumes"] = volumeData
|
||||
}
|
||||
|
||||
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
// volumeInspectOperation extracts the response as a JSON object, verify that the user
|
||||
@@ -76,15 +80,15 @@ func (transport *Transport) volumeListOperation(response *http.Response, executo
|
||||
func (transport *Transport) volumeInspectOperation(response *http.Response, executor *operationExecutor) error {
|
||||
// VolumeInspect response is a JSON object
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject["Name"] == nil || responseObject["CreatedAt"] == nil {
|
||||
return errors.New("missing identifier in Docker resource detail response")
|
||||
err = transport.decorateVolumeResponseWithResourceID(responseObject)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed decorating volume response: %w", err)
|
||||
}
|
||||
responseObject[volumeObjectIdentifier] = responseObject["Name"].(string) + responseObject["CreatedAt"].(string)
|
||||
|
||||
resourceOperationParameters := &resourceOperationParameters{
|
||||
resourceIdentifierAttribute: volumeObjectIdentifier,
|
||||
@@ -95,16 +99,31 @@ func (transport *Transport) volumeInspectOperation(response *http.Response, exec
|
||||
return transport.applyAccessControlOnResource(resourceOperationParameters, responseObject, response, executor)
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateVolumeResponseWithResourceID(responseObject map[string]interface{}) error {
|
||||
if responseObject["Name"] == nil {
|
||||
return errors.New("missing identifier in Docker resource detail response")
|
||||
}
|
||||
|
||||
resourceID, err := transport.getVolumeResourceID(responseObject["Name"].(string))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching resource id: %w", err)
|
||||
}
|
||||
|
||||
responseObject[volumeObjectIdentifier] = resourceID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectorVolumeLabels retrieve the labels object associated to the volume object.
|
||||
// Labels are available under the "Labels" property.
|
||||
// API schema references:
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
|
||||
// https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
|
||||
func selectorVolumeLabels(responseObject map[string]interface{}) map[string]interface{} {
|
||||
return responseutils.GetJSONObject(responseObject, "Labels")
|
||||
return utils.GetJSONObject(responseObject, "Labels")
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
func (transport *Transport) decorateVolumeResourceCreationOperation(request *http.Request, resourceType portainer.ResourceControlType) (*http.Response, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -136,30 +155,36 @@ func (transport *Transport) decorateVolumeResourceCreationOperation(request *htt
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusCreated {
|
||||
err = transport.decorateVolumeCreationResponse(response, resourceIdentifierAttribute, resourceType, tokenData.ID)
|
||||
err = transport.decorateVolumeCreationResponse(response, resourceType, tokenData.ID)
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
|
||||
func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceIdentifierAttribute string, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
|
||||
responseObject, err := responseutils.GetResponseAsJSONObject(response)
|
||||
func (transport *Transport) decorateVolumeCreationResponse(response *http.Response, resourceType portainer.ResourceControlType, userID portainer.UserID) error {
|
||||
responseObject, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if responseObject["Name"] == nil || responseObject["CreatedAt"] == nil {
|
||||
if responseObject["Name"] == nil {
|
||||
return errors.New("missing identifier in Docker resource creation response")
|
||||
}
|
||||
resourceID := responseObject["Name"].(string) + responseObject["CreatedAt"].(string)
|
||||
|
||||
resourceID, err := transport.getVolumeResourceID(responseObject["Name"].(string))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching resource id: %w", err)
|
||||
}
|
||||
|
||||
resourceControl, err := transport.createPrivateResourceControl(resourceID, resourceType, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
responseObject[volumeObjectIdentifier] = resourceID
|
||||
|
||||
responseObject = decorateObject(responseObject, resourceControl)
|
||||
|
||||
return responseutils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
return utils.RewriteResponse(response, responseObject, http.StatusOK)
|
||||
}
|
||||
|
||||
func (transport *Transport) restrictedVolumeOperation(requestPath string, request *http.Request) (*http.Response, error) {
|
||||
@@ -168,30 +193,47 @@ func (transport *Transport) restrictedVolumeOperation(requestPath string, reques
|
||||
return transport.rewriteOperation(request, transport.volumeInspectOperation)
|
||||
}
|
||||
|
||||
agentTargetHeader := request.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
volumeName := path.Base(requestPath)
|
||||
|
||||
resourceID, err := transport.getVolumeResourceID(agentTargetHeader, path.Base(requestPath))
|
||||
resourceID, err := transport.getVolumeResourceID(volumeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if request.Method == http.MethodDelete {
|
||||
return transport.executeGenericResourceDeletionOperation(request, resourceID, portainer.VolumeResourceControl)
|
||||
return transport.executeGenericResourceDeletionOperation(request, resourceID, volumeName, portainer.VolumeResourceControl)
|
||||
}
|
||||
return transport.restrictedResourceOperation(request, resourceID, portainer.VolumeResourceControl, false)
|
||||
return transport.restrictedResourceOperation(request, resourceID, volumeName, portainer.VolumeResourceControl, false)
|
||||
}
|
||||
|
||||
func (transport *Transport) getVolumeResourceID(nodename, volumeID string) (string, error) {
|
||||
cli, err := transport.dockerClientFactory.CreateClient(transport.endpoint, nodename)
|
||||
func (transport *Transport) getVolumeResourceID(volumeName string) (string, error) {
|
||||
dockerID, err := transport.getDockerID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("failed fetching docker id: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%s_%s", volumeName, dockerID), nil
|
||||
}
|
||||
|
||||
func (transport *Transport) getDockerID() (string, error) {
|
||||
if len(transport.endpoint.Snapshots) > 0 {
|
||||
dockerID, err := snapshot.FetchDockerID(transport.endpoint.Snapshots[0])
|
||||
// ignore err - in case of error, just generate not from snapshot
|
||||
if err == nil {
|
||||
return dockerID, nil
|
||||
}
|
||||
}
|
||||
|
||||
cli := transport.dockerClient
|
||||
defer cli.Close()
|
||||
|
||||
volume, err := cli.VolumeInspect(context.Background(), volumeID)
|
||||
info, err := cli.Info(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return volume.Name + volume.CreatedAt, nil
|
||||
if info.Swarm.Cluster != nil {
|
||||
return info.Swarm.Cluster.ID, nil
|
||||
}
|
||||
|
||||
return info.ID, nil
|
||||
}
|
||||
|
||||
@@ -49,13 +49,18 @@ func (factory *ProxyFactory) NewDockerComposeAgentProxy(endpoint *portainer.Endp
|
||||
proxy.Transport = dockercompose.NewAgentTransport(factory.signatureService, httpTransport)
|
||||
|
||||
proxyServer := &ProxyServer{
|
||||
&http.Server{
|
||||
server: &http.Server{
|
||||
Handler: proxy,
|
||||
},
|
||||
0,
|
||||
Port: 0,
|
||||
}
|
||||
|
||||
return proxyServer, proxyServer.start()
|
||||
err = proxyServer.start()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proxyServer, err
|
||||
}
|
||||
|
||||
func (proxy *ProxyServer) start() error {
|
||||
@@ -72,7 +77,7 @@ func (proxy *ProxyServer) start() error {
|
||||
err := proxy.server.Serve(listener)
|
||||
log.Printf("Exiting Proxy server %s\n", proxyHost)
|
||||
|
||||
if err != http.ErrServerClosed {
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
log.Printf("Proxy server %s exited with an error: %s\n", proxyHost, err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -39,7 +39,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport, err := kubernetes.NewLocalTransport(tokenManager)
|
||||
transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -72,7 +72,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp
|
||||
|
||||
endpointURL.Scheme = "http"
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.reverseTunnelService, endpoint.ID, tokenManager)
|
||||
proxy.Transport = kubernetes.NewEdgeTransport(factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory, factory.dataStore)
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En
|
||||
}
|
||||
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||
proxy.Transport = kubernetes.NewAgentTransport(factory.dataStore, factory.signatureService, tlsConfig, tokenManager)
|
||||
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
60
api/http/proxy/factory/kubernetes/agent_transport.go
Normal file
60
api/http/proxy/factory/kubernetes/agent_transport.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
type agentTransport struct {
|
||||
*baseTransport
|
||||
signatureService portainer.DigitalSignatureService
|
||||
}
|
||||
|
||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
||||
func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *agentTransport {
|
||||
transport := &agentTransport{
|
||||
baseTransport: newBaseTransport(
|
||||
&http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
tokenManager,
|
||||
endpoint,
|
||||
k8sClientFactory,
|
||||
dataStore,
|
||||
),
|
||||
signatureService: signatureService,
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := transport.getRoundTripToken(request, transport.tokenManager)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/v2") {
|
||||
err := decorateAgentRequest(request, transport.dataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
|
||||
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
|
||||
return transport.baseTransport.RoundTrip(request)
|
||||
}
|
||||
57
api/http/proxy/factory/kubernetes/edge_transport.go
Normal file
57
api/http/proxy/factory/kubernetes/edge_transport.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
type edgeTransport struct {
|
||||
*baseTransport
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
}
|
||||
|
||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
|
||||
func NewEdgeTransport(reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *edgeTransport {
|
||||
transport := &edgeTransport{
|
||||
baseTransport: newBaseTransport(
|
||||
&http.Transport{},
|
||||
tokenManager,
|
||||
endpoint,
|
||||
k8sClientFactory,
|
||||
dataStore,
|
||||
),
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := transport.getRoundTripToken(request, transport.tokenManager)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/v2") {
|
||||
err := decorateAgentRequest(request, transport.dataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
response, err := transport.baseTransport.RoundTrip(request)
|
||||
|
||||
if err == nil {
|
||||
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpoint.ID)
|
||||
} else {
|
||||
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpoint.ID)
|
||||
}
|
||||
|
||||
return response, err
|
||||
}
|
||||
45
api/http/proxy/factory/kubernetes/local_transport.go
Normal file
45
api/http/proxy/factory/kubernetes/local_transport.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
type localTransport struct {
|
||||
*baseTransport
|
||||
}
|
||||
|
||||
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
|
||||
func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) (*localTransport, error) {
|
||||
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport := &localTransport{
|
||||
baseTransport: newBaseTransport(
|
||||
&http.Transport{
|
||||
TLSClientConfig: config,
|
||||
},
|
||||
tokenManager,
|
||||
endpoint,
|
||||
k8sClientFactory,
|
||||
dataStore,
|
||||
),
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
_, err := transport.prepareRoundTrip(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transport.baseTransport.RoundTrip(request)
|
||||
}
|
||||
50
api/http/proxy/factory/kubernetes/namespaces.go
Normal file
50
api/http/proxy/factory/kubernetes/namespaces.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func (transport *baseTransport) proxyNamespaceDeleteOperation(request *http.Request, namespace string) (*http.Response, error) {
|
||||
if err := transport.tokenManager.kubecli.NamespaceAccessPoliciesDeleteNamespace(namespace); err != nil {
|
||||
return nil, errors.WithMessagef(err, "failed to delete a namespace [%s] from portainer config", namespace)
|
||||
}
|
||||
|
||||
registries, err := transport.dataStore.Registry().Registries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, registry := range registries {
|
||||
for endpointID, registryAccessPolicies := range registry.RegistryAccesses {
|
||||
if endpointID != transport.endpoint.ID {
|
||||
continue
|
||||
}
|
||||
|
||||
namespaces := []string{}
|
||||
for _, ns := range registryAccessPolicies.Namespaces {
|
||||
if ns == namespace {
|
||||
continue
|
||||
}
|
||||
namespaces = append(namespaces, ns)
|
||||
}
|
||||
|
||||
if len(namespaces) != len(registryAccessPolicies.Namespaces) {
|
||||
updatedAccessPolicies := portainer.RegistryAccessPolicies{
|
||||
Namespaces: namespaces,
|
||||
UserAccessPolicies: registryAccessPolicies.UserAccessPolicies,
|
||||
TeamAccessPolicies: registryAccessPolicies.TeamAccessPolicies,
|
||||
}
|
||||
|
||||
registry.RegistryAccesses[endpointID] = updatedAccessPolicies
|
||||
err := transport.dataStore.Registry().UpdateRegistry(registry.ID, ®istry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
170
api/http/proxy/factory/kubernetes/secrets.go
Normal file
170
api/http/proxy/factory/kubernetes/secrets.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/utils"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/privateregistries"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
)
|
||||
|
||||
func (transport *baseTransport) proxySecretRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
|
||||
switch request.Method {
|
||||
case "POST":
|
||||
return transport.proxySecretCreationOperation(request)
|
||||
case "GET":
|
||||
if path.Base(requestPath) == "secrets" {
|
||||
return transport.proxySecretListOperation(request)
|
||||
}
|
||||
return transport.proxySecretInspectOperation(request)
|
||||
case "PUT":
|
||||
return transport.proxySecretUpdateOperation(request)
|
||||
case "DELETE":
|
||||
return transport.proxySecretDeleteOperation(request, namespace)
|
||||
default:
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretCreationOperation(request *http.Request) (*http.Response, error) {
|
||||
body, err := utils.GetRequestAsMap(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isSecretRepresentPrivateRegistry(body) {
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
err = utils.RewriteRequest(request, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretListOperation(request *http.Request) (*http.Response, error) {
|
||||
response, err := transport.executeKubernetesRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isAdmin, err := security.IsAdmin(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
body, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := utils.GetArrayObject(body, "items")
|
||||
|
||||
if items == nil {
|
||||
utils.RewriteResponse(response, body, response.StatusCode)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
filteredItems := []interface{}{}
|
||||
for _, item := range items {
|
||||
itemObj := item.(map[string]interface{})
|
||||
if !isSecretRepresentPrivateRegistry(itemObj) {
|
||||
filteredItems = append(filteredItems, item)
|
||||
}
|
||||
}
|
||||
|
||||
body["items"] = filteredItems
|
||||
|
||||
utils.RewriteResponse(response, body, response.StatusCode)
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretInspectOperation(request *http.Request) (*http.Response, error) {
|
||||
response, err := transport.executeKubernetesRequest(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
isAdmin, err := security.IsAdmin(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isAdmin {
|
||||
return response, nil
|
||||
}
|
||||
|
||||
body, err := utils.GetResponseAsJSONObject(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isSecretRepresentPrivateRegistry(body) {
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
err = utils.RewriteResponse(response, body, response.StatusCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretUpdateOperation(request *http.Request) (*http.Response, error) {
|
||||
body, err := utils.GetRequestAsMap(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isSecretRepresentPrivateRegistry(body) {
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
err = utils.RewriteRequest(request, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxySecretDeleteOperation(request *http.Request, namespace string) (*http.Response, error) {
|
||||
kcl, err := transport.k8sClientFactory.GetKubeClient(transport.endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretName := path.Base(request.RequestURI)
|
||||
|
||||
isRegistry, err := kcl.IsRegistrySecret(namespace, secretName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isRegistry {
|
||||
return utils.WriteAccessDeniedResponse()
|
||||
}
|
||||
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
|
||||
func isSecretRepresentPrivateRegistry(secret map[string]interface{}) bool {
|
||||
if secret["type"].(string) != string(v1.SecretTypeDockerConfigJson) {
|
||||
return false
|
||||
}
|
||||
|
||||
metadata := utils.GetJSONObject(secret, "metadata")
|
||||
annotations := utils.GetJSONObject(metadata, "annotations")
|
||||
_, ok := annotations[privateregistries.RegistryIDLabel]
|
||||
|
||||
return ok
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"sync"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"io/ioutil"
|
||||
)
|
||||
|
||||
const defaultServiceAccountTokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||
@@ -13,7 +11,6 @@ type tokenManager struct {
|
||||
tokenCache *tokenCache
|
||||
kubecli portainer.KubeClient
|
||||
dataStore portainer.DataStore
|
||||
mutex sync.Mutex
|
||||
adminToken string
|
||||
}
|
||||
|
||||
@@ -25,7 +22,6 @@ func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore
|
||||
tokenCache: cache,
|
||||
kubecli: kubecli,
|
||||
dataStore: dataStore,
|
||||
mutex: sync.Mutex{},
|
||||
adminToken: "",
|
||||
}
|
||||
|
||||
@@ -41,13 +37,13 @@ func NewTokenManager(kubecli portainer.KubeClient, dataStore portainer.DataStore
|
||||
return tokenManager, nil
|
||||
}
|
||||
|
||||
func (manager *tokenManager) getAdminServiceAccountToken() string {
|
||||
func (manager *tokenManager) GetAdminServiceAccountToken() string {
|
||||
return manager.adminToken
|
||||
}
|
||||
|
||||
func (manager *tokenManager) getUserServiceAccountToken(userID int) (string, error) {
|
||||
manager.mutex.Lock()
|
||||
defer manager.mutex.Unlock()
|
||||
func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) {
|
||||
manager.tokenCache.mutex.Lock()
|
||||
defer manager.tokenCache.mutex.Unlock()
|
||||
|
||||
token, ok := manager.tokenCache.getToken(userID)
|
||||
if !ok {
|
||||
@@ -61,7 +57,13 @@ func (manager *tokenManager) getUserServiceAccountToken(userID int) (string, err
|
||||
teamIds = append(teamIds, int(membership.TeamID))
|
||||
}
|
||||
|
||||
err = manager.kubecli.SetupUserServiceAccount(userID, teamIds)
|
||||
endpoint, err := manager.dataStore.Endpoint().Endpoint(endpointID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
restrictDefaultNamespace := endpoint.Kubernetes.Configuration.RestrictDefaultNamespace
|
||||
err = manager.kubecli.SetupUserServiceAccount(userID, teamIds, restrictDefaultNamespace)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/orcaman/concurrent-map"
|
||||
)
|
||||
@@ -14,6 +15,7 @@ type (
|
||||
|
||||
tokenCache struct {
|
||||
userTokenCache cmap.ConcurrentMap
|
||||
mutex sync.Mutex
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,6 +37,18 @@ func (manager *TokenCacheManager) CreateTokenCache(endpointID int) *tokenCache {
|
||||
return tokenCache
|
||||
}
|
||||
|
||||
// GetOrCreateTokenCache will get the tokenCache from the manager map of caches if it exists,
|
||||
// otherwise it will create a new tokenCache object, associate it to the manager map of caches
|
||||
// and return a pointer to that tokenCache instance.
|
||||
func (manager *TokenCacheManager) GetOrCreateTokenCache(endpointID int) *tokenCache {
|
||||
key := strconv.Itoa(endpointID)
|
||||
if epCache, ok := manager.tokenCaches.Get(key); ok {
|
||||
return epCache.(*tokenCache)
|
||||
}
|
||||
|
||||
return manager.CreateTokenCache(endpointID)
|
||||
}
|
||||
|
||||
// RemoveUserFromCache will ensure that the specific userID is removed from all registered caches.
|
||||
func (manager *TokenCacheManager) RemoveUserFromCache(userID int) {
|
||||
for cache := range manager.tokenCaches.IterBuffered() {
|
||||
@@ -45,6 +59,7 @@ func (manager *TokenCacheManager) RemoveUserFromCache(userID int) {
|
||||
func newTokenCache() *tokenCache {
|
||||
return &tokenCache{
|
||||
userTokenCache: cmap.New(),
|
||||
mutex: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,153 +2,107 @@ package kubernetes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/crypto"
|
||||
)
|
||||
|
||||
type (
|
||||
localTransport struct {
|
||||
httpTransport *http.Transport
|
||||
tokenManager *tokenManager
|
||||
endpointIdentifier portainer.EndpointID
|
||||
}
|
||||
|
||||
agentTransport struct {
|
||||
dataStore portainer.DataStore
|
||||
httpTransport *http.Transport
|
||||
tokenManager *tokenManager
|
||||
signatureService portainer.DigitalSignatureService
|
||||
endpointIdentifier portainer.EndpointID
|
||||
}
|
||||
|
||||
edgeTransport struct {
|
||||
dataStore portainer.DataStore
|
||||
httpTransport *http.Transport
|
||||
tokenManager *tokenManager
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
endpointIdentifier portainer.EndpointID
|
||||
}
|
||||
)
|
||||
|
||||
// NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API
|
||||
func NewLocalTransport(tokenManager *tokenManager) (*localTransport, error) {
|
||||
config, err := crypto.CreateTLSConfigurationFromBytes(nil, nil, nil, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
transport := &localTransport{
|
||||
httpTransport: &http.Transport{
|
||||
TLSClientConfig: config,
|
||||
},
|
||||
tokenManager: tokenManager,
|
||||
}
|
||||
|
||||
return transport, nil
|
||||
type baseTransport struct {
|
||||
httpTransport *http.Transport
|
||||
tokenManager *tokenManager
|
||||
endpoint *portainer.Endpoint
|
||||
k8sClientFactory *cli.ClientFactory
|
||||
dataStore portainer.DataStore
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *localTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
|
||||
func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *baseTransport {
|
||||
return &baseTransport{
|
||||
httpTransport: httpTransport,
|
||||
tokenManager: tokenManager,
|
||||
endpoint: endpoint,
|
||||
k8sClientFactory: k8sClientFactory,
|
||||
dataStore: dataStore,
|
||||
}
|
||||
}
|
||||
|
||||
// #region KUBERNETES PROXY
|
||||
|
||||
// proxyKubernetesRequest intercepts a Kubernetes API request and apply logic based
|
||||
// on the requested operation.
|
||||
func (transport *baseTransport) proxyKubernetesRequest(request *http.Request) (*http.Response, error) {
|
||||
apiVersionRe := regexp.MustCompile(`^(/kubernetes)?/api/v[0-9](\.[0-9])?`)
|
||||
requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "")
|
||||
|
||||
switch {
|
||||
case strings.EqualFold(requestPath, "/namespaces"):
|
||||
return transport.executeKubernetesRequest(request)
|
||||
case strings.HasPrefix(requestPath, "/namespaces"):
|
||||
return transport.proxyNamespacedRequest(request, requestPath)
|
||||
default:
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fullRequestPath string) (*http.Response, error) {
|
||||
requestPath := strings.TrimPrefix(fullRequestPath, "/namespaces/")
|
||||
split := strings.SplitN(requestPath, "/", 2)
|
||||
namespace := split[0]
|
||||
|
||||
requestPath = ""
|
||||
if len(split) > 1 {
|
||||
requestPath = split[1]
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(requestPath, "secrets"):
|
||||
return transport.proxySecretRequest(request, namespace, requestPath)
|
||||
case requestPath == "" && request.Method == "DELETE":
|
||||
return transport.proxyNamespaceDeleteOperation(request, namespace)
|
||||
default:
|
||||
return transport.executeKubernetesRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) {
|
||||
|
||||
resp, err := transport.httpTransport.RoundTrip(request)
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region ROUND TRIP
|
||||
|
||||
func (transport *baseTransport) prepareRoundTrip(request *http.Request) (string, error) {
|
||||
token, err := transport.getRoundTripToken(request, transport.tokenManager)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
return transport.httpTransport.RoundTrip(request)
|
||||
}
|
||||
|
||||
// NewAgentTransport returns a new transport that can be used to send signed requests to a Portainer agent
|
||||
func NewAgentTransport(datastore portainer.DataStore, signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager) *agentTransport {
|
||||
transport := &agentTransport{
|
||||
dataStore: datastore,
|
||||
httpTransport: &http.Transport{
|
||||
TLSClientConfig: tlsConfig,
|
||||
},
|
||||
tokenManager: tokenManager,
|
||||
signatureService: signatureService,
|
||||
}
|
||||
|
||||
return transport
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/v2") {
|
||||
decorateAgentRequest(request, transport.dataStore)
|
||||
}
|
||||
|
||||
signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentPublicKeyHeader, transport.signatureService.EncodedPublicKey())
|
||||
request.Header.Set(portainer.PortainerAgentSignatureHeader, signature)
|
||||
|
||||
return transport.httpTransport.RoundTrip(request)
|
||||
func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return transport.proxyKubernetesRequest(request)
|
||||
}
|
||||
|
||||
// NewEdgeTransport returns a new transport that can be used to send signed requests to a Portainer Edge agent
|
||||
func NewEdgeTransport(datastore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService, endpointIdentifier portainer.EndpointID, tokenManager *tokenManager) *edgeTransport {
|
||||
transport := &edgeTransport{
|
||||
dataStore: datastore,
|
||||
httpTransport: &http.Transport{},
|
||||
tokenManager: tokenManager,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
endpointIdentifier: endpointIdentifier,
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
// RoundTrip is the implementation of the the http.RoundTripper interface
|
||||
func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
token, err := getRoundTripToken(request, transport.tokenManager, transport.endpointIdentifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token)
|
||||
|
||||
if strings.HasPrefix(request.URL.Path, "/v2") {
|
||||
decorateAgentRequest(request, transport.dataStore)
|
||||
}
|
||||
|
||||
response, err := transport.httpTransport.RoundTrip(request)
|
||||
|
||||
if err == nil {
|
||||
transport.reverseTunnelService.SetTunnelStatusToActive(transport.endpointIdentifier)
|
||||
} else {
|
||||
transport.reverseTunnelService.SetTunnelStatusToIdle(transport.endpointIdentifier)
|
||||
}
|
||||
|
||||
return response, err
|
||||
}
|
||||
|
||||
func getRoundTripToken(
|
||||
request *http.Request,
|
||||
tokenManager *tokenManager,
|
||||
endpointIdentifier portainer.EndpointID,
|
||||
) (string, error) {
|
||||
func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) {
|
||||
tokenData, err := security.RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -156,9 +110,9 @@ func getRoundTripToken(
|
||||
|
||||
var token string
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
token = tokenManager.getAdminServiceAccountToken()
|
||||
token = tokenManager.GetAdminServiceAccountToken()
|
||||
} else {
|
||||
token, err = tokenManager.getUserServiceAccountToken(int(tokenData.ID))
|
||||
token, err = tokenManager.GetUserServiceAccountToken(int(tokenData.ID), transport.endpoint.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed retrieving service account token: %v", err)
|
||||
return "", err
|
||||
@@ -168,32 +122,56 @@ func getRoundTripToken(
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region DECORATE FUNCTIONS
|
||||
|
||||
func decorateAgentRequest(r *http.Request, dataStore portainer.DataStore) error {
|
||||
requestPath := strings.TrimPrefix(r.URL.Path, "/v2")
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(requestPath, "/dockerhub"):
|
||||
decorateAgentDockerHubRequest(r, dataStore)
|
||||
return decorateAgentDockerHubRequest(r, dataStore)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decorateAgentDockerHubRequest(r *http.Request, dataStore portainer.DataStore) error {
|
||||
dockerhub, err := dataStore.DockerHub().DockerHub()
|
||||
requestPath, registryIdString := path.Split(r.URL.Path)
|
||||
|
||||
registryID, err := strconv.Atoi(registryIdString)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("missing registry id: %w", err)
|
||||
}
|
||||
|
||||
newBody, err := json.Marshal(dockerhub)
|
||||
r.URL.Path = strings.TrimSuffix(requestPath, "/")
|
||||
|
||||
registry := &portainer.Registry{
|
||||
Type: portainer.DockerHubRegistry,
|
||||
}
|
||||
|
||||
if registryID != 0 {
|
||||
registry, err = dataStore.Registry().Registry(portainer.RegistryID(registryID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed fetching registry: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if registry.Type != portainer.DockerHubRegistry {
|
||||
return errors.New("invalid registry type")
|
||||
}
|
||||
|
||||
newBody, err := json.Marshal(registry)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed marshaling registry: %w", err)
|
||||
}
|
||||
|
||||
r.Method = http.MethodPost
|
||||
|
||||
r.Body = ioutil.NopCloser(bytes.NewReader(newBody))
|
||||
r.ContentLength = int64(len(newBody))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package responseutils
|
||||
|
||||
// GetJSONObject will extract an object from a specific property of another JSON object.
|
||||
// Returns nil if nothing is associated to the specified key.
|
||||
func GetJSONObject(jsonObject map[string]interface{}, property string) map[string]interface{} {
|
||||
object := jsonObject[property]
|
||||
if object != nil {
|
||||
return object.(map[string]interface{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
91
api/http/proxy/factory/utils/json.go
Normal file
91
api/http/proxy/factory/utils/json.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// GetJSONObject will extract an object from a specific property of another JSON object.
|
||||
// Returns nil if nothing is associated to the specified key.
|
||||
func GetJSONObject(jsonObject map[string]interface{}, property string) map[string]interface{} {
|
||||
object := jsonObject[property]
|
||||
if object != nil {
|
||||
return object.(map[string]interface{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetArrayObject will extract an array from a specific property of another JSON object.
|
||||
// Returns nil if nothing is associated to the specified key.
|
||||
func GetArrayObject(jsonObject map[string]interface{}, property string) []interface{} {
|
||||
object := jsonObject[property]
|
||||
if object != nil {
|
||||
return object.([]interface{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getBody(body io.ReadCloser, contentType string, isGzip bool) (interface{}, error) {
|
||||
if body == nil {
|
||||
return nil, errors.New("unable to parse response: empty response body")
|
||||
}
|
||||
|
||||
reader := body
|
||||
|
||||
if isGzip {
|
||||
gzipReader, err := gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader = gzipReader
|
||||
}
|
||||
|
||||
defer reader.Close()
|
||||
|
||||
bodyBytes, err := ioutil.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
err = unmarshal(contentType, bodyBytes, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func marshal(contentType string, data interface{}) ([]byte, error) {
|
||||
switch contentType {
|
||||
case "application/yaml":
|
||||
return yaml.Marshal(data)
|
||||
case "application/json", "":
|
||||
return json.Marshal(data)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("content type is not supported for marshaling: %s", contentType)
|
||||
}
|
||||
|
||||
func unmarshal(contentType string, body []byte, returnBody interface{}) error {
|
||||
switch contentType {
|
||||
case "application/yaml":
|
||||
return yaml.Unmarshal(body, returnBody)
|
||||
case "application/json", "":
|
||||
return json.Unmarshal(body, returnBody)
|
||||
}
|
||||
|
||||
return fmt.Errorf("content type is not supported for unmarshaling: %s", contentType)
|
||||
}
|
||||
45
api/http/proxy/factory/utils/request.go
Normal file
45
api/http/proxy/factory/utils/request.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GetRequestAsMap returns the response content as a generic JSON object
|
||||
func GetRequestAsMap(request *http.Request) (map[string]interface{}, error) {
|
||||
data, err := getRequestBody(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data.(map[string]interface{}), nil
|
||||
}
|
||||
|
||||
// RewriteRequest will replace the existing request body with the one specified
|
||||
// in parameters
|
||||
func RewriteRequest(request *http.Request, newData interface{}) error {
|
||||
data, err := marshal(getContentType(request.Header), newData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := ioutil.NopCloser(bytes.NewReader(data))
|
||||
|
||||
request.Body = body
|
||||
request.ContentLength = int64(len(data))
|
||||
|
||||
if request.Header == nil {
|
||||
request.Header = make(http.Header)
|
||||
}
|
||||
request.Header.Set("Content-Length", strconv.Itoa(len(data)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getRequestBody(request *http.Request) (interface{}, error) {
|
||||
isGzip := request.Header.Get("Content-Encoding") == "gzip"
|
||||
|
||||
return getBody(request.Body, getContentType(request.Header), isGzip)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package responseutils
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
@@ -13,7 +11,7 @@ import (
|
||||
|
||||
// GetResponseAsJSONObject returns the response content as a generic JSON object
|
||||
func GetResponseAsJSONObject(response *http.Response) (map[string]interface{}, error) {
|
||||
responseData, err := getResponseBodyAsGenericJSON(response)
|
||||
responseData, err := getResponseBody(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -24,7 +22,7 @@ func GetResponseAsJSONObject(response *http.Response) (map[string]interface{}, e
|
||||
|
||||
// GetResponseAsJSONArray returns the response content as an array of generic JSON object
|
||||
func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
|
||||
responseData, err := getResponseBodyAsGenericJSON(response)
|
||||
responseData, err := getResponseBody(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -44,72 +42,54 @@ func GetResponseAsJSONArray(response *http.Response) ([]interface{}, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func getResponseBodyAsGenericJSON(response *http.Response) (interface{}, error) {
|
||||
if response.Body == nil {
|
||||
return nil, errors.New("unable to parse response: empty response body")
|
||||
}
|
||||
|
||||
reader := response.Body
|
||||
|
||||
if response.Header.Get("Content-Encoding") == "gzip" {
|
||||
response.Header.Del("Content-Encoding")
|
||||
gzipReader, err := gzip.NewReader(response.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reader = gzipReader
|
||||
}
|
||||
|
||||
defer reader.Close()
|
||||
|
||||
var data interface{}
|
||||
body, err := ioutil.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type dockerErrorResponse struct {
|
||||
type errorResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// WriteAccessDeniedResponse will create a new access denied response
|
||||
func WriteAccessDeniedResponse() (*http.Response, error) {
|
||||
response := &http.Response{}
|
||||
err := RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden)
|
||||
err := RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden)
|
||||
return response, err
|
||||
}
|
||||
|
||||
// RewriteAccessDeniedResponse will overwrite the existing response with an access denied response
|
||||
func RewriteAccessDeniedResponse(response *http.Response) error {
|
||||
return RewriteResponse(response, dockerErrorResponse{Message: "access denied to resource"}, http.StatusForbidden)
|
||||
return RewriteResponse(response, errorResponse{Message: "access denied to resource"}, http.StatusForbidden)
|
||||
}
|
||||
|
||||
// RewriteResponse will replace the existing response body and status code with the one specified
|
||||
// in parameters
|
||||
func RewriteResponse(response *http.Response, newResponseData interface{}, statusCode int) error {
|
||||
jsonData, err := json.Marshal(newResponseData)
|
||||
data, err := marshal(getContentType(response.Header), newResponseData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := ioutil.NopCloser(bytes.NewReader(jsonData))
|
||||
body := ioutil.NopCloser(bytes.NewReader(data))
|
||||
|
||||
response.StatusCode = statusCode
|
||||
response.Body = body
|
||||
response.ContentLength = int64(len(jsonData))
|
||||
response.ContentLength = int64(len(data))
|
||||
|
||||
if response.Header == nil {
|
||||
response.Header = make(http.Header)
|
||||
}
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(jsonData)))
|
||||
response.Header.Set("Content-Length", strconv.Itoa(len(data)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getResponseBody(response *http.Response) (interface{}, error) {
|
||||
isGzip := response.Header.Get("Content-Encoding") == "gzip"
|
||||
|
||||
if isGzip {
|
||||
response.Header.Del("Content-Encoding")
|
||||
}
|
||||
|
||||
return getBody(response.Body, getContentType(response.Header), isGzip)
|
||||
}
|
||||
|
||||
func getContentType(headers http.Header) string {
|
||||
return headers.Get("Content-type")
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// IsAdmin returns true if the logged-in user is an admin
|
||||
func IsAdmin(request *http.Request) (bool, error) {
|
||||
tokenData, err := RetrieveTokenData(request)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return tokenData.Role == portainer.AdministratorRole, nil
|
||||
}
|
||||
|
||||
// AuthorizedResourceControlAccess checks whether the user can alter an existing resource control.
|
||||
func AuthorizedResourceControlAccess(resourceControl *portainer.ResourceControl, context *RestrictedRequestContext) bool {
|
||||
if context.IsAdmin || resourceControl.Public {
|
||||
@@ -95,9 +107,9 @@ func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedReques
|
||||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams of the endpoint and the associated group.
|
||||
func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
groupAccess := authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
|
||||
groupAccess := AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
|
||||
if !groupAccess {
|
||||
return authorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies)
|
||||
return AuthorizedAccess(userID, memberships, endpoint.UserAccessPolicies, endpoint.TeamAccessPolicies)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -106,17 +118,24 @@ func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *porta
|
||||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams.
|
||||
func authorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
return authorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
|
||||
return AuthorizedAccess(userID, memberships, endpointGroup.UserAccessPolicies, endpointGroup.TeamAccessPolicies)
|
||||
}
|
||||
|
||||
// AuthorizedRegistryAccess ensure that the user can access the specified registry.
|
||||
// It will check if the user is part of the authorized users or part of a team that is
|
||||
// listed in the authorized teams.
|
||||
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
|
||||
return authorizedAccess(userID, memberships, registry.UserAccessPolicies, registry.TeamAccessPolicies)
|
||||
// listed in the authorized teams for a specified endpoint,
|
||||
func AuthorizedRegistryAccess(registry *portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) bool {
|
||||
if user.Role == portainer.AdministratorRole {
|
||||
return true
|
||||
}
|
||||
|
||||
registryEndpointAccesses := registry.RegistryAccesses[endpointID]
|
||||
|
||||
return AuthorizedAccess(user.ID, teamMemberships, registryEndpointAccesses.UserAccessPolicies, registryEndpointAccesses.TeamAccessPolicies)
|
||||
}
|
||||
|
||||
func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool {
|
||||
// AuthorizedAccess verifies the userID or memberships are authorized to use an object per the supplied access policies
|
||||
func AuthorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, userAccessPolicies portainer.UserAccessPolicies, teamAccessPolicies portainer.TeamAccessPolicies) bool {
|
||||
_, userAccess := userAccessPolicies[userID]
|
||||
if userAccess {
|
||||
return true
|
||||
|
||||
@@ -128,31 +128,6 @@ func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request,
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegistryAccess retrieves the JWT token from the request context and verifies
|
||||
// that the user can access the specified registry.
|
||||
// An error is returned when access is denied.
|
||||
func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portainer.Registry) error {
|
||||
tokenData, err := RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return nil
|
||||
}
|
||||
|
||||
memberships, err := bouncer.dataStore.TeamMembership().TeamMembershipsByUserID(tokenData.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !AuthorizedRegistryAccess(registry, tokenData.ID, memberships) {
|
||||
return httperrors.ErrEndpointAccessDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlers are applied backwards to the incoming request:
|
||||
// - add secure handlers to the response
|
||||
// - parse the JWT token and put it into the http context.
|
||||
@@ -213,7 +188,7 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h
|
||||
return
|
||||
}
|
||||
|
||||
ctx := storeRestrictedRequestContext(r, requestContext)
|
||||
ctx := StoreRestrictedRequestContext(r, requestContext)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -33,9 +33,9 @@ func RetrieveTokenData(request *http.Request) (*portainer.TokenData, error) {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
// storeRestrictedRequestContext stores a RestrictedRequestContext object inside the request context
|
||||
// StoreRestrictedRequestContext stores a RestrictedRequestContext object inside the request context
|
||||
// and returns the enhanced context.
|
||||
func storeRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context {
|
||||
func StoreRestrictedRequestContext(request *http.Request, requestContext *RestrictedRequestContext) context.Context {
|
||||
return context.WithValue(request.Context(), contextRestrictedRequest, requestContext)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// FilterUserTeams filters teams based on user role.
|
||||
@@ -64,15 +64,16 @@ func FilterUsers(users []portainer.User, context *RestrictedRequestContext) []po
|
||||
|
||||
// FilterRegistries filters registries based on user role and team memberships.
|
||||
// Non administrator users only have access to authorized registries.
|
||||
func FilterRegistries(registries []portainer.Registry, context *RestrictedRequestContext) []portainer.Registry {
|
||||
filteredRegistries := registries
|
||||
if !context.IsAdmin {
|
||||
filteredRegistries = make([]portainer.Registry, 0)
|
||||
func FilterRegistries(registries []portainer.Registry, user *portainer.User, teamMemberships []portainer.TeamMembership, endpointID portainer.EndpointID) []portainer.Registry {
|
||||
if user.Role == portainer.AdministratorRole {
|
||||
return registries
|
||||
}
|
||||
|
||||
for _, registry := range registries {
|
||||
if AuthorizedRegistryAccess(®istry, context.UserID, context.UserMemberships) {
|
||||
filteredRegistries = append(filteredRegistries, registry)
|
||||
}
|
||||
filteredRegistries := []portainer.Registry{}
|
||||
|
||||
for _, registry := range registries {
|
||||
if AuthorizedRegistryAccess(®istry, user, teamMemberships, endpointID) {
|
||||
filteredRegistries = append(filteredRegistries, registry)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/portainer/portainer/api/http/handler/auth"
|
||||
"github.com/portainer/portainer/api/http/handler/backup"
|
||||
"github.com/portainer/portainer/api/http/handler/customtemplates"
|
||||
"github.com/portainer/portainer/api/http/handler/dockerhub"
|
||||
"github.com/portainer/portainer/api/http/handler/edgegroups"
|
||||
"github.com/portainer/portainer/api/http/handler/edgejobs"
|
||||
"github.com/portainer/portainer/api/http/handler/edgestacks"
|
||||
@@ -111,9 +110,6 @@ func (server *Server) Start() error {
|
||||
customTemplatesHandler.FileService = server.FileService
|
||||
customTemplatesHandler.GitService = server.GitService
|
||||
|
||||
var dockerHubHandler = dockerhub.NewHandler(requestBouncer)
|
||||
dockerHubHandler.DataStore = server.DataStore
|
||||
|
||||
var edgeGroupsHandler = edgegroups.NewHandler(requestBouncer)
|
||||
edgeGroupsHandler.DataStore = server.DataStore
|
||||
|
||||
@@ -135,6 +131,7 @@ func (server *Server) Start() error {
|
||||
endpointHandler.FileService = server.FileService
|
||||
endpointHandler.ProxyManager = server.ProxyManager
|
||||
endpointHandler.SnapshotService = server.SnapshotService
|
||||
endpointHandler.K8sClientFactory = server.KubernetesClientFactory
|
||||
endpointHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
endpointHandler.ComposeStackManager = server.ComposeStackManager
|
||||
endpointHandler.AuthorizationService = server.AuthorizationService
|
||||
@@ -161,6 +158,7 @@ func (server *Server) Start() error {
|
||||
registryHandler.DataStore = server.DataStore
|
||||
registryHandler.FileService = server.FileService
|
||||
registryHandler.ProxyManager = server.ProxyManager
|
||||
registryHandler.K8sClientFactory = server.KubernetesClientFactory
|
||||
|
||||
var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer)
|
||||
resourceControlHandler.DataStore = server.DataStore
|
||||
@@ -204,7 +202,7 @@ func (server *Server) Start() error {
|
||||
userHandler.DataStore = server.DataStore
|
||||
userHandler.CryptoService = server.CryptoService
|
||||
|
||||
var websocketHandler = websocket.NewHandler(requestBouncer)
|
||||
var websocketHandler = websocket.NewHandler(server.KubernetesTokenCacheManager, requestBouncer)
|
||||
websocketHandler.DataStore = server.DataStore
|
||||
websocketHandler.SignatureService = server.SignatureService
|
||||
websocketHandler.ReverseTunnelService = server.ReverseTunnelService
|
||||
@@ -219,7 +217,6 @@ func (server *Server) Start() error {
|
||||
AuthHandler: authHandler,
|
||||
BackupHandler: backupHandler,
|
||||
CustomTemplatesHandler: customTemplatesHandler,
|
||||
DockerHubHandler: dockerHubHandler,
|
||||
EdgeGroupsHandler: edgeGroupsHandler,
|
||||
EdgeJobsHandler: edgeJobsHandler,
|
||||
EdgeStacksHandler: edgeStacksHandler,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
package authorization
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
)
|
||||
|
||||
// Service represents a service used to
|
||||
// update authorizations associated to a user or team.
|
||||
type Service struct {
|
||||
dataStore portainer.DataStore
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
dataStore portainer.DataStore
|
||||
K8sClientFactory *cli.ClientFactory
|
||||
}
|
||||
|
||||
// NewService returns a point to a new Service instance.
|
||||
@@ -140,6 +140,7 @@ func DefaultEndpointAuthorizationsForEndpointAdministratorRole() portainer.Autho
|
||||
portainer.OperationDockerAgentUndefined: true,
|
||||
portainer.OperationPortainerResourceControlCreate: true,
|
||||
portainer.OperationPortainerResourceControlUpdate: true,
|
||||
portainer.OperationPortainerRegistryUpdateAccess: true,
|
||||
portainer.OperationPortainerStackList: true,
|
||||
portainer.OperationPortainerStackInspect: true,
|
||||
portainer.OperationPortainerStackFile: true,
|
||||
|
||||
@@ -9,8 +9,8 @@ func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment
|
||||
}
|
||||
|
||||
// IsDocketEndpoint returns true if this is a docker endpoint
|
||||
func IsDocketEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
// IsDockerEndpoint returns true if this is a docker endpoint
|
||||
func IsDockerEndpoint(endpoint *portainer.Endpoint) bool {
|
||||
return endpoint.Type == portainer.DockerEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnDockerEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnDockerEnvironment
|
||||
|
||||
@@ -2,6 +2,7 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
@@ -187,3 +188,27 @@ func (service *Service) snapshotEndpoints() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchDockerID fetches info.Swarm.Cluster.ID if endpoint is swarm and info.ID otherwise
|
||||
func FetchDockerID(snapshot portainer.DockerSnapshot) (string, error) {
|
||||
info, done := snapshot.SnapshotRaw.Info.(map[string]interface{})
|
||||
if !done {
|
||||
return "", errors.New("failed getting snapshot info")
|
||||
}
|
||||
|
||||
if !snapshot.Swarm {
|
||||
return info["ID"].(string), nil
|
||||
}
|
||||
|
||||
if info["Swarm"] == nil {
|
||||
return "", errors.New("swarm endpoint is missing swarm info snapshot")
|
||||
}
|
||||
|
||||
swarmInfo := info["Swarm"].(map[string]interface{})
|
||||
if swarmInfo["Cluster"] == nil {
|
||||
return "", errors.New("swarm endpoint is missing cluster info snapshot")
|
||||
}
|
||||
|
||||
clusterInfo := swarmInfo["Cluster"].(map[string]interface{})
|
||||
return clusterInfo["ID"].(string), nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
)
|
||||
|
||||
type datastore struct {
|
||||
dockerHub portainer.DockerHubService
|
||||
customTemplate portainer.CustomTemplateService
|
||||
edgeGroup portainer.EdgeGroupService
|
||||
edgeJob portainer.EdgeJobService
|
||||
@@ -37,7 +36,6 @@ func (d *datastore) CheckCurrentEdition() error { retur
|
||||
func (d *datastore) IsNew() bool { return false }
|
||||
func (d *datastore) MigrateData(force bool) error { return nil }
|
||||
func (d *datastore) RollbackToCE() error { return nil }
|
||||
func (d *datastore) DockerHub() portainer.DockerHubService { return d.dockerHub }
|
||||
func (d *datastore) CustomTemplate() portainer.CustomTemplateService { return d.customTemplate }
|
||||
func (d *datastore) EdgeGroup() portainer.EdgeGroupService { return d.edgeGroup }
|
||||
func (d *datastore) EdgeJob() portainer.EdgeJobService { return d.edgeJob }
|
||||
|
||||
@@ -3,27 +3,49 @@ package cli
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
type (
|
||||
namespaceAccessPolicies map[string]portainer.K8sNamespaceAccessPolicy
|
||||
)
|
||||
// NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace
|
||||
func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error {
|
||||
kcl.lock.Lock()
|
||||
defer kcl.lock.Unlock()
|
||||
|
||||
func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string) error {
|
||||
policies, err := kcl.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return errors.WithMessage(err, "failed to fetch access policies")
|
||||
}
|
||||
|
||||
delete(policies, ns)
|
||||
|
||||
return kcl.UpdateNamespaceAccessPolicies(policies)
|
||||
}
|
||||
|
||||
// GetNamespaceAccessPolicies gets the namespace access policies
|
||||
// from config maps in the portainer namespace
|
||||
func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) {
|
||||
configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{})
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return nil
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessData := configMap.Data[portainerConfigMapAccessPoliciesKey]
|
||||
|
||||
var accessPolicies namespaceAccessPolicies
|
||||
err = json.Unmarshal([]byte(accessData), &accessPolicies)
|
||||
var policies map[string]portainer.K8sNamespaceAccessPolicy
|
||||
err = json.Unmarshal([]byte(accessData), &policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, serviceAccountName string, restrictDefaultNamespace bool) error {
|
||||
accessPolicies, err := kcl.GetNamespaceAccessPolicies()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -34,20 +56,16 @@ func (kcl *KubeClient) setupNamespaceAccesses(userID int, teamIDs []int, service
|
||||
}
|
||||
|
||||
for _, namespace := range namespaces.Items {
|
||||
if namespace.Name == defaultNamespace {
|
||||
continue
|
||||
}
|
||||
|
||||
policies, ok := accessPolicies[namespace.Name]
|
||||
if !ok {
|
||||
err = kcl.removeNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name)
|
||||
if namespace.Name == defaultNamespace && !restrictDefaultNamespace {
|
||||
err = kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, defaultNamespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !hasUserAccessToNamespace(userID, teamIDs, policies) {
|
||||
policies, ok := accessPolicies[namespace.Name]
|
||||
if !ok || !hasUserAccessToNamespace(userID, teamIDs, policies) {
|
||||
err = kcl.removeNamespaceAccessForServiceAccount(serviceAccountName, namespace.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -80,28 +98,6 @@ func hasUserAccessToNamespace(userID int, teamIDs []int, policies portainer.K8sN
|
||||
return false
|
||||
}
|
||||
|
||||
// GetNamespaceAccessPolicies gets the namespace access policies
|
||||
// from config maps in the portainer namespace
|
||||
func (kcl *KubeClient) GetNamespaceAccessPolicies() (map[string]portainer.K8sNamespaceAccessPolicy, error) {
|
||||
configMap, err := kcl.cli.CoreV1().ConfigMaps(portainerNamespace).Get(portainerConfigMapName, metav1.GetOptions{})
|
||||
if k8serrors.IsNotFound(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessData := configMap.Data[portainerConfigMapAccessPoliciesKey]
|
||||
|
||||
var policies map[string]portainer.K8sNamespaceAccessPolicy
|
||||
err = json.Unmarshal([]byte(accessData), &policies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// UpdateNamespaceAccessPolicies updates the namespace access policies
|
||||
func (kcl *KubeClient) UpdateNamespaceAccessPolicies(accessPolicies map[string]portainer.K8sNamespaceAccessPolicy) error {
|
||||
data, err := json.Marshal(accessPolicies)
|
||||
|
||||
68
api/kubernetes/cli/access_test.go
Normal file
68
api/kubernetes/cli/access_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
ktypes "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
kfake "k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func Test_NamespaceAccessPoliciesDeleteNamespace_updatesPortainerConfig_whenConfigExists(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
namespaceToDelete string
|
||||
expectedConfig map[string]portainer.K8sNamespaceAccessPolicy
|
||||
}{
|
||||
{
|
||||
name: "doesn't change config, when designated namespace absent",
|
||||
namespaceToDelete: "missing-namespace",
|
||||
expectedConfig: map[string]portainer.K8sNamespaceAccessPolicy{
|
||||
"ns1": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
"ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "removes designated namespace from config, when namespace is present",
|
||||
namespaceToDelete: "ns2",
|
||||
expectedConfig: map[string]portainer.K8sNamespaceAccessPolicy{
|
||||
"ns1": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range testcases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
k := &KubeClient{
|
||||
cli: kfake.NewSimpleClientset(),
|
||||
instanceID: "instance",
|
||||
lock: &sync.Mutex{},
|
||||
}
|
||||
|
||||
config := &ktypes.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: portainerConfigMapName,
|
||||
Namespace: portainerNamespace,
|
||||
},
|
||||
Data: map[string]string{
|
||||
"NamespaceAccessPolicies": `{"ns1":{"UserAccessPolicies":{"2":{"RoleId":0}}}, "ns2":{"UserAccessPolicies":{"2":{"RoleId":0}}}}`,
|
||||
},
|
||||
}
|
||||
_, err := k.cli.CoreV1().ConfigMaps(portainerNamespace).Create(config)
|
||||
assert.NoError(t, err, "failed to create a portainer config")
|
||||
defer func() {
|
||||
k.cli.CoreV1().ConfigMaps(portainerNamespace).Delete(portainerConfigMapName, nil)
|
||||
}()
|
||||
|
||||
err = k.NamespaceAccessPoliciesDeleteNamespace(test.namespaceToDelete)
|
||||
assert.NoError(t, err, "failed to delete namespace")
|
||||
|
||||
policies, err := k.GetNamespaceAccessPolicies()
|
||||
assert.NoError(t, err, "failed to fetch policies")
|
||||
assert.Equal(t, test.expectedConfig, policies)
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user