diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 811f71a04..221d4eb1d 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -28,17 +28,15 @@ Briefly describe the problem you are having in a few paragraphs. **Steps to reproduce the issue:** -1. -2. -3. +1. 2. 3. Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead? **Technical details:** -* Portainer version: -* Target Docker version (the host/cluster you manage): -* Platform (windows/linux): -* Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`): -* Target Swarm version (if applicable): -* Browser: +- Portainer version: +- Target Docker version (the host/cluster you manage): +- Platform (windows/linux): +- Command used to start Portainer (`docker run -p 9443:9443 portainer/portainer`): +- Target Swarm version (if applicable): +- Browser: diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md index c357d7190..1d950b9ac 100644 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -45,7 +45,7 @@ You can see how [here](https://documentation.portainer.io/archive/1.23.2/faq/#ho - Docker version (managed by Portainer): - Kubernetes version (managed by Portainer): - Platform (windows/linux): -- Command used to start Portainer (`docker run -p 9000:9000 portainer/portainer`): +- Command used to start Portainer (`docker run -p 9443:9443 portainer/portainer`): - Browser: - Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commerical setup. - Have you reviewed our technical documentation and knowledge base? Yes/No diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index fce5d3700..000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,54 +0,0 @@ -# Config for Stalebot, limited to only `issues` -only: issues - -# Issues config -issues: - daysUntilStale: 60 - daysUntilClose: 7 - - # Limit the number of actions per hour, from 1-30. Default is 30 - limitPerRun: 30 - - # Issues with these labels will never be considered stale - exemptLabels: - - kind/enhancement - - kind/question - - kind/style - - kind/workaround - - kind/refactor - - bug/need-confirmation - - bug/confirmed - - status/discuss - - # Only issues with all of these labels are checked if stale. Defaults to `[]` (disabled) - onlyLabels: [] - - # Set to true to ignore issues in a project (defaults to false) - exemptProjects: true - # Set to true to ignore issues in a milestone (defaults to false) - exemptMilestones: true - # Set to true to ignore issues with an assignee (defaults to false) - exemptAssignees: true - - # Label to use when marking an issue as stale - staleLabel: status/stale - - # Comment to post when marking an issue as stale. Set to `false` to disable - markComment: > - This issue has been marked as stale as it has not had recent activity, - it will be closed if no further activity occurs in the next 7 days. - If you believe that it has been incorrectly labelled as stale, - leave a comment and the label will be removed. - - # Comment to post when removing the stale label. - # unmarkComment: > - # Your comment here. - - # Comment to post when closing a stale issue. Set to `false` to disable - closeComment: > - Since no further activity has appeared on this issue it will be closed. - If you believe that it has been incorrectly closed, leave a comment - mentioning `ametdoohan`, `balasu` or `keverv` and one of our staff will then review the issue. - - Note - If it is an old bug report, make sure that it is reproduceable in the - latest version of Portainer as it may have already been fixed. diff --git a/.github/workflows/label-conflcts.yaml b/.github/workflows/label-conflcts.yaml new file mode 100644 index 000000000..3d101aa23 --- /dev/null +++ b/.github/workflows/label-conflcts.yaml @@ -0,0 +1,15 @@ +on: + push: + branches: + - develop + - 'release/**' +jobs: + triage: + runs-on: ubuntu-latest + steps: + - uses: mschilde/auto-label-merge-conflicts@master + with: + CONFLICT_LABEL_NAME: 'has conflicts' + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MAX_RETRIES: 5 + WAIT_MS: 5000 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..cf978446c --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,27 @@ +name: Close Stale Issues +on: + schedule: + - cron: '0 12 * * *' +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - uses: actions/stale@v4.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # Issue Config + days-before-issue-stale: 60 + days-before-issue-close: 7 + stale-issue-label: 'status/stale' + exempt-all-issue-milestones: true # Do not stale issues in a milestone + exempt-issue-labels: kind/enhancement, kind/style, kind/workaround, kind/refactor, bug/need-confirmation, bug/confirmed, status/discuss + stale-issue-message: 'This issue has been marked as stale as it has not had recent activity, it will be closed if no further activity occurs in the next 7 days. If you believe that it has been incorrectly labelled as stale, leave a comment and the label will be removed.' + close-issue-message: 'Since no further activity has appeared on this issue it will be closed. If you believe that it has been incorrectly closed, leave a comment mentioning `portainer/support` and one of our staff will then review the issue. Note - If it is an old bug report, make sure that it is reproduceable in the latest version of Portainer as it may have already been fixed.' + + # Pull Request Config + days-before-pr-stale: -1 # Do not stale pull request + days-before-pr-close: -1 # Do not close pull request diff --git a/.vscode.example/portainer.code-snippets b/.vscode.example/portainer.code-snippets index fefb732ce..fa3511098 100644 --- a/.vscode.example/portainer.code-snippets +++ b/.vscode.example/portainer.code-snippets @@ -163,5 +163,19 @@ "// @failure 500 \"Server error\"", "// @router /{id} [get]" ] + }, + "analytics": { + "prefix": "nlt", + "body": ["analytics-on", "analytics-category=\"$1\"", "analytics-event=\"$2\""], + "description": "analytics" + }, + "analytics-if": { + "prefix": "nltf", + "body": ["analytics-if=\"$1\""], + "description": "analytics" + }, + "analytics-metadata": { + "prefix": "nltm", + "body": "analytics-properties=\"{ metadata: { $1 } }\"" } } diff --git a/.vscode.example/settings.json b/.vscode.example/settings.json new file mode 100644 index 000000000..64be114a1 --- /dev/null +++ b/.vscode.example/settings.json @@ -0,0 +1,4 @@ +{ + "go.lintTool": "golangci-lint", + "go.lintFlags": ["--fast", "-E", "exportloopref"] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a9e336cf..8ef15bb22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,7 +91,7 @@ Then build and run the project: $ yarn start ``` -Portainer can now be accessed at . +Portainer can now be accessed at . Find more detailed steps at . diff --git a/README.md b/README.md index deb337483..a81a1702b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,14 @@

- +

-[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/) -[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer 'Image size') -[![Build Status](https://portainer.visualstudio.com/Portainer%20CI/_apis/build/status/Portainer%20CI?branchName=develop)](https://portainer.visualstudio.com/Portainer%20CI/_build/latest?definitionId=3&branchName=develop) -[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer) -[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](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. + +A fully supported version of Portainer is available for business use. Visit http://www.portainer.io to learn more ## Demo @@ -18,30 +16,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). @@ -51,6 +57,10 @@ For community support: You can find more information about Portainer's community - Here at Portainer, we believe in [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure) of security issues. If you have found a security issue, please report it to . +## WORK FOR US + +If you are a developer, and our code in this repo makes sense to you, we would love to hear from you. We are always on the hunt for awesome devs, either freelance or employed. Drop us a line to info@portainer.io with your details and we will be in touch. + ## Privacy **To make sure we focus our development effort in the right places we need to know which features get used most often. To give us this information we use [Matomo Analytics](https://matomo.org/), which is hosted in Germany and is fully GDPR compliant.** diff --git a/api/api-description.md b/api/api-description.md index 030687259..8c2465333 100644 --- a/api/api-description.md +++ b/api/api-description.md @@ -1,10 +1,10 @@ Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API. -Examples are available at https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8 +Examples are available at https://documentation.portainer.io/api/api-examples/ You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/). # Authentication -Most of the API endpoints require to be authenticated as well as some level of authorization to be used. +Most of the API environments(endpoints) require to be authenticated as well as some level of authorization to be used. Portainer API uses JSON Web Token to manage authentication and thus requires you to provide a token in the **Authorization** header of each request with the **Bearer** authentication mechanism. @@ -16,7 +16,7 @@ Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIs # Security -Each API endpoint has an associated access policy, it is documented in the description of each endpoint. +Each API environment(endpoint) has an associated access policy, it is documented in the description of each environment(endpoint). Different access policies are available: @@ -27,27 +27,27 @@ Different access policies are available: ### Public access -No authentication is required to access the endpoints with this access policy. +No authentication is required to access the environments(endpoints) with this access policy. ### Authenticated access -Authentication is required to access the endpoints with this access policy. +Authentication is required to access the environments(endpoints) with this access policy. ### Restricted access -Authentication is required to access the endpoints with this access policy. +Authentication is required to access the environments(endpoints) with this access policy. Extra-checks might be added to ensure access to the resource is granted. Returned data might also be filtered. ### Administrator access -Authentication as well as an administrator role are required to access the endpoints with this access policy. +Authentication as well as an administrator role are required to access the environments(endpoints) with this access policy. # Execute Docker requests -Portainer **DO NOT** expose specific endpoints to manage your Docker resources (create a container, remove a volume, etc...). +Portainer **DO NOT** expose specific environments(endpoints) to manage your Docker resources (create a container, remove a volume, etc...). Instead, it acts as a reverse-proxy to the Docker HTTP API. This means that you can execute Docker requests **via** the Portainer HTTP API. -To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). +To do so, you can use the `/endpoints/{id}/docker` Portainer API environment(endpoint) (which is not documented below due to Swagger limitations). This environment(endpoint) has a restricted access policy so you still need to be authenticated to be able to query this environment(endpoint). Any query on this environment(endpoint) will be proxied to the Docker API of the associated environment(endpoint) (requests and responses objects are the same as documented in the Docker API). -**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). +**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://documentation.portainer.io/api/api-examples/). diff --git a/api/backup/backup.go b/api/backup/backup.go index 3da032c2c..8470f837f 100644 --- a/api/backup/backup.go +++ b/api/backup/backup.go @@ -10,12 +10,24 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/archive" "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/offlinegate" ) const rwxr__r__ os.FileMode = 0744 -var filesToBackup = []string{"compose", "config.json", "custom_templates", "edge_jobs", "edge_stacks", "extensions", "portainer.key", "portainer.pub", "tls"} +var filesToBackup = []string{ + "certs", + "compose", + "config.json", + "custom_templates", + "edge_jobs", + "edge_stacks", + "extensions", + "portainer.key", + "portainer.pub", + "tls", +} // Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file. func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datastore portainer.DataStore, filestorePath string) (string, error) { @@ -32,7 +44,7 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto } for _, filename := range filesToBackup { - err := copyPath(filepath.Join(filestorePath, filename), backupDirPath) + err := filesystem.CopyPath(filepath.Join(filestorePath, filename), backupDirPath) if err != nil { return "", errors.Wrap(err, "Failed to create backup file") } diff --git a/api/backup/copy_test.go b/api/backup/copy_test.go deleted file mode 100644 index b9ceaeaab..000000000 --- a/api/backup/copy_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package backup - -import ( - "io/ioutil" - "os" - "path" - "path/filepath" - "testing" - - "github.com/docker/docker/pkg/ioutils" - "github.com/stretchr/testify/assert" -) - -func listFiles(dir string) []string { - items := make([]string, 0) - filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if path == dir { - return nil - } - items = append(items, path) - return nil - }) - - return items -} - -func contains(t *testing.T, list []string, path string) { - assert.Contains(t, list, path) - copyContent, _ := ioutil.ReadFile(path) - assert.Equal(t, "content\n", string(copyContent)) -} - -func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) { - tmpdir, _ := ioutils.TempDir("", "backup") - defer os.RemoveAll(tmpdir) - - err := copyFile("does-not-exist", tmpdir) - assert.NotNil(t, err) -} - -func Test_copyFile_shouldMakeAbackup(t *testing.T) { - tmpdir, _ := ioutils.TempDir("", "backup") - defer os.RemoveAll(tmpdir) - - content := []byte("content") - ioutil.WriteFile(path.Join(tmpdir, "origin"), content, 0600) - - err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy")) - assert.Nil(t, err) - - copyContent, _ := ioutil.ReadFile(path.Join(tmpdir, "copy")) - assert.Equal(t, content, copyContent) -} - -func Test_copyDir_shouldCopyAllFilesAndDirectories(t *testing.T) { - destination, _ := ioutils.TempDir("", "destination") - defer os.RemoveAll(destination) - err := copyDir("./test_assets/copy_test", destination) - assert.Nil(t, err) - - createdFiles := listFiles(destination) - - contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer")) - contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile")) - contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner")) -} - -func Test_backupPath_shouldSkipWhenNotExist(t *testing.T) { - tmpdir, _ := ioutils.TempDir("", "backup") - defer os.RemoveAll(tmpdir) - - err := copyPath("does-not-exists", tmpdir) - assert.Nil(t, err) - - assert.Empty(t, listFiles(tmpdir)) -} - -func Test_backupPath_shouldCopyFile(t *testing.T) { - tmpdir, _ := ioutils.TempDir("", "backup") - defer os.RemoveAll(tmpdir) - - content := []byte("content") - ioutil.WriteFile(path.Join(tmpdir, "file"), content, 0600) - - os.MkdirAll(path.Join(tmpdir, "backup"), 0700) - err := copyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup")) - assert.Nil(t, err) - - copyContent, err := ioutil.ReadFile(path.Join(tmpdir, "backup", "file")) - assert.Nil(t, err) - assert.Equal(t, content, copyContent) -} - -func Test_backupPath_shouldCopyDir(t *testing.T) { - destination, _ := ioutils.TempDir("", "destination") - defer os.RemoveAll(destination) - err := copyPath("./test_assets/copy_test", destination) - assert.Nil(t, err) - - createdFiles := listFiles(destination) - - contains(t, createdFiles, filepath.Join(destination, "copy_test", "outer")) - contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", ".dotfile")) - contains(t, createdFiles, filepath.Join(destination, "copy_test", "dir", "inner")) -} diff --git a/api/backup/restore.go b/api/backup/restore.go index b0d7acee2..e5329e913 100644 --- a/api/backup/restore.go +++ b/api/backup/restore.go @@ -11,6 +11,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/archive" "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/http/offlinegate" ) @@ -59,7 +60,7 @@ func extractArchive(r io.Reader, destinationDirPath string) error { func restoreFiles(srcDir string, destinationDir string) error { for _, filename := range filesToRestore { - err := copyPath(filepath.Join(srcDir, filename), destinationDir) + err := filesystem.CopyPath(filepath.Join(srcDir, filename), destinationDir) if err != nil { return err } diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index b0904102c..da8197cd9 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -6,6 +6,8 @@ import ( "path" "time" + "github.com/portainer/portainer/api/bolt/helmuserrepository" + "github.com/boltdb/bolt" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/customtemplate" @@ -25,6 +27,7 @@ import ( "github.com/portainer/portainer/api/bolt/role" "github.com/portainer/portainer/api/bolt/schedule" "github.com/portainer/portainer/api/bolt/settings" + "github.com/portainer/portainer/api/bolt/ssl" "github.com/portainer/portainer/api/bolt/stack" "github.com/portainer/portainer/api/bolt/tag" "github.com/portainer/portainer/api/bolt/team" @@ -43,32 +46,34 @@ const ( // Store defines the implementation of portainer.DataStore using // BoltDB as the storage system. type Store struct { - path string - connection *internal.DbConnection - isNew bool - fileService portainer.FileService - CustomTemplateService *customtemplate.Service - DockerHubService *dockerhub.Service - EdgeGroupService *edgegroup.Service - EdgeJobService *edgejob.Service - EdgeStackService *edgestack.Service - EndpointGroupService *endpointgroup.Service - EndpointService *endpoint.Service - EndpointRelationService *endpointrelation.Service - ExtensionService *extension.Service - RegistryService *registry.Service - ResourceControlService *resourcecontrol.Service - RoleService *role.Service - ScheduleService *schedule.Service - SettingsService *settings.Service - StackService *stack.Service - TagService *tag.Service - TeamMembershipService *teammembership.Service - TeamService *team.Service - TunnelServerService *tunnelserver.Service - UserService *user.Service - VersionService *version.Service - WebhookService *webhook.Service + path string + connection *internal.DbConnection + isNew bool + fileService portainer.FileService + CustomTemplateService *customtemplate.Service + DockerHubService *dockerhub.Service + EdgeGroupService *edgegroup.Service + EdgeJobService *edgejob.Service + EdgeStackService *edgestack.Service + EndpointGroupService *endpointgroup.Service + EndpointService *endpoint.Service + EndpointRelationService *endpointrelation.Service + ExtensionService *extension.Service + HelmUserRepositoryService *helmuserrepository.Service + RegistryService *registry.Service + ResourceControlService *resourcecontrol.Service + RoleService *role.Service + ScheduleService *schedule.Service + SettingsService *settings.Service + SSLSettingsService *ssl.Service + StackService *stack.Service + TagService *tag.Service + TeamMembershipService *teammembership.Service + TeamService *team.Service + TunnelServerService *tunnelserver.Service + UserService *user.Service + VersionService *version.Service + WebhookService *webhook.Service } func (store *Store) edition() portainer.SoftwareEdition { @@ -114,6 +119,7 @@ func (store *Store) Open() error { } // Close closes the BoltDB database. +// Safe to being called multiple times. func (store *Store) Close() error { if store.connection.DB != nil { return store.connection.Close() @@ -169,6 +175,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) diff --git a/api/bolt/edgejob/edgejob.go b/api/bolt/edgejob/edgejob.go index 216bdacec..ab94699bd 100644 --- a/api/bolt/edgejob/edgejob.go +++ b/api/bolt/edgejob/edgejob.go @@ -95,7 +95,7 @@ func (service *Service) DeleteEdgeJob(ID portainer.EdgeJobID) error { return internal.DeleteObject(service.connection, BucketName, identifier) } -// GetNextIdentifier returns the next identifier for an endpoint. +// GetNextIdentifier returns the next identifier for an environment(endpoint). func (service *Service) GetNextIdentifier() int { return internal.GetNextIdentifier(service.connection, BucketName) } diff --git a/api/bolt/edgestack/edgestack.go b/api/bolt/edgestack/edgestack.go index ff58c0dae..6136156fe 100644 --- a/api/bolt/edgestack/edgestack.go +++ b/api/bolt/edgestack/edgestack.go @@ -95,7 +95,7 @@ func (service *Service) DeleteEdgeStack(ID portainer.EdgeStackID) error { return internal.DeleteObject(service.connection, BucketName, identifier) } -// GetNextIdentifier returns the next identifier for an endpoint. +// GetNextIdentifier returns the next identifier for an environment(endpoint). func (service *Service) GetNextIdentifier() int { return internal.GetNextIdentifier(service.connection, BucketName) } diff --git a/api/bolt/endpoint/endpoint.go b/api/bolt/endpoint/endpoint.go index ebd162985..a136058ce 100644 --- a/api/bolt/endpoint/endpoint.go +++ b/api/bolt/endpoint/endpoint.go @@ -11,7 +11,7 @@ const ( BucketName = "endpoints" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } @@ -28,7 +28,7 @@ func NewService(connection *internal.DbConnection) (*Service, error) { }, nil } -// Endpoint returns an endpoint by ID. +// Endpoint returns an environment(endpoint) by ID. func (service *Service) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { var endpoint portainer.Endpoint identifier := internal.Itob(int(ID)) @@ -41,19 +41,19 @@ func (service *Service) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, return &endpoint, nil } -// UpdateEndpoint updates an endpoint. +// UpdateEndpoint updates an environment(endpoint). func (service *Service) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { identifier := internal.Itob(int(ID)) return internal.UpdateObject(service.connection, BucketName, identifier, endpoint) } -// DeleteEndpoint deletes an endpoint. +// DeleteEndpoint deletes an environment(endpoint). func (service *Service) DeleteEndpoint(ID portainer.EndpointID) error { identifier := internal.Itob(int(ID)) return internal.DeleteObject(service.connection, BucketName, identifier) } -// Endpoints return an array containing all the endpoints. +// Endpoints return an array containing all the environments(endpoints). func (service *Service) Endpoints() ([]portainer.Endpoint, error) { var endpoints = make([]portainer.Endpoint, 0) @@ -76,12 +76,12 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) { return endpoints, err } -// CreateEndpoint assign an ID to a new endpoint and saves it. +// CreateEndpoint assign an ID to a new environment(endpoint) and saves it. func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error { return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) - // We manually manage sequences for endpoints + // We manually manage sequences for environments(endpoints) err := bucket.SetSequence(uint64(endpoint.ID)) if err != nil { return err @@ -96,12 +96,12 @@ func (service *Service) CreateEndpoint(endpoint *portainer.Endpoint) error { }) } -// GetNextIdentifier returns the next identifier for an endpoint. +// GetNextIdentifier returns the next identifier for an environment(endpoint). func (service *Service) GetNextIdentifier() int { return internal.GetNextIdentifier(service.connection, BucketName) } -// Synchronize creates, updates and deletes endpoints inside a single transaction. +// Synchronize creates, updates and deletes environments(endpoints) inside a single transaction. func (service *Service) Synchronize(toCreate, toUpdate, toDelete []*portainer.Endpoint) error { return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) diff --git a/api/bolt/endpointgroup/endpointgroup.go b/api/bolt/endpointgroup/endpointgroup.go index 02c0e3382..4ab9bb556 100644 --- a/api/bolt/endpointgroup/endpointgroup.go +++ b/api/bolt/endpointgroup/endpointgroup.go @@ -12,7 +12,7 @@ const ( BucketName = "endpoint_groups" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } @@ -29,7 +29,7 @@ func NewService(connection *internal.DbConnection) (*Service, error) { }, nil } -// EndpointGroup returns an endpoint group by ID. +// EndpointGroup returns an environment(endpoint) group by ID. func (service *Service) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) { var endpointGroup portainer.EndpointGroup identifier := internal.Itob(int(ID)) @@ -42,19 +42,19 @@ func (service *Service) EndpointGroup(ID portainer.EndpointGroupID) (*portainer. return &endpointGroup, nil } -// UpdateEndpointGroup updates an endpoint group. +// UpdateEndpointGroup updates an environment(endpoint) group. func (service *Service) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error { identifier := internal.Itob(int(ID)) return internal.UpdateObject(service.connection, BucketName, identifier, endpointGroup) } -// DeleteEndpointGroup deletes an endpoint group. +// DeleteEndpointGroup deletes an environment(endpoint) group. func (service *Service) DeleteEndpointGroup(ID portainer.EndpointGroupID) error { identifier := internal.Itob(int(ID)) return internal.DeleteObject(service.connection, BucketName, identifier) } -// EndpointGroups return an array containing all the endpoint groups. +// EndpointGroups return an array containing all the environment(endpoint) groups. func (service *Service) EndpointGroups() ([]portainer.EndpointGroup, error) { var endpointGroups = make([]portainer.EndpointGroup, 0) @@ -77,7 +77,7 @@ func (service *Service) EndpointGroups() ([]portainer.EndpointGroup, error) { return endpointGroups, err } -// CreateEndpointGroup assign an ID to a new endpoint group and saves it. +// CreateEndpointGroup assign an ID to a new environment(endpoint) group and saves it. func (service *Service) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error { return service.connection.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(BucketName)) diff --git a/api/bolt/endpointrelation/endpointrelation.go b/api/bolt/endpointrelation/endpointrelation.go index 974913531..ecd192d42 100644 --- a/api/bolt/endpointrelation/endpointrelation.go +++ b/api/bolt/endpointrelation/endpointrelation.go @@ -11,7 +11,7 @@ const ( BucketName = "endpoint_relations" ) -// Service represents a service for managing endpoint relation data. +// Service represents a service for managing environment(endpoint) relation data. type Service struct { connection *internal.DbConnection } @@ -28,7 +28,7 @@ func NewService(connection *internal.DbConnection) (*Service, error) { }, nil } -// EndpointRelation returns a Endpoint relation object by EndpointID +// EndpointRelation returns a Environment(Endpoint) relation object by EndpointID func (service *Service) EndpointRelation(endpointID portainer.EndpointID) (*portainer.EndpointRelation, error) { var endpointRelation portainer.EndpointRelation identifier := internal.Itob(int(endpointID)) @@ -55,13 +55,13 @@ func (service *Service) CreateEndpointRelation(endpointRelation *portainer.Endpo }) } -// UpdateEndpointRelation updates an Endpoint relation object +// UpdateEndpointRelation updates an Environment(Endpoint) relation object func (service *Service) UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error { identifier := internal.Itob(int(EndpointID)) return internal.UpdateObject(service.connection, BucketName, identifier, endpointRelation) } -// DeleteEndpointRelation deletes an Endpoint relation object +// DeleteEndpointRelation deletes an Environment(Endpoint) relation object func (service *Service) DeleteEndpointRelation(EndpointID portainer.EndpointID) error { identifier := internal.Itob(int(EndpointID)) return internal.DeleteObject(service.connection, BucketName, identifier) diff --git a/api/bolt/extension/extension.go b/api/bolt/extension/extension.go index 15104af8f..2d45b0eed 100644 --- a/api/bolt/extension/extension.go +++ b/api/bolt/extension/extension.go @@ -12,7 +12,7 @@ const ( BucketName = "extension" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/helmuserrepository/helmuserrepository.go b/api/bolt/helmuserrepository/helmuserrepository.go new file mode 100644 index 000000000..2605d54a0 --- /dev/null +++ b/api/bolt/helmuserrepository/helmuserrepository.go @@ -0,0 +1,73 @@ +package helmuserrepository + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "helm_user_repository" +) + +// Service represents a service for managing environment(endpoint) data. +type Service struct { + connection *internal.DbConnection +} + +// NewService creates a new instance of a service. +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// HelmUserRepositoryByUserID return an array containing all the HelmUserRepository objects where the specified userID is present. +func (service *Service) HelmUserRepositoryByUserID(userID portainer.UserID) ([]portainer.HelmUserRepository, error) { + var result = make([]portainer.HelmUserRepository, 0) + + err := service.connection.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var record portainer.HelmUserRepository + err := internal.UnmarshalObject(v, &record) + if err != nil { + return err + } + + if record.UserID == userID { + result = append(result, record) + } + } + + return nil + }) + + return result, err +} + +// CreateHelmUserRepository creates a new HelmUserRepository object. +func (service *Service) CreateHelmUserRepository(record *portainer.HelmUserRepository) error { + return service.connection.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + id, _ := bucket.NextSequence() + record.ID = portainer.HelmUserRepositoryID(id) + + data, err := internal.MarshalObject(record) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(record.ID)), data) + }) +} diff --git a/api/bolt/init.go b/api/bolt/init.go index 7ce23f138..4b9e4559f 100644 --- a/api/bolt/init.go +++ b/api/bolt/init.go @@ -44,7 +44,9 @@ func (store *Store) Init() error { EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds, TemplatesURL: portainer.DefaultTemplatesURL, + HelmRepositoryURL: portainer.DefaultHelmRepositoryURL, UserSessionTimeout: portainer.DefaultUserSessionTimeout, + KubeconfigExpiry: portainer.DefaultKubeconfigExpiry, } err = store.SettingsService.UpdateSettings(defaultSettings) @@ -55,20 +57,20 @@ func (store *Store) Init() error { return err } - _, err = store.DockerHubService.DockerHub() - if err == errors.ErrObjectNotFound { - defaultDockerHub := &portainer.DockerHub{ - Authentication: false, - Username: "", - Password: "", + _, err = store.SSLSettings().Settings() + if err != nil { + if err != errors.ErrObjectNotFound { + return err } - err := store.DockerHubService.UpdateDockerHub(defaultDockerHub) + defaultSSLSettings := &portainer.SSLSettings{ + HTTPEnabled: true, + } + + err = store.SSLSettings().UpdateSettings(defaultSSLSettings) if err != nil { return err } - } else if err != nil { - return err } groups, err := store.EndpointGroupService.EndpointGroups() @@ -79,7 +81,7 @@ func (store *Store) Init() error { if len(groups) == 0 { unassignedGroup := &portainer.EndpointGroup{ Name: "Unassigned", - Description: "Unassigned endpoints", + Description: "Unassigned environments", Labels: []portainer.Pair{}, UserAccessPolicies: portainer.UserAccessPolicies{}, TeamAccessPolicies: portainer.TeamAccessPolicies{}, diff --git a/api/bolt/internal/json.go b/api/bolt/internal/json.go index f0636a599..5d117d104 100644 --- a/api/bolt/internal/json.go +++ b/api/bolt/internal/json.go @@ -17,7 +17,7 @@ func UnmarshalObject(data []byte, object interface{}) error { } // UnmarshalObjectWithJsoniter decodes an object from binary data -// using the jsoniter library. It is mainly used to accelerate endpoint +// using the jsoniter library. It is mainly used to accelerate environment(endpoint) // decoding at the moment. func UnmarshalObjectWithJsoniter(data []byte, object interface{}) error { var jsoni = jsoniter.ConfigCompatibleWithStandardLibrary diff --git a/api/bolt/log/log.go b/api/bolt/log/log.go new file mode 100644 index 000000000..5ae90946a --- /dev/null +++ b/api/bolt/log/log.go @@ -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)) +} diff --git a/api/bolt/log/log.test.go b/api/bolt/log/log.test.go new file mode 100644 index 000000000..7330d5405 --- /dev/null +++ b/api/bolt/log/log.test.go @@ -0,0 +1 @@ +package log diff --git a/api/bolt/migrator/migrate_dbversion30.go b/api/bolt/migrator/migrate_dbversion29.go similarity index 64% rename from api/bolt/migrator/migrate_dbversion30.go rename to api/bolt/migrator/migrate_dbversion29.go index 77b9023c1..0ef7546d2 100644 --- a/api/bolt/migrator/migrate_dbversion30.go +++ b/api/bolt/migrator/migrate_dbversion29.go @@ -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 diff --git a/api/bolt/migrator/migrate_dbversion30_test.go b/api/bolt/migrator/migrate_dbversion29_test.go similarity index 98% rename from api/bolt/migrator/migrate_dbversion30_test.go rename to api/bolt/migrator/migrate_dbversion29_test.go index 42f09b4ab..707fe8796 100644 --- a/api/bolt/migrator/migrate_dbversion30_test.go +++ b/api/bolt/migrator/migrate_dbversion29_test.go @@ -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() diff --git a/api/bolt/migrator/migrate_dbversion31.go b/api/bolt/migrator/migrate_dbversion31.go new file mode 100644 index 000000000..8086dfc40 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion31.go @@ -0,0 +1,242 @@ +package migrator + +import ( + "fmt" + "log" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/internal/endpointutils" + 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 + } + + if err := m.kubeconfigExpiryToDB32(); err != nil { + return err + } + + if err := m.helmRepositoryURLToDB32(); 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 environments: %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 { + log.Println("[DEBUG] [volume migration] [message: no snapshot found]") + continue + } + + snapshot := endpoint.Snapshots[totalSnapshots-1] + + endpointDockerID, err := snapshotutils.FetchDockerID(snapshot) + if err != nil { + return fmt.Errorf("failed fetching environment docker id: %w", err) + } + + if volumesData, done := snapshot.SnapshotRaw.Volumes.(map[string]interface{}); done { + if volumesData["Volumes"] == nil { + log.Println("[DEBUG] [volume migration] [message: no volume data found]") + 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) + } + log.Printf("[DEBUG] [volume migration] [message: legacy resource control(%s) has been deleted]", resourceControl.ResourceID) + } + } + + 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) + } + } +} + +func (m *Migrator) kubeconfigExpiryToDB32() error { + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + settings.KubeconfigExpiry = portainer.DefaultKubeconfigExpiry + return m.settingsService.UpdateSettings(settings) +} + +func (m *Migrator) helmRepositoryURLToDB32() error { + settings, err := m.settingsService.Settings() + if err != nil { + return err + } + settings.HelmRepositoryURL = portainer.DefaultHelmRepositoryURL + return m.settingsService.UpdateSettings(settings) +} diff --git a/api/bolt/migrator/migrate_dbversion33.go b/api/bolt/migrator/migrate_dbversion33.go new file mode 100644 index 000000000..d7277ada7 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion33.go @@ -0,0 +1,32 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" +) + +func (m *Migrator) migrateDBVersionTo33() error { + err := migrateStackEntryPoint(m.stackService) + if err != nil { + return err + } + + return nil +} + +func migrateStackEntryPoint(stackService portainer.StackService) error { + stacks, err := stackService.Stacks() + if err != nil { + return err + } + for i := range stacks { + stack := &stacks[i] + if stack.GitConfig == nil { + continue + } + stack.GitConfig.ConfigFilePath = stack.EntryPoint + if err := stackService.UpdateStack(stack.ID, stack); err != nil { + return err + } + } + return nil +} diff --git a/api/bolt/migrator/migrate_dbversion33_test.go b/api/bolt/migrator/migrate_dbversion33_test.go new file mode 100644 index 000000000..256cc121e --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion33_test.go @@ -0,0 +1,51 @@ +package migrator + +import ( + "path" + "testing" + "time" + + "github.com/boltdb/bolt" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" + "github.com/portainer/portainer/api/bolt/stack" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/stretchr/testify/assert" +) + +func TestMigrateStackEntryPoint(t *testing.T) { + dbConn, err := bolt.Open(path.Join(t.TempDir(), "portainer-ee-mig-33.db"), 0600, &bolt.Options{Timeout: 1 * time.Second}) + assert.NoError(t, err, "failed to init testing DB connection") + defer dbConn.Close() + + stackService, err := stack.NewService(&internal.DbConnection{DB: dbConn}) + assert.NoError(t, err, "failed to init testing Stack service") + + stacks := []*portainer.Stack{ + { + ID: 1, + EntryPoint: "dir/sub/compose.yml", + }, + { + ID: 2, + EntryPoint: "dir/sub/compose.yml", + GitConfig: &gittypes.RepoConfig{}, + }, + } + + for _, s := range stacks { + err := stackService.CreateStack(s) + assert.NoError(t, err, "failed to create stack") + } + + err = migrateStackEntryPoint(stackService) + assert.NoError(t, err, "failed to migrate entry point to Git ConfigFilePath") + + s, err := stackService.Stack(1) + assert.NoError(t, err) + assert.Nil(t, s.GitConfig, "first stack should not have git config") + + s, err = stackService.Stack(2) + assert.NoError(t, err) + assert.Equal(t, "dir/sub/compose.yml", s.GitConfig.ConfigFilePath, "second stack should have config file path migrated") +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 8d99b5bfa..df3ad0436 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -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,11 +367,25 @@ 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 + } + } + + if m.currentDBVersion < 33 { + if err := m.migrateDBVersionTo33(); err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/bolt/registry/registry.go b/api/bolt/registry/registry.go index dc741ae7b..f1f530fd7 100644 --- a/api/bolt/registry/registry.go +++ b/api/bolt/registry/registry.go @@ -12,7 +12,7 @@ const ( BucketName = "registries" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/resourcecontrol/resourcecontrol.go b/api/bolt/resourcecontrol/resourcecontrol.go index d0d1559fb..0421ccbdb 100644 --- a/api/bolt/resourcecontrol/resourcecontrol.go +++ b/api/bolt/resourcecontrol/resourcecontrol.go @@ -12,7 +12,7 @@ const ( BucketName = "resource_control" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/role/role.go b/api/bolt/role/role.go index eff9d56f1..7849b32e9 100644 --- a/api/bolt/role/role.go +++ b/api/bolt/role/role.go @@ -12,7 +12,7 @@ const ( BucketName = "roles" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/services.go b/api/bolt/services.go index 4cdc84069..849efa496 100644 --- a/api/bolt/services.go +++ b/api/bolt/services.go @@ -11,11 +11,13 @@ import ( "github.com/portainer/portainer/api/bolt/endpointgroup" "github.com/portainer/portainer/api/bolt/endpointrelation" "github.com/portainer/portainer/api/bolt/extension" + "github.com/portainer/portainer/api/bolt/helmuserrepository" "github.com/portainer/portainer/api/bolt/registry" "github.com/portainer/portainer/api/bolt/resourcecontrol" "github.com/portainer/portainer/api/bolt/role" "github.com/portainer/portainer/api/bolt/schedule" "github.com/portainer/portainer/api/bolt/settings" + "github.com/portainer/portainer/api/bolt/ssl" "github.com/portainer/portainer/api/bolt/stack" "github.com/portainer/portainer/api/bolt/tag" "github.com/portainer/portainer/api/bolt/team" @@ -87,6 +89,12 @@ func (store *Store) initServices() error { } store.ExtensionService = extensionService + helmUserRepositoryService, err := helmuserrepository.NewService(store.connection) + if err != nil { + return err + } + store.HelmUserRepositoryService = helmUserRepositoryService + registryService, err := registry.NewService(store.connection) if err != nil { return err @@ -105,6 +113,12 @@ func (store *Store) initServices() error { } store.SettingsService = settingsService + sslSettingsService, err := ssl.NewService(store.connection) + if err != nil { + return err + } + store.SSLSettingsService = sslSettingsService + stackService, err := stack.NewService(store.connection) if err != nil { return err @@ -167,11 +181,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 @@ -187,7 +196,7 @@ func (store *Store) EdgeStack() portainer.EdgeStackService { return store.EdgeStackService } -// Endpoint gives access to the Endpoint data management layer +// Environment(Endpoint) gives access to the Environment(Endpoint) data management layer func (store *Store) Endpoint() portainer.EndpointService { return store.EndpointService } @@ -202,6 +211,11 @@ func (store *Store) EndpointRelation() portainer.EndpointRelationService { return store.EndpointRelationService } +// HelmUserRepository access the helm user repository settings +func (store *Store) HelmUserRepository() portainer.HelmUserRepositoryService { + return store.HelmUserRepositoryService +} + // Registry gives access to the Registry data management layer func (store *Store) Registry() portainer.RegistryService { return store.RegistryService @@ -222,6 +236,11 @@ func (store *Store) Settings() portainer.SettingsService { return store.SettingsService } +// SSLSettings gives access to the SSL Settings data management layer +func (store *Store) SSLSettings() portainer.SSLSettingsService { + return store.SSLSettingsService +} + // Stack gives access to the Stack data management layer func (store *Store) Stack() portainer.StackService { return store.StackService diff --git a/api/bolt/settings/settings.go b/api/bolt/settings/settings.go index 001bd6142..60f8735c1 100644 --- a/api/bolt/settings/settings.go +++ b/api/bolt/settings/settings.go @@ -11,7 +11,7 @@ const ( settingsKey = "SETTINGS" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/ssl/ssl.go b/api/bolt/ssl/ssl.go new file mode 100644 index 000000000..c71c9234e --- /dev/null +++ b/api/bolt/ssl/ssl.go @@ -0,0 +1,46 @@ +package ssl + +import ( + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/internal" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "ssl" + key = "SSL" +) + +// Service represents a service for managing ssl data. +type Service struct { + connection *internal.DbConnection +} + +// NewService creates a new instance of a service. +func NewService(connection *internal.DbConnection) (*Service, error) { + err := internal.CreateBucket(connection, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + connection: connection, + }, nil +} + +// Settings retrieve the ssl settings object. +func (service *Service) Settings() (*portainer.SSLSettings, error) { + var settings portainer.SSLSettings + + err := internal.GetObject(service.connection, BucketName, []byte(key), &settings) + if err != nil { + return nil, err + } + + return &settings, nil +} + +// UpdateSettings persists a SSLSettings object. +func (service *Service) UpdateSettings(settings *portainer.SSLSettings) error { + return internal.UpdateObject(service.connection, BucketName, []byte(key), settings) +} diff --git a/api/bolt/stack/stack.go b/api/bolt/stack/stack.go index f9cfafad7..b6839493f 100644 --- a/api/bolt/stack/stack.go +++ b/api/bolt/stack/stack.go @@ -1,11 +1,14 @@ package stack import ( + "strings" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/bolt/internal" "github.com/boltdb/bolt" + pkgerrors "github.com/pkg/errors" ) const ( @@ -13,7 +16,7 @@ const ( BucketName = "stacks" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } @@ -133,3 +136,76 @@ func (service *Service) DeleteStack(ID portainer.StackID) error { identifier := internal.Itob(int(ID)) return internal.DeleteObject(service.connection, BucketName, identifier) } + +// StackByWebhookID returns a pointer to a stack object by webhook ID. +// It returns nil, errors.ErrObjectNotFound if there's no stack associated with the webhook ID. +func (service *Service) StackByWebhookID(id string) (*portainer.Stack, error) { + if id == "" { + return nil, pkgerrors.New("webhook ID can't be empty string") + } + var stack portainer.Stack + found := false + + err := service.connection.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + cursor := bucket.Cursor() + + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var t struct { + AutoUpdate *struct { + WebhookID string `json:"Webhook"` + } `json:"AutoUpdate"` + } + + err := internal.UnmarshalObject(v, &t) + if err != nil { + return err + } + + if t.AutoUpdate != nil && strings.EqualFold(t.AutoUpdate.WebhookID, id) { + found = true + err := internal.UnmarshalObject(v, &stack) + if err != nil { + return err + } + break + } + } + + return nil + }) + + if err != nil { + return nil, err + } + if !found { + return nil, errors.ErrObjectNotFound + } + + return &stack, nil +} + +// RefreshableStacks returns stacks that are configured for a periodic update +func (service *Service) RefreshableStacks() ([]portainer.Stack, error) { + stacks := make([]portainer.Stack, 0) + err := service.connection.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + cursor := bucket.Cursor() + + var stack portainer.Stack + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + err := internal.UnmarshalObject(v, &stack) + if err != nil { + return err + } + + if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" { + stacks = append(stacks, stack) + } + } + + return nil + }) + + return stacks, err +} diff --git a/api/bolt/stack/tests/stack_test.go b/api/bolt/stack/tests/stack_test.go new file mode 100644 index 000000000..d0c66dadf --- /dev/null +++ b/api/bolt/stack/tests/stack_test.go @@ -0,0 +1,111 @@ +package tests + +import ( + "testing" + "time" + + "github.com/portainer/portainer/api/bolt" + + bolterrors "github.com/portainer/portainer/api/bolt/errors" + + "github.com/portainer/portainer/api/bolt/bolttest" + + "github.com/gofrs/uuid" + + "github.com/stretchr/testify/assert" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" +) + +func newGuidString(t *testing.T) string { + uuid, err := uuid.NewV4() + assert.NoError(t, err) + + return uuid.String() +} + +type stackBuilder struct { + t *testing.T + count int + store *bolt.Store +} + +func TestService_StackByWebhookID(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode. Normally takes ~1s to run.") + } + store, teardown := bolttest.MustNewTestStore(true) + defer teardown() + + b := stackBuilder{t: t, store: store} + b.createNewStack(newGuidString(t)) + for i := 0; i < 10; i++ { + b.createNewStack("") + } + webhookID := newGuidString(t) + stack := b.createNewStack(webhookID) + + // can find a stack by webhook ID + got, err := store.StackService.StackByWebhookID(webhookID) + assert.NoError(t, err) + assert.Equal(t, stack, *got) + + // returns nil and object not found error if there's no stack associated with the webhook + got, err = store.StackService.StackByWebhookID(newGuidString(t)) + assert.Nil(t, got) + assert.ErrorIs(t, err, bolterrors.ErrObjectNotFound) +} + +func (b *stackBuilder) createNewStack(webhookID string) portainer.Stack { + b.count++ + stack := portainer.Stack{ + ID: portainer.StackID(b.count), + Name: "Name", + Type: portainer.DockerComposeStack, + EndpointID: 2, + EntryPoint: filesystem.ComposeFileDefaultName, + Env: []portainer.Pair{{"Name1", "Value1"}}, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), + ProjectPath: "/tmp/project", + CreatedBy: "test", + } + + if webhookID == "" { + if b.count%2 == 0 { + stack.AutoUpdate = &portainer.StackAutoUpdate{ + Interval: "", + Webhook: "", + } + } // else keep AutoUpdate nil + } else { + stack.AutoUpdate = &portainer.StackAutoUpdate{Webhook: webhookID} + } + + err := b.store.StackService.CreateStack(&stack) + assert.NoError(b.t, err) + + return stack +} + +func Test_RefreshableStacks(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode. Normally takes ~1s to run.") + } + store, teardown := bolttest.MustNewTestStore(true) + defer teardown() + + staticStack := portainer.Stack{ID: 1} + stackWithWebhook := portainer.Stack{ID: 2, AutoUpdate: &portainer.StackAutoUpdate{Webhook: "webhook"}} + refreshableStack := portainer.Stack{ID: 3, AutoUpdate: &portainer.StackAutoUpdate{Interval: "1m"}} + + for _, stack := range []*portainer.Stack{&staticStack, &stackWithWebhook, &refreshableStack} { + err := store.Stack().CreateStack(stack) + assert.NoError(t, err) + } + + stacks, err := store.Stack().RefreshableStacks() + assert.NoError(t, err) + assert.ElementsMatch(t, []portainer.Stack{refreshableStack}, stacks) +} diff --git a/api/bolt/tag/tag.go b/api/bolt/tag/tag.go index f10c64d35..00bd15f0a 100644 --- a/api/bolt/tag/tag.go +++ b/api/bolt/tag/tag.go @@ -12,7 +12,7 @@ const ( BucketName = "tags" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/team/team.go b/api/bolt/team/team.go index d710f05c1..681b6bd8a 100644 --- a/api/bolt/team/team.go +++ b/api/bolt/team/team.go @@ -15,7 +15,7 @@ const ( BucketName = "teams" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/teammembership/teammembership.go b/api/bolt/teammembership/teammembership.go index 752120ea1..f6a0d94b5 100644 --- a/api/bolt/teammembership/teammembership.go +++ b/api/bolt/teammembership/teammembership.go @@ -12,7 +12,7 @@ const ( BucketName = "team_membership" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/tunnelserver/tunnelserver.go b/api/bolt/tunnelserver/tunnelserver.go index a85b098df..2b88a848f 100644 --- a/api/bolt/tunnelserver/tunnelserver.go +++ b/api/bolt/tunnelserver/tunnelserver.go @@ -11,7 +11,7 @@ const ( infoKey = "INFO" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/bolt/user/user.go b/api/bolt/user/user.go index 700d2f419..e91598c4d 100644 --- a/api/bolt/user/user.go +++ b/api/bolt/user/user.go @@ -15,7 +15,7 @@ const ( BucketName = "users" ) -// Service represents a service for managing endpoint data. +// Service represents a service for managing environment(endpoint) data. type Service struct { connection *internal.DbConnection } diff --git a/api/chisel/schedules.go b/api/chisel/schedules.go index bac424fcb..6bcba574d 100644 --- a/api/chisel/schedules.go +++ b/api/chisel/schedules.go @@ -6,7 +6,7 @@ import ( portainer "github.com/portainer/portainer/api" ) -// AddEdgeJob register an EdgeJob inside the tunnel details associated to an endpoint. +// AddEdgeJob register an EdgeJob inside the tunnel details associated to an environment(endpoint). func (service *Service) AddEdgeJob(endpointID portainer.EndpointID, edgeJob *portainer.EdgeJob) { tunnel := service.GetTunnelDetails(endpointID) diff --git a/api/chisel/service.go b/api/chisel/service.go index d5787d9e5..f22281bd5 100644 --- a/api/chisel/service.go +++ b/api/chisel/service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log" + "net/http" "strconv" "time" @@ -42,6 +43,55 @@ func NewService(dataStore portainer.DataStore, shutdownCtx context.Context) *Ser } } +// pingAgent ping the given agent so that the agent can keep the tunnel alive +func (service *Service) pingAgent(endpointID portainer.EndpointID) error{ + tunnel := service.GetTunnelDetails(endpointID) + requestURL := fmt.Sprintf("http://127.0.0.1:%d/ping", tunnel.Port) + req, err := http.NewRequest(http.MethodHead, requestURL, nil) + if err != nil { + return err + } + + httpClient := &http.Client{ + Timeout: 3 * time.Second, + } + _, err = httpClient.Do(req) + if err != nil { + return err + } + + return nil +} + +// KeepTunnelAlive keeps the tunnel of the given environment for maxAlive duration, or until ctx is done +func (service *Service) KeepTunnelAlive(endpointID portainer.EndpointID, ctx context.Context, maxAlive time.Duration) { + go func() { + log.Printf("[DEBUG] [chisel,KeepTunnelAlive] [endpoint_id: %d] [message: start for %.0f minutes]\n", endpointID, maxAlive.Minutes()) + maxAliveTicker := time.NewTicker(maxAlive) + defer maxAliveTicker.Stop() + pingTicker := time.NewTicker(tunnelCleanupInterval) + defer pingTicker.Stop() + + for { + select { + case <-pingTicker.C: + service.SetTunnelStatusToActive(endpointID) + err := service.pingAgent(endpointID) + if err != nil { + log.Printf("[DEBUG] [chisel,KeepTunnelAlive] [endpoint_id: %d] [warning: ping agent err=%s]\n", endpointID, err) + } + case <-maxAliveTicker.C: + log.Printf("[DEBUG] [chisel,KeepTunnelAlive] [endpoint_id: %d] [message: stop as %.0f minutes timeout]\n", endpointID, maxAlive.Minutes()) + return + case <-ctx.Done(): + err := ctx.Err() + log.Printf("[DEBUG] [chisel,KeepTunnelAlive] [endpoint_id: %d] [message: stop as err=%s]\n", endpointID, err) + return + } + } + }() +} + // StartTunnelServer starts a tunnel server on the specified addr and port. // It uses a seed to generate a new private/public key pair. If the seed cannot // be found inside the database, it will generate a new one randomly and persist it. @@ -141,7 +191,7 @@ func (service *Service) checkTunnels() { } elapsed := time.Since(tunnel.LastActivity) - log.Printf("[DEBUG] [chisel,monitoring] [endpoint_id: %s] [status: %s] [status_time_seconds: %f] [message: endpoint tunnel monitoring]", item.Key, tunnel.Status, elapsed.Seconds()) + log.Printf("[DEBUG] [chisel,monitoring] [endpoint_id: %s] [status: %s] [status_time_seconds: %f] [message: environment tunnel monitoring]", item.Key, tunnel.Status, elapsed.Seconds()) if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed.Seconds() < requiredTimeout.Seconds() { continue @@ -156,19 +206,19 @@ func (service *Service) checkTunnels() { endpointID, err := strconv.Atoi(item.Key) if err != nil { - log.Printf("[ERROR] [chisel,snapshot,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err) + log.Printf("[ERROR] [chisel,snapshot,conversion] Invalid environment identifier (id: %s): %s", item.Key, err) } err = service.snapshotEnvironment(portainer.EndpointID(endpointID), tunnel.Port) if err != nil { - log.Printf("[ERROR] [snapshot] Unable to snapshot Edge endpoint (id: %s): %s", item.Key, err) + log.Printf("[ERROR] [snapshot] Unable to snapshot Edge environment (id: %s): %s", item.Key, err) } } if len(tunnel.Jobs) > 0 { endpointID, err := strconv.Atoi(item.Key) if err != nil { - log.Printf("[ERROR] [chisel,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err) + log.Printf("[ERROR] [chisel,conversion] Invalid environment identifier (id: %s): %s", item.Key, err) continue } diff --git a/api/chisel/tunnel.go b/api/chisel/tunnel.go index 1306df48c..8f48461f6 100644 --- a/api/chisel/tunnel.go +++ b/api/chisel/tunnel.go @@ -38,7 +38,7 @@ func randomInt(min, max int) int { return min + rand.Intn(max-min) } -// GetTunnelDetails returns information about the tunnel associated to an endpoint. +// GetTunnelDetails returns information about the tunnel associated to an environment(endpoint). func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails { key := strconv.Itoa(int(endpointID)) @@ -56,7 +56,7 @@ func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) *porta } } -// SetTunnelStatusToActive update the status of the tunnel associated to the specified endpoint. +// SetTunnelStatusToActive update the status of the tunnel associated to the specified environment(endpoint). // It sets the status to ACTIVE. func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) { tunnel := service.GetTunnelDetails(endpointID) @@ -68,7 +68,7 @@ func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) service.tunnelDetailsMap.Set(key, tunnel) } -// SetTunnelStatusToIdle update the status of the tunnel associated to the specified endpoint. +// SetTunnelStatusToIdle update the status of the tunnel associated to the specified environment(endpoint). // It sets the status to IDLE. // It removes any existing credentials associated to the tunnel. func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) { @@ -88,11 +88,11 @@ func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) { service.tunnelDetailsMap.Set(key, tunnel) } -// SetTunnelStatusToRequired update the status of the tunnel associated to the specified endpoint. +// SetTunnelStatusToRequired update the status of the tunnel associated to the specified environment(endpoint). // It sets the status to REQUIRED. // If no port is currently associated to the tunnel, it will associate a random unused port to the tunnel // and generate temporary credentials that can be used to establish a reverse tunnel on that port. -// Credentials are encrypted using the Edge ID associated to the endpoint. +// Credentials are encrypted using the Edge ID associated to the environment(endpoint). func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error { tunnel := service.GetTunnelDetails(endpointID) diff --git a/api/cli/cli.go b/api/cli/cli.go index ca6534893..e5c984807 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -5,7 +5,7 @@ import ( "log" "time" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "os" "path/filepath" @@ -18,7 +18,7 @@ import ( type Service struct{} var ( - errInvalidEndpointProtocol = errors.New("Invalid endpoint protocol: Portainer only supports unix://, npipe:// or tcp://") + errInvalidEndpointProtocol = errors.New("Invalid environment protocol: Portainer only supports unix://, npipe:// or tcp://") errSocketOrNamedPipeNotFound = errors.New("Unable to locate Unix socket or named pipe") errInvalidSnapshotInterval = errors.New("Invalid snapshot interval") errAdminPassExcludeAdminPassFile = errors.New("Cannot use --admin-password with --admin-password-file") @@ -30,11 +30,12 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { flags := &portainer.CLIFlags{ Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), + AddrHTTPS: kingpin.Flag("bind-https", "Address and port to serve Portainer via https").Default(defaultHTTPSBindAddress).String(), TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(), TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(), Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), - EndpointURL: kingpin.Flag("host", "Endpoint URL").Short('H').String(), + EndpointURL: kingpin.Flag("host", "Environment URL").Short('H').String(), EnableEdgeComputeFeatures: kingpin.Flag("edge-compute", "Enable Edge Compute features").Bool(), NoAnalytics: kingpin.Flag("no-analytics", "Disable Analytics in app (deprecated)").Bool(), TLS: kingpin.Flag("tlsverify", "TLS support").Default(defaultTLS).Bool(), @@ -42,10 +43,11 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { TLSCacert: kingpin.Flag("tlscacert", "Path to the CA").Default(defaultTLSCACertPath).String(), TLSCert: kingpin.Flag("tlscert", "Path to the TLS certificate file").Default(defaultTLSCertPath).String(), TLSKey: kingpin.Flag("tlskey", "Path to the TLS key").Default(defaultTLSKeyPath).String(), - SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL").Default(defaultSSL).Bool(), - SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(), - SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(), - SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each endpoint snapshot job").Default(defaultSnapshotInterval).String(), + HTTPDisabled: kingpin.Flag("http-disabled", "Serve portainer only on https").Default(defaultHTTPDisabled).Bool(), + SSL: kingpin.Flag("ssl", "Secure Portainer instance using SSL (deprecated)").Default(defaultSSL).Bool(), + SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").String(), + SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").String(), + SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each environment snapshot job").Default(defaultSnapshotInterval).String(), AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(), Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), @@ -92,6 +94,10 @@ func displayDeprecationWarnings(flags *portainer.CLIFlags) { if *flags.NoAnalytics { log.Println("Warning: The --no-analytics flag has been kept to allow migration of instances running a previous version of Portainer with this flag enabled, to version 2.0 where enabling this flag will have no effect.") } + + if *flags.SSL { + log.Println("Warning: SSL is enabled by default and there is no need for the --ssl flag. It has been kept to allow migration of instances running a previous version of Portainer with this flag enabled") + } } func validateEndpointURL(endpointURL string) error { diff --git a/api/cli/defaults.go b/api/cli/defaults.go index e52240ebf..be27b4a93 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -4,6 +4,7 @@ package cli const ( defaultBindAddress = ":9000" + defaultHTTPSBindAddress = ":9443" defaultTunnelServerAddress = "0.0.0.0" defaultTunnelServerPort = "8000" defaultDataDirectory = "/data" @@ -13,6 +14,7 @@ const ( defaultTLSCACertPath = "/certs/ca.pem" defaultTLSCertPath = "/certs/cert.pem" defaultTLSKeyPath = "/certs/key.pem" + defaultHTTPDisabled = "false" defaultSSL = "false" defaultSSLCertPath = "/certs/portainer.crt" defaultSSLKeyPath = "/certs/portainer.key" diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index c7e10f685..89d77ed14 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -2,6 +2,7 @@ package cli const ( defaultBindAddress = ":9000" + defaultHTTPSBindAddress = ":9443" defaultTunnelServerAddress = "0.0.0.0" defaultTunnelServerPort = "8000" defaultDataDirectory = "C:\\data" @@ -11,6 +12,7 @@ const ( defaultTLSCACertPath = "C:\\certs\\ca.pem" defaultTLSCertPath = "C:\\certs\\cert.pem" defaultTLSKeyPath = "C:\\certs\\key.pem" + defaultHTTPDisabled = "false" defaultSSL = "false" defaultSSLCertPath = "C:\\certs\\portainer.crt" defaultSSLKeyPath = "C:\\certs\\portainer.key" diff --git a/api/cmd/portainer/log.go b/api/cmd/portainer/log.go new file mode 100644 index 000000000..5bab3c5de --- /dev/null +++ b/api/cmd/portainer/log.go @@ -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) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 51fa6f192..9eeb62e61 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -13,6 +13,7 @@ import ( "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/docker" + "github.com/portainer/libhelm" "github.com/portainer/portainer/api/exec" "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/git" @@ -23,12 +24,14 @@ import ( "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/edge" "github.com/portainer/portainer/api/internal/snapshot" + "github.com/portainer/portainer/api/internal/ssl" "github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/kubernetes" kubecli "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/ldap" - "github.com/portainer/portainer/api/libcompose" "github.com/portainer/portainer/api/oauth" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks" ) func initCLI() *portainer.CLIFlags { @@ -53,7 +56,7 @@ func initFileService(dataStorePath string) portainer.FileService { return fileService } -func initDataStore(dataStorePath string, fileService portainer.FileService) portainer.DataStore { +func initDataStore(dataStorePath string, fileService portainer.FileService, shutdownCtx context.Context) portainer.DataStore { store, err := bolt.NewStore(dataStorePath, fileService) if err != nil { log.Fatalf("failed creating data store: %v", err) @@ -73,26 +76,37 @@ func initDataStore(dataStorePath string, fileService portainer.FileService) port if err != nil { log.Fatalf("failed migration: %v", err) } + + go shutdownDatastore(shutdownCtx, store) return store } -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 - } - - return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService) +func shutdownDatastore(shutdownCtx context.Context, datastore portainer.DataStore) { + <-shutdownCtx.Done() + datastore.Close() } -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 initComposeStackManager(assetsPath string, configPath string, reverseTunnelService portainer.ReverseTunnelService, proxyManager *proxy.Manager) portainer.ComposeStackManager { + composeWrapper, err := exec.NewComposeStackManager(assetsPath, configPath, proxyManager) + if err != nil { + log.Fatalf("failed creating compose manager: %s", err) + } + + return composeWrapper +} + +func initSwarmStackManager(assetsPath string, configPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) { + return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService) } 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 initHelmPackageManager(assetsPath string) (libhelm.HelmPackageManager, error) { + return libhelm.NewHelmPackageManager(libhelm.HelmConfig{BinaryPath: assetsPath}) +} + func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) { settings, err := dataStore.Settings().Settings() if err != nil { @@ -103,7 +117,7 @@ func initJWTService(dataStore portainer.DataStore) (portainer.JWTService, error) settings.UserSessionTimeout = portainer.DefaultUserSessionTimeout dataStore.Settings().UpdateSettings(settings) } - jwtService, err := jwt.NewService(settings.UserSessionTimeout) + jwtService, err := jwt.NewService(settings.UserSessionTimeout, dataStore) if err != nil { return nil, err } @@ -130,12 +144,29 @@ func initGitService() portainer.GitService { return git.NewService() } +func initSSLService(addr, dataPath, certPath, keyPath string, fileService portainer.FileService, dataStore portainer.DataStore, shutdownTrigger context.CancelFunc) (*ssl.Service, error) { + slices := strings.Split(addr, ":") + host := slices[0] + if host == "" { + host = "0.0.0.0" + } + + sslService := ssl.NewService(fileService, dataStore, shutdownTrigger) + + err := sslService.Init(host, certPath, keyPath) + if err != nil { + return nil, err + } + + return sslService, nil +} + func initDockerClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory { 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) { @@ -150,9 +181,10 @@ func initSnapshotService(snapshotInterval string, dataStore portainer.DataStore, return snapshotService, nil } -func initStatus(flags *portainer.CLIFlags) *portainer.Status { +func initStatus(instanceID string) *portainer.Status { return &portainer.Status{ - Version: portainer.APIVersion, + Version: portainer.APIVersion, + InstanceID: instanceID, } } @@ -176,7 +208,26 @@ func updateSettingsFromFlags(dataStore portainer.DataStore, flags *portainer.CLI settings.BlackListedLabels = *flags.Labels } - return dataStore.Settings().UpdateSettings(settings) + err = dataStore.Settings().UpdateSettings(settings) + if err != nil { + return err + } + + httpEnabled := !*flags.HTTPDisabled + + sslSettings, err := dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + sslSettings.HTTPEnabled = httpEnabled + + err = dataStore.SSLSettings().UpdateSettings(sslSettings) + if err != nil { + return err + } + + return nil } func loadAndParseKeyPair(fileService portainer.FileService, signatureService portainer.DigitalSignatureService) error { @@ -270,7 +321,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore portainer.Dat err := snapshotService.SnapshotEndpoint(endpoint) if err != nil { - log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("http error: environment snapshot error (environment=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) } return dataStore.Endpoint().CreateEndpoint(endpoint) @@ -316,7 +367,7 @@ func createUnsecuredEndpoint(endpointURL string, dataStore portainer.DataStore, err := snapshotService.SnapshotEndpoint(endpoint) if err != nil { - log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("http error: environment snapshot error (environment=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) } return dataStore.Endpoint().CreateEndpoint(endpoint) @@ -333,7 +384,7 @@ func initEndpoint(flags *portainer.CLIFlags, dataStore portainer.DataStore, snap } if len(endpoints) > 0 { - log.Println("Instance already has defined endpoints. Skipping the endpoint defined via CLI.") + log.Println("Instance already has defined environments. Skipping the environment defined via CLI.") return nil } @@ -348,7 +399,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { fileService := initFileService(*flags.Data) - dataStore := initDataStore(*flags.Data, fileService) + dataStore := initDataStore(*flags.Data, fileService, shutdownCtx) if err := dataStore.CheckCurrentEdition(); err != nil { log.Fatal(err) @@ -369,6 +420,16 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { digitalSignatureService := initDigitalSignatureService() + sslService, err := initSSLService(*flags.AddrHTTPS, *flags.Data, *flags.SSLCert, *flags.SSLKey, fileService, dataStore, shutdownTrigger) + if err != nil { + log.Fatal(err) + } + + sslSettings, err := sslService.GetSSLSettings() + if err != nil { + log.Fatalf("failed to get ssl settings: %s", err) + } + err = initKeyPair(fileService, digitalSignatureService) if err != nil { log.Fatalf("failed initializing key pai: %v", err) @@ -382,7 +443,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 { @@ -393,17 +454,28 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { authorizationService := authorization.NewService(dataStore) authorizationService.K8sClientFactory = kubernetesClientFactory - swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService) - if err != nil { - log.Fatalf("failed initializing swarm stack manager: %v", err) - } kubernetesTokenCacheManager := kubeproxy.NewTokenCacheManager() + + kubeConfigService := kubernetes.NewKubeConfigCAService(*flags.AddrHTTPS, sslSettings.CertPath) + proxyManager := proxy.NewManager(dataStore, digitalSignatureService, reverseTunnelService, dockerClientFactory, kubernetesClientFactory, kubernetesTokenCacheManager) - composeStackManager := initComposeStackManager(*flags.Assets, *flags.Data, reverseTunnelService, proxyManager) + dockerConfigPath := fileService.GetDockerConfigPath() + + composeStackManager := initComposeStackManager(*flags.Assets, dockerConfigPath, reverseTunnelService, proxyManager) + + swarmStackManager, err := initSwarmStackManager(*flags.Assets, dockerConfigPath, digitalSignatureService, fileService, reverseTunnelService) + if err != nil { + log.Fatalf("failed initializing swarm stack manager: %s", err) + } kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, digitalSignatureService, *flags.Assets) + helmPackageManager, err := initHelmPackageManager(*flags.Assets) + if err != nil { + log.Fatalf("failed initializing helm package manager: %s", err) + } + if dataStore.IsNew() { err = updateSettingsFromFlags(dataStore, flags) if err != nil { @@ -416,11 +488,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { log.Fatalf("failed loading edge jobs from database: %v", err) } - applicationStatus := initStatus(flags) + applicationStatus := initStatus(instanceID) err = initEndpoint(flags, dataStore, snapshotService) if err != nil { - log.Fatalf("failed initializing endpoint: %v", err) + log.Fatalf("failed initializing environment: %v", err) } adminPasswordHash := "" @@ -461,19 +533,31 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotService) if err != nil { - log.Fatalf("failed starting license service: %s", err) + log.Fatalf("failed starting tunnel server: %s", err) } + sslDBSettings, err := dataStore.SSLSettings().Settings() + if err != nil { + log.Fatalf("failed to fetch ssl settings from DB") + } + + scheduler := scheduler.NewScheduler(shutdownCtx) + stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager) + stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService) + return &http.Server{ AuthorizationService: authorizationService, ReverseTunnelService: reverseTunnelService, Status: applicationStatus, BindAddress: *flags.Addr, + BindAddressHTTPS: *flags.AddrHTTPS, + HTTPEnabled: sslDBSettings.HTTPEnabled, AssetsPath: *flags.Assets, DataStore: dataStore, SwarmStackManager: swarmStackManager, ComposeStackManager: composeStackManager, KubernetesDeployer: kubernetesDeployer, + HelmPackageManager: helmPackageManager, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, @@ -482,25 +566,28 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { GitService: gitService, ProxyManager: proxyManager, KubernetesTokenCacheManager: kubernetesTokenCacheManager, + KubeConfigService: kubeConfigService, SignatureService: digitalSignatureService, SnapshotService: snapshotService, - SSL: *flags.SSL, - SSLCert: *flags.SSLCert, - SSLKey: *flags.SSLKey, + SSLService: sslService, DockerClientFactory: dockerClientFactory, KubernetesClientFactory: kubernetesClientFactory, + Scheduler: scheduler, ShutdownCtx: shutdownCtx, ShutdownTrigger: shutdownTrigger, + StackDeployer: stackDeployer, } } func main() { flags := initCLI() + configureLogger() + for { server := buildServer(flags) - log.Printf("Starting Portainer %s on %s\n", portainer.APIVersion, *flags.Addr) + log.Printf("[INFO] [cmd,main] Starting Portainer version %s\n", portainer.APIVersion) err := server.Start() - log.Printf("Http server exited: %s\n", err) + log.Printf("[INFO] [cmd,main] Http server exited: %s\n", err) } } diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go index 9148eb68d..1cd119fce 100644 --- a/api/crypto/ecdsa.go +++ b/api/crypto/ecdsa.go @@ -22,7 +22,7 @@ const ( ) // ECDSAService is a service used to create digital signatures when communicating with -// an agent based environment. It will automatically generates a key pair using ECDSA or +// an agent based environment(endpoint). It will automatically generates a key pair using ECDSA or // can also reuse an existing ECDSA key pair. type ECDSAService struct { privateKey *ecdsa.PrivateKey diff --git a/api/docker/client.go b/api/docker/client.go index dace4800d..8aa82051b 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -34,15 +34,15 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers } // createClient is a generic function to create a Docker client based on -// a specific endpoint configuration. The nodeName parameter can be used -// with an agent enabled endpoint to target a specific node in an agent cluster. +// a specific environment(endpoint) configuration. The nodeName parameter can be used +// with an agent enabled environment(endpoint) to target a specific node in an agent cluster. func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) { if endpoint.Type == portainer.AzureEnvironment { return nil, errUnsupportedEnvironmentType } else if endpoint.Type == portainer.AgentOnDockerEnvironment { return createAgentClient(endpoint, factory.signatureService, nodeName) } else if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { - return createEdgeClient(endpoint, factory.reverseTunnelService, nodeName) + return createEdgeClient(endpoint, factory.signatureService, factory.reverseTunnelService, nodeName) } if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { @@ -71,13 +71,22 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) { ) } -func createEdgeClient(endpoint *portainer.Endpoint, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) { +func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) { httpCli, err := httpClient(endpoint) if err != nil { return nil, err } - headers := map[string]string{} + signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + headers := map[string]string{ + portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(), + portainer.PortainerAgentSignatureHeader: signature, + } + if nodeName != "" { headers[portainer.PortainerAgentTargetHeader] = nodeName } diff --git a/api/docker/errors.go b/api/docker/errors.go index 80611c8b4..04e415934 100644 --- a/api/docker/errors.go +++ b/api/docker/errors.go @@ -4,5 +4,5 @@ import "errors" // Docker errors var ( - ErrUnableToPingEndpoint = errors.New("Unable to communicate with the endpoint") + ErrUnableToPingEndpoint = errors.New("Unable to communicate with the environment") ) diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go index f3e96ff7e..553a94847 100644 --- a/api/docker/snapshot.go +++ b/api/docker/snapshot.go @@ -12,7 +12,7 @@ import ( "github.com/portainer/portainer/api" ) -// Snapshotter represents a service used to create endpoint snapshots +// Snapshotter represents a service used to create environment(endpoint) snapshots type Snapshotter struct { clientFactory *ClientFactory } @@ -24,7 +24,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter { } } -// CreateSnapshot creates a snapshot of a specific Docker endpoint +// CreateSnapshot creates a snapshot of a specific Docker environment(endpoint) func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.DockerSnapshot, error) { cli, err := snapshotter.clientFactory.CreateClient(endpoint, "") if err != nil { @@ -47,44 +47,44 @@ func snapshot(cli *client.Client, endpoint *portainer.Endpoint) (*portainer.Dock err = snapshotInfo(snapshot, cli) if err != nil { - log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot engine information] [endpoint: %s] [err: %s]", endpoint.Name, err) + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot engine information] [environment: %s] [err: %s]", endpoint.Name, err) } if snapshot.Swarm { err = snapshotSwarmServices(snapshot, cli) if err != nil { - log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot Swarm services] [endpoint: %s] [err: %s]", endpoint.Name, err) + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot Swarm services] [environment: %s] [err: %s]", endpoint.Name, err) } err = snapshotNodes(snapshot, cli) if err != nil { - log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot Swarm nodes] [endpoint: %s] [err: %s]", endpoint.Name, err) + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot Swarm nodes] [environment: %s] [err: %s]", endpoint.Name, err) } } err = snapshotContainers(snapshot, cli) if err != nil { - log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot containers] [endpoint: %s] [err: %s]", endpoint.Name, err) + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot containers] [environment: %s] [err: %s]", endpoint.Name, err) } err = snapshotImages(snapshot, cli) if err != nil { - log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot images] [endpoint: %s] [err: %s]", endpoint.Name, err) + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot images] [environment: %s] [err: %s]", endpoint.Name, err) } err = snapshotVolumes(snapshot, cli) if err != nil { - log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot volumes] [endpoint: %s] [err: %s]", endpoint.Name, err) + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot volumes] [environment: %s] [err: %s]", endpoint.Name, err) } err = snapshotNetworks(snapshot, cli) if err != nil { - log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot networks] [endpoint: %s] [err: %s]", endpoint.Name, err) + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot networks] [environment: %s] [err: %s]", endpoint.Name, err) } err = snapshotVersion(snapshot, cli) if err != nil { - log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot engine version] [endpoint: %s] [err: %s]", endpoint.Name, err) + log.Printf("[WARN] [docker,snapshot] [message: unable to snapshot engine version] [environment: %s] [err: %s]", endpoint.Name, err) } snapshot.Time = time.Now().Unix() diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go new file mode 100644 index 000000000..c3296e37d --- /dev/null +++ b/api/exec/compose_stack.go @@ -0,0 +1,143 @@ +package exec + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/pkg/errors" + + libstack "github.com/portainer/docker-compose-wrapper" + "github.com/portainer/docker-compose-wrapper/compose" + + 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 { + deployer libstack.Deployer + 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) { + deployer, err := compose.NewComposeDeployer(binaryPath, configPath) + if err != nil { + return nil, err + } + + return &ComposeStackManager{ + deployer: deployer, + proxyManager: proxyManager, + }, nil +} + +// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax +func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string { + return portainer.ComposeSyntaxMaxVersion +} + +// Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command +func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + url, proxy, err := manager.fetchEndpointProxy(endpoint) + if err != nil { + return errors.Wrap(err, "failed to featch environment proxy") + } + + if proxy != nil { + defer proxy.Close() + } + + envFilePath, err := createEnvFile(stack) + if err != nil { + return errors.Wrap(err, "failed to create env file") + } + + filePaths := getStackFiles(stack) + err = manager.deployer.Deploy(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFilePath) + return errors.Wrap(err, "failed to deploy a stack") +} + +// Down stops and removes containers, networks, images, and volumes. Wraps `docker-compose down --remove-orphans` command +func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + url, proxy, err := manager.fetchEndpointProxy(endpoint) + if err != nil { + return err + } + if proxy != nil { + defer proxy.Close() + } + + filePaths := getStackFiles(stack) + err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths) + return errors.Wrap(err, "failed to remove a stack") +} + +// 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), "") +} + +func (manager *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 := manager.proxyManager.CreateComposeProxyServer(endpoint) + if err != nil { + return "", nil, err + } + + return fmt.Sprintf("tcp://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 "stack.env", nil +} + +// getStackFiles returns list of stack's confile file paths. +// items in the list would be sanitized according to following criterias: +// 1. no empty paths +// 2. no "../xxx" paths that are trying to escape stack folder +// 3. no dir paths +// 4. root paths would be made relative +func getStackFiles(stack *portainer.Stack) []string { + paths := make([]string, 0, len(stack.AdditionalFiles)+1) + + for _, p := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { + if strings.HasPrefix(p, "/") { + p = `.` + p + } + + if p == `` || p == `.` || strings.HasPrefix(p, `..`) || strings.HasSuffix(p, string(filepath.Separator)) { + continue + } + + paths = append(paths, p) + } + + return paths +} diff --git a/api/exec/compose_wrapper_integration_test.go b/api/exec/compose_stack_integration_test.go similarity index 69% rename from api/exec/compose_wrapper_integration_test.go rename to api/exec/compose_stack_integration_test.go index 42c4af1c1..27209d57d 100644 --- a/api/exec/compose_wrapper_integration_test.go +++ b/api/exec/compose_stack_integration_test.go @@ -1,8 +1,7 @@ -// +build integration - package exec import ( + "context" "fmt" "log" "os" @@ -33,7 +32,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,18 +43,23 @@ 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) + ctx := context.TODO() + + err = w.Up(ctx, 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") } - err = w.Down(stack, endpoint) + err = w.Down(ctx, stack, endpoint) if err != nil { t.Fatalf("Error calling docker-compose down: %s", err) } @@ -63,13 +69,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) } diff --git a/api/exec/compose_stack_test.go b/api/exec/compose_stack_test.go new file mode 100644 index 000000000..0b5dec2a3 --- /dev/null +++ b/api/exec/compose_stack_test.go @@ -0,0 +1,84 @@ +package exec + +import ( + "io/ioutil" + "os" + "path" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +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 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, "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) + } + }) + } +} + +func Test_getStackFiles(t *testing.T) { + stack := &portainer.Stack{ + EntryPoint: "./file", // picks entry point + AdditionalFiles: []string{ + ``, // ignores empty string + `.`, // ignores . + `..`, // ignores .. + `./dir/`, // ignrores paths that end with trailing / + `/with-root-prefix`, // replaces "root" based paths with relative + `./relative`, // keeps relative paths + `../escape`, // prevents dir escape + }, + } + + filePaths := getStackFiles(stack) + assert.ElementsMatch(t, filePaths, []string{`./file`, `./with-root-prefix`, `./relative`}) +} diff --git a/api/exec/compose_wrapper.go b/api/exec/compose_wrapper.go deleted file mode 100644 index 87aef1724..000000000 --- a/api/exec/compose_wrapper.go +++ /dev/null @@ -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 -} diff --git a/api/exec/compose_wrapper_test.go b/api/exec/compose_wrapper_test.go deleted file mode 100644 index caee859ef..000000000 --- a/api/exec/compose_wrapper_test.go +++ /dev/null @@ -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)) - } - }) - } -} diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go index ed4723c2d..975945503 100644 --- a/api/exec/kubernetes_deploy.go +++ b/api/exec/kubernetes_deploy.go @@ -17,11 +17,15 @@ import ( "strings" "time" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "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" ) -// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment. +// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment(endpoint). type KubernetesDeployer struct { binaryPath string dataStore portainer.DataStore @@ -65,7 +69,7 @@ func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *po return tokenManager.GetAdminServiceAccountToken(), nil } - token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID)) + token, err := tokenManager.GetUserServiceAccountToken(int(tokenData.ID), endpoint.ID) if err != nil { return "", err } @@ -76,11 +80,11 @@ func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *po return token, nil } -// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes endpoint. +// Deploy will deploy a Kubernetes manifest inside a specific namespace in a Kubernetes environment(endpoint). // Otherwise it will use kubectl to deploy the manifest. func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) { if endpoint.Type == portainer.KubernetesLocalEnvironment { - token, err := deployer.getToken(request, endpoint, true); + token, err := deployer.getToken(request, endpoint, true) if err != nil { return "", err } @@ -179,7 +183,7 @@ func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *port return "", err } - token, err := deployer.getToken(request, endpoint, false); + token, err := deployer.getToken(request, endpoint, false) if err != nil { return "", err } @@ -229,7 +233,7 @@ func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *port } // ConvertCompose leverages the kompose binary to deploy a compose compliant manifest. -func (deployer *KubernetesDeployer) ConvertCompose(data string) ([]byte, error) { +func (deployer *KubernetesDeployer) ConvertCompose(data []byte) ([]byte, error) { command := path.Join(deployer.binaryPath, "kompose") if runtime.GOOS == "windows" { command = path.Join(deployer.binaryPath, "kompose.exe") @@ -241,7 +245,7 @@ func (deployer *KubernetesDeployer) ConvertCompose(data string) ([]byte, error) var stderr bytes.Buffer cmd := exec.Command(command, args...) cmd.Stderr = &stderr - cmd.Stdin = strings.NewReader(data) + cmd.Stdin = bytes.NewReader(data) output, err := cmd.Output() if err != nil { diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index cf59f7607..6256e92e3 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -8,15 +8,18 @@ import ( "os" "os/exec" "path" + "regexp" "runtime" + "strings" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/stackutils" ) // SwarmStackManager represents a service for managing stacks. type SwarmStackManager struct { binaryPath string - dataPath string + configPath string signatureService portainer.DigitalSignatureService fileService portainer.FileService reverseTunnelService portainer.ReverseTunnelService @@ -24,16 +27,16 @@ type SwarmStackManager struct { // NewSwarmStackManager initializes a new SwarmStackManager service. // It also updates the configuration of the Docker CLI binary. -func NewSwarmStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (*SwarmStackManager, error) { +func NewSwarmStackManager(binaryPath, configPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (*SwarmStackManager, error) { manager := &SwarmStackManager{ binaryPath: binaryPath, - dataPath: dataPath, + configPath: configPath, signatureService: signatureService, fileService: fileService, reverseTunnelService: reverseTunnelService, } - err := manager.updateDockerCLIConfiguration(dataPath) + err := manager.updateDockerCLIConfiguration(manager.configPath) if err != nil { return nil, err } @@ -42,51 +45,47 @@ 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) { - command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) +func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) { + command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint) for _, registry := range registries { if registry.Authentication { registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL) 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. func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { - command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) + command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint) args = append(args, "logout") return runCommandAndCaptureStdErr(command, args, nil, "") } // Deploy executes the docker stack deploy command. func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { - stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) - command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) + filePaths := stackutils.GetStackFilePaths(stack) + command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint) if prune { - args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) + args = append(args, "stack", "deploy", "--prune", "--with-registry-auth") } else { - args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) + args = append(args, "stack", "deploy", "--with-registry-auth") } + args = configureFilePaths(args, filePaths) + args = append(args, stack.Name) + env := make([]string, 0) for _, envvar := range stack.Env { env = append(env, envvar.Name+"="+envvar.Value) } - - stackFolder := path.Dir(stackFilePath) - return runCommandAndCaptureStdErr(command, args, env, stackFolder) + return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath) } // Remove executes the docker stack rm command. func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { - command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) + command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint) args = append(args, "stack", "rm", stack.Name) return runCommandAndCaptureStdErr(command, args, nil, "") } @@ -110,7 +109,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor return nil } -func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portainer.Endpoint) (string, []string) { +func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, configPath string, endpoint *portainer.Endpoint) (string, []string) { // Assume Linux as a default command := path.Join(binaryPath, "docker") @@ -119,7 +118,7 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPa } args := make([]string, 0) - args = append(args, "--config", dataPath) + args = append(args, "--config", configPath) endpointURL := endpoint.URL if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { @@ -146,8 +145,8 @@ func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPa return command, args } -func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string) error { - configFilePath := path.Join(dataPath, "config.json") +func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error { + configFilePath := path.Join(configPath, "config.json") config, err := manager.retrieveConfigurationFromDisk(configFilePath) if err != nil { return err @@ -189,3 +188,15 @@ 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), "") +} + +func configureFilePaths(args []string, filePaths []string) []string { + for _, path := range filePaths { + args = append(args, "--compose-file", path) + } + return args +} diff --git a/api/exec/swarm_stack_test.go b/api/exec/swarm_stack_test.go new file mode 100644 index 000000000..47d28ce2c --- /dev/null +++ b/api/exec/swarm_stack_test.go @@ -0,0 +1,15 @@ +package exec + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfigFilePaths(t *testing.T) { + args := []string{"stack", "deploy", "--with-registry-auth"} + filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"} + expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"} + output := configureFilePaths(args, filePaths) + assert.ElementsMatch(t, expected, output, "wrong output file paths") +} diff --git a/api/exec/utils.go b/api/exec/utils.go deleted file mode 100644 index 75a896f65..000000000 --- a/api/exec/utils.go +++ /dev/null @@ -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 -} diff --git a/api/exec/utils_test.go b/api/exec/utils_test.go deleted file mode 100644 index 38695488a..000000000 --- a/api/exec/utils_test.go +++ /dev/null @@ -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") - } -} diff --git a/api/backup/copy.go b/api/filesystem/copy.go similarity index 63% rename from api/backup/copy.go rename to api/filesystem/copy.go index 6aaefd54c..abf4d33aa 100644 --- a/api/backup/copy.go +++ b/api/filesystem/copy.go @@ -1,4 +1,4 @@ -package backup +package filesystem import ( "errors" @@ -8,7 +8,8 @@ import ( "strings" ) -func copyPath(path string, toDir string) error { +// CopyPath copies file or directory defined by the path to the toDir path +func CopyPath(path string, toDir string) error { info, err := os.Stat(path) if err != nil && errors.Is(err, os.ErrNotExist) { // skip copy if file does not exist @@ -20,17 +21,30 @@ func copyPath(path string, toDir string) error { return copyFile(path, destination) } - return copyDir(path, toDir) + return CopyDir(path, toDir, true) } -func copyDir(fromDir, toDir string) error { +// CopyDir copies contents of fromDir to toDir. +// When keepParent is true, contents will be copied with their immediate parent dir, +// i.e. given /from/dirA and /to/dirB with keepParent == true, result will be /to/dirB/dirA/ +func CopyDir(fromDir, toDir string, keepParent bool) error { cleanedSourcePath := filepath.Clean(fromDir) parentDirectory := filepath.Dir(cleanedSourcePath) err := filepath.Walk(cleanedSourcePath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - destination := filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory)) + var destination string + if keepParent { + destination = filepath.Join(toDir, strings.TrimPrefix(path, parentDirectory)) + } else { + destination = filepath.Join(toDir, strings.TrimPrefix(path, cleanedSourcePath)) + } + + if destination == "" { + return nil + } + if info.IsDir() { return nil // skip directory creations } diff --git a/api/filesystem/copy_test.go b/api/filesystem/copy_test.go new file mode 100644 index 000000000..2fcef9e6b --- /dev/null +++ b/api/filesystem/copy_test.go @@ -0,0 +1,92 @@ +package filesystem + +import ( + "io/ioutil" + "os" + "path" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_copyFile_returnsError_whenSourceDoesNotExist(t *testing.T) { + tmpdir, _ := ioutil.TempDir("", "backup") + defer os.RemoveAll(tmpdir) + + err := copyFile("does-not-exist", tmpdir) + assert.Error(t, err) +} + +func Test_copyFile_shouldMakeAbackup(t *testing.T) { + tmpdir, _ := ioutil.TempDir("", "backup") + defer os.RemoveAll(tmpdir) + + content := []byte("content") + ioutil.WriteFile(path.Join(tmpdir, "origin"), content, 0600) + + err := copyFile(path.Join(tmpdir, "origin"), path.Join(tmpdir, "copy")) + assert.NoError(t, err) + + copyContent, _ := ioutil.ReadFile(path.Join(tmpdir, "copy")) + assert.Equal(t, content, copyContent) +} + +func Test_CopyDir_shouldCopyAllFilesAndDirectories(t *testing.T) { + destination, _ := ioutil.TempDir("", "destination") + defer os.RemoveAll(destination) + err := CopyDir("./testdata/copy_test", destination, true) + assert.NoError(t, err) + + assert.FileExists(t, filepath.Join(destination, "copy_test", "outer")) + assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile")) + assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner")) +} + +func Test_CopyDir_shouldCopyOnlyDirContents(t *testing.T) { + destination, _ := ioutil.TempDir("", "destination") + defer os.RemoveAll(destination) + err := CopyDir("./testdata/copy_test", destination, false) + assert.NoError(t, err) + + assert.FileExists(t, filepath.Join(destination, "outer")) + assert.FileExists(t, filepath.Join(destination, "dir", ".dotfile")) + assert.FileExists(t, filepath.Join(destination, "dir", "inner")) +} + +func Test_CopyPath_shouldSkipWhenNotExist(t *testing.T) { + tmpdir, _ := ioutil.TempDir("", "backup") + defer os.RemoveAll(tmpdir) + + err := CopyPath("does-not-exists", tmpdir) + assert.NoError(t, err) + + assert.NoFileExists(t, tmpdir) +} + +func Test_CopyPath_shouldCopyFile(t *testing.T) { + tmpdir, _ := ioutil.TempDir("", "backup") + defer os.RemoveAll(tmpdir) + + content := []byte("content") + ioutil.WriteFile(path.Join(tmpdir, "file"), content, 0600) + + os.MkdirAll(path.Join(tmpdir, "backup"), 0700) + err := CopyPath(path.Join(tmpdir, "file"), path.Join(tmpdir, "backup")) + assert.NoError(t, err) + + copyContent, err := ioutil.ReadFile(path.Join(tmpdir, "backup", "file")) + assert.NoError(t, err) + assert.Equal(t, content, copyContent) +} + +func Test_CopyPath_shouldCopyDir(t *testing.T) { + destination, _ := ioutil.TempDir("", "destination") + defer os.RemoveAll(destination) + err := CopyPath("./testdata/copy_test", destination) + assert.NoError(t, err) + + assert.FileExists(t, filepath.Join(destination, "copy_test", "outer")) + assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", ".dotfile")) + assert.FileExists(t, filepath.Join(destination, "copy_test", "dir", "inner")) +} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 0a0b6dbbd..3f68bcf53 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -43,6 +43,8 @@ const ( BinaryStorePath = "bin" // EdgeJobStorePath represents the subfolder where schedule files are stored. EdgeJobStorePath = "edge_jobs" + // DockerConfigPath represents the subfolder where docker configuration is stored. + DockerConfigPath = "docker_config" // ExtensionRegistryManagementStorePath represents the subfolder where files related to the // registry management extension are stored. ExtensionRegistryManagementStorePath = "extensions" @@ -50,6 +52,12 @@ const ( CustomTemplateStorePath = "custom_templates" // TempPath represent the subfolder where temporary files are saved TempPath = "tmp" + // SSLCertPath represents the default ssl certificates path + SSLCertPath = "certs" + // DefaultSSLCertFilename represents the default ssl certificate file name + DefaultSSLCertFilename = "cert.pem" + // DefaultSSLKeyFilename represents the default ssl key file name + DefaultSSLKeyFilename = "key.pem" ) // ErrUndefinedTLSFileType represents an error returned on undefined TLS file type @@ -74,6 +82,11 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) { return nil, err } + err = service.createDirectoryInStore(SSLCertPath) + if err != nil { + return nil, err + } + err = service.createDirectoryInStore(TLSStorePath) if err != nil { return nil, err @@ -89,6 +102,11 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) { return nil, err } + err = service.createDirectoryInStore(DockerConfigPath) + if err != nil { + return nil, err + } + return service, nil } @@ -97,6 +115,11 @@ func (service *Service) GetBinaryFolder() string { return path.Join(service.fileStorePath, BinaryStorePath) } +// GetDockerConfigPath returns the full path to the docker config store on the filesystem +func (service *Service) GetDockerConfigPath() string { + return path.Join(service.fileStorePath, DockerConfigPath) +} + // RemoveDirectory removes a directory on the filesystem. func (service *Service) RemoveDirectory(directoryPath string) error { return os.RemoveAll(directoryPath) @@ -108,6 +131,66 @@ func (service *Service) GetStackProjectPath(stackIdentifier string) string { return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier) } +// Copy copies the file on fromFilePath to toFilePath +// if toFilePath exists func will fail unless deleteIfExists is true +func (service *Service) Copy(fromFilePath string, toFilePath string, deleteIfExists bool) error { + exists, err := service.FileExists(fromFilePath) + if err != nil { + return err + } + + if !exists { + return errors.New("File doesn't exist") + } + + finput, err := os.Open(fromFilePath) + if err != nil { + return err + } + + defer finput.Close() + + exists, err = service.FileExists(toFilePath) + if err != nil { + return err + } + + if exists { + if !deleteIfExists { + return errors.New("Destination file exists") + } + + err := os.Remove(toFilePath) + if err != nil { + return err + } + } + + foutput, err := os.Create(toFilePath) + if err != nil { + return err + } + + defer foutput.Close() + + buf := make([]byte, 1024) + for { + n, err := finput.Read(buf) + if err != nil && err != io.EOF { + return err + } + if n == 0 { + break + } + + if _, err := foutput.Write(buf[:n]); err != nil { + return err + } + } + + return nil +} + // StoreStackFileFromBytes creates a subfolder in the ComposeStorePath and stores a new file from bytes. // It returns the path to the folder where the file is stored. func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) { @@ -205,7 +288,7 @@ func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer. return path.Join(service.fileStorePath, tlsFilePath), nil } -// GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint. +// GetPathForTLSFile returns the absolute path to a specific TLS file for an environment(endpoint). func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSFileType) (string, error) { var fileName string switch fileType { @@ -507,6 +590,58 @@ func (service *Service) GetDatastorePath() string { return service.dataStorePath } +func (service *Service) wrapFileStore(filepath string) string { + return path.Join(service.fileStorePath, filepath) +} + +func defaultCertPathUnderFileStore() (string, string) { + certPath := path.Join(SSLCertPath, DefaultSSLCertFilename) + keyPath := path.Join(SSLCertPath, DefaultSSLKeyFilename) + return certPath, keyPath +} + +// GetDefaultSSLCertsPath returns the ssl certs path +func (service *Service) GetDefaultSSLCertsPath() (string, string) { + certPath, keyPath := defaultCertPathUnderFileStore() + return service.wrapFileStore(certPath), service.wrapFileStore(keyPath) +} + +// StoreSSLCertPair stores a ssl certificate pair +func (service *Service) StoreSSLCertPair(cert, key []byte) (string, string, error) { + certPath, keyPath := defaultCertPathUnderFileStore() + + r := bytes.NewReader(cert) + err := service.createFileInStore(certPath, r) + if err != nil { + return "", "", err + } + + r = bytes.NewReader(key) + err = service.createFileInStore(keyPath, r) + if err != nil { + return "", "", err + } + + return service.wrapFileStore(certPath), service.wrapFileStore(keyPath), nil +} + +// CopySSLCertPair copies a ssl certificate pair +func (service *Service) CopySSLCertPair(certPath, keyPath string) (string, string, error) { + defCertPath, defKeyPath := service.GetDefaultSSLCertsPath() + + err := service.Copy(certPath, defCertPath, false) + if err != nil { + return "", "", err + } + + err = service.Copy(keyPath, defKeyPath, false) + if err != nil { + return "", "", err + } + + return defCertPath, defKeyPath, nil +} + // FileExists checks for the existence of the specified file. func FileExists(filePath string) (bool, error) { if _, err := os.Stat(filePath); err != nil { diff --git a/api/backup/test_assets/copy_test/dir/.dotfile b/api/filesystem/testdata/copy_test/dir/.dotfile similarity index 100% rename from api/backup/test_assets/copy_test/dir/.dotfile rename to api/filesystem/testdata/copy_test/dir/.dotfile diff --git a/api/backup/test_assets/copy_test/dir/inner b/api/filesystem/testdata/copy_test/dir/inner similarity index 100% rename from api/backup/test_assets/copy_test/dir/inner rename to api/filesystem/testdata/copy_test/dir/inner diff --git a/api/backup/test_assets/copy_test/outer b/api/filesystem/testdata/copy_test/outer similarity index 100% rename from api/backup/test_assets/copy_test/outer rename to api/filesystem/testdata/copy_test/outer diff --git a/api/git/azure.go b/api/git/azure.go index 78f10e52d..417d5db4b 100644 --- a/api/git/azure.go +++ b/api/git/azure.go @@ -2,15 +2,17 @@ package git import ( "context" + "encoding/json" "fmt" - "github.com/pkg/errors" - "github.com/portainer/portainer/api/archive" "io" "io/ioutil" "net/http" "net/url" "os" "strings" + + "github.com/pkg/errors" + "github.com/portainer/portainer/api/archive" ) const ( @@ -37,7 +39,7 @@ type azureDownloader struct { func NewAzureDownloader(client *http.Client) *azureDownloader { return &azureDownloader{ - client: client, + client: client, baseUrl: "https://dev.azure.com", } } @@ -100,6 +102,57 @@ func (a *azureDownloader) downloadZipFromAzureDevOps(ctx context.Context, option return zipFile.Name(), nil } +func (a *azureDownloader) latestCommitID(ctx context.Context, options fetchOptions) (string, error) { + config, err := parseUrl(options.repositoryUrl) + if err != nil { + return "", errors.WithMessage(err, "failed to parse url") + } + + refsUrl, err := a.buildRefsUrl(config, options.referenceName) + if err != nil { + return "", errors.WithMessage(err, "failed to build azure refs url") + } + + req, err := http.NewRequestWithContext(ctx, "GET", refsUrl, nil) + if options.username != "" || options.password != "" { + req.SetBasicAuth(options.username, options.password) + } else if config.username != "" || config.password != "" { + req.SetBasicAuth(config.username, config.password) + } + + if err != nil { + return "", errors.WithMessage(err, "failed to create a new HTTP request") + } + + resp, err := a.client.Do(req) + if err != nil { + return "", errors.WithMessage(err, "failed to make an HTTP request") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get repository refs with a status \"%v\"", resp.Status) + } + + var refs struct { + Value []struct { + Name string `json:"name"` + ObjectId string `json:"objectId"` + } + } + if err := json.NewDecoder(resp.Body).Decode(&refs); err != nil { + return "", errors.Wrap(err, "could not parse Azure Refs response") + } + + for _, ref := range refs.Value { + if strings.EqualFold(ref.Name, options.referenceName) { + return ref.ObjectId, nil + } + } + + return "", errors.Errorf("could not find ref %q in the repository", options.referenceName) +} + func parseUrl(rawUrl string) (*azureOptions, error) { if strings.HasPrefix(rawUrl, "https://") || strings.HasPrefix(rawUrl, "http://") { return parseHttpUrl(rawUrl) @@ -193,6 +246,27 @@ func (a *azureDownloader) buildDownloadUrl(config *azureOptions, referenceName s return u.String(), nil } +func (a *azureDownloader) buildRefsUrl(config *azureOptions, referenceName string) (string, error) { + rawUrl := fmt.Sprintf("%s/%s/%s/_apis/git/repositories/%s/refs", + a.baseUrl, + url.PathEscape(config.organisation), + url.PathEscape(config.project), + url.PathEscape(config.repository)) + u, err := url.Parse(rawUrl) + + if err != nil { + return "", errors.Wrapf(err, "failed to parse refs url path %s", rawUrl) + } + + // filterContains=main&api-version=6.0 + q := u.Query() + q.Set("filterContains", formatReferenceName(referenceName)) + q.Set("api-version", "6.0") + u.RawQuery = q.Encode() + + return u.String(), nil +} + const ( branchPrefix = "refs/heads/" tagPrefix = "refs/tags/" diff --git a/api/git/azure_integration_test.go b/api/git/azure_integration_test.go index 6d684d877..200747daa 100644 --- a/api/git/azure_integration_test.go +++ b/api/git/azure_integration_test.go @@ -78,6 +78,18 @@ func TestService_ClonePrivateRepository_Azure(t *testing.T) { assert.FileExists(t, filepath.Join(dst, "README.md")) } +func TestService_LatestCommitID_Azure(t *testing.T) { + ensureIntegrationTest(t) + + pat := getRequiredValue(t, "AZURE_DEVOPS_PAT") + service := NewService() + + repositoryUrl := "https://portainer.visualstudio.com/Playground/_git/dev_integration" + id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", "", pat) + assert.NoError(t, err) + assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") +} + func getRequiredValue(t *testing.T, name string) string { value, ok := os.LookupEnv(name) if !ok { diff --git a/api/git/azure_test.go b/api/git/azure_test.go index 18417e9e6..b95dc981e 100644 --- a/api/git/azure_test.go +++ b/api/git/azure_test.go @@ -2,11 +2,12 @@ package git import ( "context" - "github.com/stretchr/testify/assert" "net/http" "net/http/httptest" "net/url" "testing" + + "github.com/stretchr/testify/assert" ) func Test_buildDownloadUrl(t *testing.T) { @@ -27,6 +28,23 @@ func Test_buildDownloadUrl(t *testing.T) { } } +func Test_buildRefsUrl(t *testing.T) { + a := NewAzureDownloader(nil) + u, err := a.buildRefsUrl(&azureOptions{ + organisation: "organisation", + project: "project", + repository: "repository", + }, "refs/heads/main") + + expectedUrl, _ := url.Parse("https://dev.azure.com/organisation/project/_apis/git/repositories/repository/refs?filterContains=main&api-version=6.0") + actualUrl, _ := url.Parse(u) + assert.NoError(t, err) + assert.Equal(t, expectedUrl.Host, actualUrl.Host) + assert.Equal(t, expectedUrl.Scheme, actualUrl.Scheme) + assert.Equal(t, expectedUrl.Path, actualUrl.Path) + assert.Equal(t, expectedUrl.Query(), actualUrl.Query()) +} + func Test_parseAzureUrl(t *testing.T) { type args struct { url string @@ -248,3 +266,110 @@ func Test_azureDownloader_downloadZipFromAzureDevOps(t *testing.T) { }) } } + +func Test_azureDownloader_latestCommitID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := `{ + "value": [ + { + "name": "refs/heads/feature/calcApp", + "objectId": "ffe9cba521f00d7f60e322845072238635edb451", + "creator": { + "displayName": "Normal Paulk", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "_links": { + "avatar": { + "href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5" + } + }, + "id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "uniqueName": "dev@mailserver.com", + "imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5" + }, + "url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2FcalcApp" + }, + { + "name": "refs/heads/feature/replacer", + "objectId": "917131a709996c5cfe188c3b57e9a6ad90e8b85c", + "creator": { + "displayName": "Normal Paulk", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "_links": { + "avatar": { + "href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5" + } + }, + "id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "uniqueName": "dev@mailserver.com", + "imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5" + }, + "url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Ffeature%2Freplacer" + }, + { + "name": "refs/heads/master", + "objectId": "ffe9cba521f00d7f60e322845072238635edb451", + "creator": { + "displayName": "Normal Paulk", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "_links": { + "avatar": { + "href": "https://dev.azure.com/fabrikam/_apis/GraphProfile/MemberAvatars/aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5" + } + }, + "id": "ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "uniqueName": "dev@mailserver.com", + "imageUrl": "https://dev.azure.com/fabrikam/_api/_common/identityImage?id=ac5aaba6-a66a-4e1d-b508-b060ec624fa9", + "descriptor": "aad.YmFjMGYyZDctNDA3ZC03OGRhLTlhMjUtNmJhZjUwMWFjY2U5" + }, + "url": "https://dev.azure.com/fabrikam/7484f783-66a3-4f27-b7cd-6b08b0b077ed/_apis/git/repositories/d3d1760b-311c-4175-a726-20dfc6a7f885/refs?filter=heads%2Fmaster" + } + ], + "count": 3 + }` + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) + defer server.Close() + + a := &azureDownloader{ + client: server.Client(), + baseUrl: server.URL, + } + + tests := []struct { + name string + args fetchOptions + want string + wantErr bool + }{ + { + name: "should be able to parse response", + args: fetchOptions{ + referenceName: "refs/heads/master", + repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"}, + want: "ffe9cba521f00d7f60e322845072238635edb451", + wantErr: false, + }, + { + name: "should be able to parse response", + args: fetchOptions{ + referenceName: "refs/heads/unknown", + repositoryUrl: "https://dev.azure.com/Organisation/Project/_git/Repository"}, + want: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, err := a.latestCommitID(context.Background(), tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("azureDownloader.latestCommitID() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.want, id) + }) + } +} diff --git a/api/git/git.go b/api/git/git.go index 7887f7d95..57c8eb106 100644 --- a/api/git/git.go +++ b/api/git/git.go @@ -6,16 +6,26 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/pkg/errors" "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport/client" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/storage/memory" ) +type fetchOptions struct { + repositoryUrl string + username string + password string + referenceName string +} + type cloneOptions struct { repositoryUrl string username string @@ -26,6 +36,7 @@ type cloneOptions struct { type downloader interface { download(ctx context.Context, dst string, opt cloneOptions) error + latestCommitID(ctx context.Context, opt fetchOptions) (string, error) } type gitClient struct { @@ -36,13 +47,7 @@ func (c gitClient) download(ctx context.Context, dst string, opt cloneOptions) e gitOptions := git.CloneOptions{ URL: opt.repositoryUrl, Depth: opt.depth, - } - - if opt.password != "" || opt.username != "" { - gitOptions.Auth = &githttp.BasicAuth{ - Username: opt.username, - Password: opt.password, - } + Auth: getAuth(opt.username, opt.password), } if opt.referenceName != "" { @@ -62,6 +67,44 @@ func (c gitClient) download(ctx context.Context, dst string, opt cloneOptions) e return nil } +func (c gitClient) latestCommitID(ctx context.Context, opt fetchOptions) (string, error) { + remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ + Name: "origin", + URLs: []string{opt.repositoryUrl}, + }) + + listOptions := &git.ListOptions{ + Auth: getAuth(opt.username, opt.password), + } + + refs, err := remote.List(listOptions) + if err != nil { + return "", errors.Wrap(err, "failed to list repository refs") + } + + for _, ref := range refs { + if strings.EqualFold(ref.Name().String(), opt.referenceName) { + return ref.Hash().String(), nil + } + } + + return "", errors.Errorf("could not find ref %q in the repository", opt.referenceName) +} + +func getAuth(username, password string) *githttp.BasicAuth { + if password != "" { + if username == "" { + username = "token" + } + + return &githttp.BasicAuth{ + Username: username, + Password: password, + } + } + return nil +} + // Service represents a service for managing Git. type Service struct { httpsCli *http.Client @@ -74,6 +117,7 @@ func NewService() *Service { httpsCli := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + Proxy: http.ProxyFromEnvironment, }, Timeout: 300 * time.Second, } @@ -108,3 +152,19 @@ func (service *Service) cloneRepository(destination string, options cloneOptions return service.git.download(context.TODO(), destination, options) } + +// LatestCommitID returns SHA1 of the latest commit of the specified reference +func (service *Service) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) { + options := fetchOptions{ + repositoryUrl: repositoryURL, + username: username, + password: password, + referenceName: referenceName, + } + + if isAzureUrl(options.repositoryUrl) { + return service.azure.latestCommitID(context.TODO(), options) + } + + return service.git.latestCommitID(context.TODO(), options) +} diff --git a/api/git/git_integration_test.go b/api/git/git_integration_test.go index d35ba8d52..6f123c130 100644 --- a/api/git/git_integration_test.go +++ b/api/git/git_integration_test.go @@ -12,7 +12,7 @@ import ( func TestService_ClonePrivateRepository_GitHub(t *testing.T) { ensureIntegrationTest(t) - pat := getRequiredValue(t, "GITHUB_PAT") + accessToken := getRequiredValue(t, "GITHUB_PAT") username := getRequiredValue(t, "GITHUB_USERNAME") service := NewService() @@ -21,7 +21,20 @@ func TestService_ClonePrivateRepository_GitHub(t *testing.T) { defer os.RemoveAll(dst) repositoryUrl := "https://github.com/portainer/private-test-repository.git" - err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, pat) + err = service.CloneRepository(dst, repositoryUrl, "refs/heads/main", username, accessToken) assert.NoError(t, err) assert.FileExists(t, filepath.Join(dst, "README.md")) } + +func TestService_LatestCommitID_GitHub(t *testing.T) { + ensureIntegrationTest(t) + + accessToken := getRequiredValue(t, "GITHUB_PAT") + username := getRequiredValue(t, "GITHUB_USERNAME") + service := NewService() + + repositoryUrl := "https://github.com/portainer/private-test-repository.git" + id, err := service.LatestCommitID(repositoryUrl, "refs/heads/main", username, accessToken) + assert.NoError(t, err) + assert.NotEmpty(t, id, "cannot guarantee commit id, but it should be not empty") +} diff --git a/api/git/git_test.go b/api/git/git_test.go index 14878b304..11ff5e5c8 100644 --- a/api/git/git_test.go +++ b/api/git/git_test.go @@ -105,7 +105,19 @@ func Test_cloneRepository(t *testing.T) { }) assert.NoError(t, err) - assert.Equal(t, 3, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth") + assert.Equal(t, 4, getCommitHistoryLength(t, err, dir), "cloned repo has incorrect depth") +} + +func Test_latestCommitID(t *testing.T) { + service := Service{git: gitClient{preserveGitDirectory: true}} // no need for http client since the test access the repo via file system. + + repositoryURL := bareRepoDir + referenceName := "refs/heads/main" + + id, err := service.LatestCommitID(repositoryURL, referenceName, "", "") + + assert.NoError(t, err) + assert.Equal(t, "68dcaa7bd452494043c64252ab90db0f98ecf8d2", id) } func getCommitHistoryLength(t *testing.T, err error, dir string) int { @@ -137,6 +149,10 @@ func (t *testDownloader) download(_ context.Context, _ string, _ cloneOptions) e return nil } +func (t *testDownloader) latestCommitID(_ context.Context, _ fetchOptions) (string, error) { + return "", nil +} + func Test_cloneRepository_azure(t *testing.T) { tests := []struct { name string diff --git a/api/git/testdata/azure-repo copy.zip b/api/git/testdata/azure-repo copy.zip new file mode 100644 index 000000000..d4b53d0b8 Binary files /dev/null and b/api/git/testdata/azure-repo copy.zip differ diff --git a/api/git/testdata/test-clone-git-repo.tar.gz b/api/git/testdata/test-clone-git-repo.tar.gz index ca63d337c..cba76a0d8 100644 Binary files a/api/git/testdata/test-clone-git-repo.tar.gz and b/api/git/testdata/test-clone-git-repo.tar.gz differ diff --git a/api/git/types/types.go b/api/git/types/types.go index 055222700..d24283b52 100644 --- a/api/git/types/types.go +++ b/api/git/types/types.go @@ -1,10 +1,20 @@ package gittypes +// RepoConfig represents a configuration for a repo type RepoConfig struct { // The repo url - URL string `example:"https://github.com/portainer/portainer-ee.git"` + URL string `example:"https://github.com/portainer/portainer.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"` + // Git credentials + Authentication *GitAuthentication + // Repository hash + ConfigHash string `example:"bc4c183d756879ea4d173315338110b31004b8e0"` +} + +type GitAuthentication struct { + Username string + Password string } diff --git a/api/go.mod b/api/go.mod index 82a130f3c..39f55db29 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,17 +1,23 @@ module github.com/portainer/portainer/api -go 1.13 +go 1.16 require ( + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.4.16 - github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a + github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect + github.com/alecthomas/units v0.0.0-20210208195552-ff826a37aa15 // indirect + github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 github.com/boltdb/bolt v1.3.1 github.com/containerd/containerd v1.3.1 // indirect github.com/coreos/go-semver v0.3.0 github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/docker/cli v0.0.0-20191126203649-54d085b857e9 - github.com/docker/docker v0.0.0-00010101000000-000000000000 + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0 + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.4.0 // indirect github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 github.com/go-git/go-git/v5 v5.3.0 github.com/go-ldap/ldap/v3 v3.1.8 @@ -21,19 +27,28 @@ require ( github.com/gorilla/websocket v1.4.1 github.com/joho/godotenv v1.3.0 github.com/jpillora/chisel v0.0.0-20190724232113-f3a8df20e389 - github.com/json-iterator/go v1.1.8 + github.com/json-iterator/go v1.1.10 github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c github.com/mattn/go-shellwords v1.0.6 // indirect github.com/mitchellh/mapstructure v1.1.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.0.1 // indirect github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6 github.com/pkg/errors v0.9.1 - github.com/portainer/libcompose v0.5.3 - github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 + github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1 + github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a + github.com/portainer/libhelm v0.0.0-20210906035629-b5635edd5d97 github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 - github.com/stretchr/testify v1.6.1 + github.com/robfig/cron/v3 v3.0.1 + github.com/sirupsen/logrus v1.8.1 + github.com/stretchr/testify v1.7.0 + github.com/xeipuuv/gojsonschema v1.2.0 // indirect 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-20210107192922-496545a6307b + gotest.tools v2.2.0+incompatible // indirect k8s.io/api v0.17.2 k8s.io/apimachinery v0.17.2 k8s.io/client-go v0.17.2 diff --git a/api/go.sum b/api/go.sum index 37a687dde..cffccad43 100644 --- a/api/go.sum +++ b/api/go.sum @@ -1,8 +1,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8= -github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= @@ -11,40 +11,31 @@ github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxB github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Microsoft/go-winio v0.3.8/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/go-winio v0.4.16 h1:FtSW/jqD+l4ba5iPBj9CODVtgfYAD8w2wS923g/cFDk= github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= -github.com/Microsoft/hcsshim v0.8.6 h1:ZfF0+zZeYdzMIVMZHKtDKJvLHj76XCuVae/jNkjj0IA= -github.com/Microsoft/hcsshim v0.8.6/go.mod h1:Op3hHsoHPAvb6lceZHDtd9OkTew38wNoXnJs8iY7rUg= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20210208195552-ff826a37aa15 h1:AUNCr9CiJuwrRYS3XieqF+Z9B9gNxo/eANAJCF2eiN4= +github.com/alecthomas/units v0.0.0-20210208195552-ff826a37aa15/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 h1:axBiC50cNZOs7ygH5BgQp4N+aYrZ2DNpWZ1KG3VOSOM= github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2/go.mod h1:jnzFpU88PccN/tPPhCpnNU8mZphvKxYM9lLNkd8e+os= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= -github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= -github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535 h1:4daAzAu0S6Vi7/lbWECcX0j45yZReDZ56BQsrVBOEEY= +github.com/asaskevich/govalidator v0.0.0-20200428143746-21a406dcc535/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/containerd/containerd v1.3.1 h1:LdbWxLhkAIxGO7h3mATHkyav06WuDs/yTWxIljJOTks= github.com/containerd/containerd v1.3.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA= -github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc h1:TP+534wVlf61smEIq1nwLLAjQVEK2EADoW3CX9AuT+8= -github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -56,23 +47,16 @@ github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9 h1:74lLNRzvsdIlkTgfD github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/docker/cli v0.0.0-20190711175710-5b38d82aa076/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v0.0.0-20191126203649-54d085b857e9 h1:Q6D6b2iRKhvtL3Wj9p0SyPOvUDJ1ht62mbiBoNJ3Aus= github.com/docker/cli v0.0.0-20191126203649-54d085b857e9/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= -github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ= -github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y= github.com/docker/engine v1.4.2-0.20200204220554-5f6d6f3f2203 h1:QeBh8wW8pIZKlXxlMOQ8hSCMdJA+2Z/bD/iDyCAS8XU= github.com/docker/engine v1.4.2-0.20200204220554-5f6d6f3f2203/go.mod h1:3CPr2caMgTHxxIAZgEMd3uLYPDlRvPqCpyeRf6ncPcY= -github.com/docker/go-connections v0.3.0 h1:3lOnM9cSzgGwx8VfK/NGOW5fLQ0GjIlCkaktF+n1M6o= -github.com/docker/go-connections v0.3.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= -github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82 h1:X0fj836zx99zFu83v/M79DuBn84IL/Syx1SY6Y5ZEMA= -github.com/docker/go-metrics v0.0.0-20181218153428-b84716841b82/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4= -github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96 h1:cenwrSVm+Z7QLSV/BsnenAOcDXdX4cMv4wP0B/5QbPg= github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= github.com/elazarl/goproxy v0.0.0-20170405201442-c4fc26588b6e h1:p1yVGRW3nmb85p1Sh1ZJSDm4A4iKLS5QNbvUHMgGu/M= @@ -82,7 +66,6 @@ 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= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -102,20 +85,15 @@ github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12 h1:PbK github.com/go-git/go-git-fixtures/v4 v4.0.2-0.20200613231340-f56387b50c12/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= github.com/go-git/go-git/v5 v5.3.0 h1:8WKMtJR2j8RntEXR/uvTKagfEt4GYlwQ7mntE4+0GWc= github.com/go-git/go-git/v5 v5.3.0/go.mod h1:xdX4bWJ48aOrdhnl2XqHYstHbbp6+LFS4r4X+lNVprw= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-ldap/ldap/v3 v3.1.8 h1:5vU/2jOh9HqprwXp8aF915s9p6Z8wmbSEVF7/gdTFhM= github.com/go-ldap/ldap/v3 v3.1.8/go.mod h1:5Zun81jBTabRaI8lzN7E1JjyEl1g6zI6u9pd8luAK4Q= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= github.com/go-openapi/jsonreference v0.0.0-20160704190145-13c6e3589ad9/go.mod h1:W3Z9FmVs9qj+KR4zFKmDPGiLdk1D9Rlm7cyMvf57TTg= github.com/go-openapi/spec v0.0.0-20160808142527-6aced65f8501/go.mod h1:J8+jY1nAiCcj+friV/PDoE1/3eeccG9LYBs0tYvLOWc= github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dpr1UfpPtxFw+EFuQ41HhCWZfha5jSVRG7C7I= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d h1:3PaI8p3seN09VjbTYC/QWlUZdZ1qS1zGjy7LH2Wt07I= github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -124,7 +102,6 @@ github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfb github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v0.0.0-20161109072736-4bd1920723d7/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -142,8 +119,6 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+ github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d h1:7XGaL1e6bYS1yIonGp9761ExpPPV1ui0SAC59Yube9k= github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8= -github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= -github.com/gorilla/mux v0.0.0-20160317213430-0eeaf8392f5b/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= @@ -174,25 +149,20 @@ github.com/jpillora/requestlog v0.0.0-20181015073026-df8817be5f82/go.mod h1:w8bu github.com/jpillora/sizestr v0.0.0-20160130011556-e2ea2fa42fb9 h1:0c9jcgBtHRtDU//jTrcCgWG6UHjMZytiq/3WhraNgUM= github.com/jpillora/sizestr v0.0.0-20160130011556-e2ea2fa42fb9/go.mod h1:1ffp+CRe0eAwwRb0/BownUAjMBsmTLwgAvRbfj9dRwE= github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.8 h1:QiWkFLKq0T7mpzwOTu6BzNDbfTE8OLrYhVKYMLF46Ok= github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= 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= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pty v0.0.0-20150511174710-5cf931ef8f76/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -200,8 +170,6 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattn/go-shellwords v1.0.6 h1:9Jok5pILi5S1MnDirGVTufYGtksUs/V2BWUP3ZkeUUI= github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= -github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= @@ -213,10 +181,9 @@ github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lN github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c h1:nXxl5PrvVm2L/wCy8dQu6DMTwH4oIuGN8GJDAlqDdVE= -github.com/morikuni/aec v0.0.0-20170113033406-39771216ff4c/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= 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= @@ -226,47 +193,36 @@ github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+ 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= -github.com/opencontainers/image-spec v0.0.0-20170515205857-f03dbe35d449 h1:Aq8iG72akPb/kszE7ksZ5ldV+JYPYii/KZOxlpJF07s= -github.com/opencontainers/image-spec v0.0.0-20170515205857-f03dbe35d449/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= -github.com/opencontainers/runc v0.0.0-20161109192122-51371867a01c h1:iOMba/KmaXgSX5PFKu1u6s+DZXiq+EzPayawa76w6aA= -github.com/opencontainers/runc v0.0.0-20161109192122-51371867a01c/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.0.1 h1:JMemWkRwHx4Zj+fVxWoMCFm/8sYGGrUVojFA6h/TRcI= +github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6 h1:lNCW6THrCKBiJBpz8kbVGjC7MgdCGKwuvBgc7LoD6sw= github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/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= -github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2/go.mod h1:/wIeGwJOMYc1JplE/OvYMO5korce39HddIfI8VKGyAM= +github.com/portainer/docker-compose-wrapper v0.0.0-20210909011155-9ff375eac059 h1:98v0k3x3ZXa09NaHP/HmSA83rcN8cuE/zTKo6xvNmoM= +github.com/portainer/docker-compose-wrapper v0.0.0-20210909011155-9ff375eac059/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c= +github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1 h1:0ZGSu3Atz7RHMDsoITHV676igRfsb51mlgELGo37ELU= +github.com/portainer/docker-compose-wrapper v0.0.0-20210909083948-8be0d98451a1/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c= +github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM= +github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE= +github.com/portainer/libhelm v0.0.0-20210906035629-b5635edd5d97 h1:ZcRVgWHTac8V7WU9TUBr73H3e5ajVFYTPjPl9TWULDA= +github.com/portainer/libhelm v0.0.0-20210906035629-b5635edd5d97/go.mod h1:YvYAk7krKTzB+rFwDr0jQ3sQu2BtiXK1AR0sZH7nhJA= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= -github.com/prometheus/client_golang v1.1.0 h1:BQ53HtBmfOitExawJ6LokA4x8ov/z0SYYb0+HxJfRI8= -github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90 h1:S/YWwWx/RA8rT8tKFRuGUZhuA90OyIBpPCXkcbwU8DE= -github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= -github.com/prometheus/common v0.6.0 h1:kRhiuYSXR3+uv2IbVbZhUxK5zVD/2pp3Gd2PpvPkpEo= -github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= -github.com/prometheus/procfs v0.0.3 h1:CTwfnzjQ+8dS6MhHHu4YswVAD99sL2wjPqP+VkURmKE= -github.com/prometheus/procfs v0.0.3/go.mod h1:4A/X28fw3Fc593LaREMrKMqOKvUAntwMDaekg4FpcdQ= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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= @@ -277,21 +233,19 @@ 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= github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.1.0 h1:ngVtJC9TY/lg0AA/1k48FYhBrhRoFlEmWzsehpNAaZg= -github.com/xeipuuv/gojsonschema v1.1.0/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -303,18 +257,15 @@ golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191004110552-13f9640d40b9/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210326060303-6b1517762897 h1:KrsHThm5nFk34YtATK1LsThyGhGbGe1olrte/HInHvs= @@ -327,8 +278,6 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456 h1:ng0gs1AKnRRuEMZoTLLlbOd+C17zUDepwGQBb/n+JVg= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= @@ -347,9 +296,7 @@ golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -359,9 +306,8 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7 h1:ZUjXAXmrAyrmmCPHgCA/vChHcpsX27MZ3yBonD/z1KE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.22.1 h1:/7cs52RnTJmD43s3uxzlq2U7nqVTd/37viQwMrMNlOM= -google.golang.org/grpc v1.22.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -383,13 +329,13 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= k8s.io/api v0.17.2 h1:NF1UFXcKN7/OOv1uxdRz3qfra8AHsPav5M93hlV9+Dc= k8s.io/api v0.17.2/go.mod h1:BS9fjjLc4CMuqfSO8vgbHPKMt5+SF0ET6u/RVDihTo4= k8s.io/apimachinery v0.17.2 h1:hwDQQFbdRlpnnsR64Asdi55GyCaIP/3WQpMmbNBeWr4= diff --git a/api/http/client/client.go b/api/http/client/client.go index ba185950a..c789854a4 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -102,7 +102,7 @@ func Get(url string, timeout int) ([]byte, error) { return body, nil } -// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment +// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment(endpoint) // using the specified host and optional TLS configuration. // It uses a new Http.Client for each operation. func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) { diff --git a/api/http/errors/errors.go b/api/http/errors/errors.go index 2e6aeceb5..df896fde0 100644 --- a/api/http/errors/errors.go +++ b/api/http/errors/errors.go @@ -3,8 +3,8 @@ package errors import "errors" var ( - // ErrEndpointAccessDenied Access denied to endpoint error - ErrEndpointAccessDenied = errors.New("Access denied to endpoint") + // ErrEndpointAccessDenied Access denied to environment(endpoint) error + ErrEndpointAccessDenied = errors.New("Access denied to environment") // ErrUnauthorized Unauthorized error ErrUnauthorized = errors.New("Unauthorized") // ErrResourceAccessDenied Access denied to resource error diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index ae2201855..500854a0f 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -39,7 +39,7 @@ func (payload *authenticatePayload) Validate(r *http.Request) error { // @id AuthenticateUser // @summary Authenticate -// @description Use this endpoint to authenticate against Portainer using a username and password. +// @description Use this environment(endpoint) to authenticate against Portainer using a username and password. // @tags auth // @accept json // @produce json diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index e01ba01df..60727396b 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -21,7 +21,7 @@ import ( // @description **Access policy**: authenticated // @tags custom_templates // @security jwt -// @accept json, multipart/form-data +// @accept json,multipart/form-data // @produce json // @param method query string true "method for creating template" Enums(string, file, repository) // @param body_string body customTemplateFromFileContentPayload false "Required when using method=string" @@ -105,9 +105,10 @@ type customTemplateFromFileContentPayload struct { Note string `example:"This is my custom template"` // Platform associated to the template. // Valid values are: 1 - 'linux', 2 - 'windows' - Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"` - // Type of created stack (1 - swarm, 2 - compose) - Type portainer.StackType `example:"1" enums:"1,2" validate:"required"` + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` + // Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"` // Content of stack file FileContent string `validate:"required"` } @@ -122,10 +123,10 @@ func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) e if govalidator.IsNull(payload.FileContent) { return errors.New("Invalid file content") } - if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { return errors.New("Invalid custom template platform") } - if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { + if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { return errors.New("Invalid custom template type") } return nil @@ -171,7 +172,8 @@ type customTemplateFromGitRepositoryPayload struct { Note string `example:"This is my custom template"` // Platform associated to the template. // Valid values are: 1 - 'linux', 2 - 'windows' - Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"` + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` // Type of created stack (1 - swarm, 2 - compose) Type portainer.StackType `example:"1" enums:"1,2" validate:"required"` @@ -205,6 +207,11 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) if govalidator.IsNull(payload.ComposeFilePathInRepository) { payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName } + + if payload.Type == portainer.KubernetesStack { + return errors.New("Creating a Kubernetes custom template from git is not supported") + } + if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { return errors.New("Invalid custom template platform") } @@ -272,26 +279,29 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er if err != nil { return errors.New("Invalid custom template description") } - payload.Description = description + logo, _ := request.RetrieveMultiPartFormValue(r, "Logo", true) + payload.Logo = logo + note, _ := request.RetrieveMultiPartFormValue(r, "Note", true) payload.Note = note - platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true) - templatePlatform := portainer.CustomTemplatePlatform(platform) - if templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows { - return errors.New("Invalid custom template platform") - } - payload.Platform = templatePlatform - typeNumeral, _ := request.RetrieveNumericMultiPartFormValue(r, "Type", true) templateType := portainer.StackType(typeNumeral) - if templateType != portainer.DockerComposeStack && templateType != portainer.DockerSwarmStack { + if templateType != portainer.KubernetesStack && templateType != portainer.DockerSwarmStack && templateType != portainer.DockerComposeStack { return errors.New("Invalid custom template type") } payload.Type = templateType + platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true) + templatePlatform := portainer.CustomTemplatePlatform(platform) + if templateType != portainer.KubernetesStack && templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows { + return errors.New("Invalid custom template platform") + } + + payload.Platform = templatePlatform + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "File") if err != nil { return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly") diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index 13dd10363..e6d616de3 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -2,7 +2,9 @@ package customtemplates import ( "net/http" + "strconv" + "github.com/pkg/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" @@ -17,10 +19,16 @@ import ( // @tags custom_templates // @security jwt // @produce json +// @param type query []int true "Template types" Enums(1,2,3) // @success 200 {array} portainer.CustomTemplate "Success" // @failure 500 "Server error" // @router /custom_templates [get] func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + templateTypes, err := parseTemplateTypes(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template type", err} + } + customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err} @@ -52,5 +60,52 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs) } + customTemplates = filterByType(customTemplates, templateTypes) + return response.JSON(w, customTemplates) } + +func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) { + err := r.ParseForm() + if err != nil { + return nil, errors.WithMessage(err, "failed to parse request params") + } + + types, exist := r.Form["type"] + if !exist { + return []portainer.StackType{}, nil + } + + res := []portainer.StackType{} + for _, templateTypeStr := range types { + templateType, err := strconv.Atoi(templateTypeStr) + if err != nil { + return nil, errors.WithMessage(err, "failed parsing template type") + } + + res = append(res, portainer.StackType(templateType)) + } + + return res, nil +} + +func filterByType(customTemplates []portainer.CustomTemplate, templateTypes []portainer.StackType) []portainer.CustomTemplate { + if len(templateTypes) == 0 { + return customTemplates + } + + typeSet := map[portainer.StackType]bool{} + for _, templateType := range templateTypes { + typeSet[templateType] = true + } + + filtered := []portainer.CustomTemplate{} + + for _, template := range customTemplates { + if typeSet[template.Type] { + filtered = append(filtered, template) + } + } + + return filtered +} diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index ecbd9c48a..dcbb1dd65 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -27,9 +27,10 @@ type customTemplateUpdatePayload struct { Note string `example:"This is my custom template"` // Platform associated to the template. // Valid values are: 1 - 'linux', 2 - 'windows' - Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"` - // Type of created stack (1 - swarm, 2 - compose) - Type portainer.StackType `example:"1" enums:"1,2" validate:"required"` + // Required for Docker stacks + Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"` + // Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes) + Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"` // Content of stack file FileContent string `validate:"required"` } @@ -41,10 +42,10 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.FileContent) { return errors.New("Invalid file content") } - if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { + if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows { return errors.New("Invalid custom template platform") } - if payload.Type != portainer.DockerComposeStack && payload.Type != portainer.DockerSwarmStack { + if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack { return errors.New("Invalid custom template type") } if govalidator.IsNull(payload.Description) { diff --git a/api/http/handler/customtemplates/handler.go b/api/http/handler/customtemplates/handler.go index ff8fce4a5..e62dcb575 100644 --- a/api/http/handler/customtemplates/handler.go +++ b/api/http/handler/customtemplates/handler.go @@ -10,7 +10,7 @@ import ( "github.com/portainer/portainer/api/internal/authorization" ) -// Handler is the HTTP handler used to handle endpoint group operations. +// Handler is the HTTP handler used to handle environment(endpoint) group operations. type Handler struct { *mux.Router DataStore portainer.DataStore @@ -18,7 +18,7 @@ type Handler struct { GitService portainer.GitService } -// NewHandler creates a handler to manage endpoint group operations. +// NewHandler creates a handler to manage environment(endpoint) group operations. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), diff --git a/api/http/handler/dockerhub/dockerhub_inspect.go b/api/http/handler/dockerhub/dockerhub_inspect.go deleted file mode 100644 index e7dc713f8..000000000 --- a/api/http/handler/dockerhub/dockerhub_inspect.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/dockerhub/dockerhub_update.go b/api/http/handler/dockerhub/dockerhub_update.go deleted file mode 100644 index 536b84420..000000000 --- a/api/http/handler/dockerhub/dockerhub_update.go +++ /dev/null @@ -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) -} diff --git a/api/http/handler/dockerhub/handler.go b/api/http/handler/dockerhub/handler.go deleted file mode 100644 index f1328acb8..000000000 --- a/api/http/handler/dockerhub/handler.go +++ /dev/null @@ -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 -} diff --git a/api/http/handler/edgegroups/edgegroup_create.go b/api/http/handler/edgegroups/edgegroup_create.go index 81f832bb2..087e7847a 100644 --- a/api/http/handler/edgegroups/edgegroup_create.go +++ b/api/http/handler/edgegroups/edgegroup_create.go @@ -27,7 +27,7 @@ func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { return errors.New("TagIDs is mandatory for a dynamic Edge group") } if !payload.Dynamic && (payload.Endpoints == nil || len(payload.Endpoints) == 0) { - return errors.New("Endpoints is mandatory for a static Edge group") + return errors.New("Environment is mandatory for a static Edge group") } return nil } @@ -41,7 +41,7 @@ func (payload *edgeGroupCreatePayload) Validate(r *http.Request) error { // @produce json // @param body body edgeGroupCreatePayload true "EdgeGroup data" // @success 200 {object} portainer.EdgeGroup -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @failure 500 // @router /edge_groups [post] func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -77,7 +77,7 @@ func (handler *Handler) edgeGroupCreate(w http.ResponseWriter, r *http.Request) for _, endpointID := range payload.Endpoints { endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment from the database", err} } if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { diff --git a/api/http/handler/edgegroups/edgegroup_delete.go b/api/http/handler/edgegroups/edgegroup_delete.go index e9ed2446d..7ff9c1ed5 100644 --- a/api/http/handler/edgegroups/edgegroup_delete.go +++ b/api/http/handler/edgegroups/edgegroup_delete.go @@ -20,7 +20,7 @@ import ( // @produce json // @param id path int true "EdgeGroup Id" // @success 204 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @failure 500 // @router /edge_groups/{id} [delete] func (handler *Handler) edgeGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { diff --git a/api/http/handler/edgegroups/edgegroup_inspect.go b/api/http/handler/edgegroups/edgegroup_inspect.go index 938624c29..4d378b2be 100644 --- a/api/http/handler/edgegroups/edgegroup_inspect.go +++ b/api/http/handler/edgegroups/edgegroup_inspect.go @@ -19,7 +19,7 @@ import ( // @produce json // @param id path int true "EdgeGroup Id" // @success 200 {object} portainer.EdgeGroup -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @failure 500 // @router /edge_groups/{id} [get] func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -38,7 +38,7 @@ func (handler *Handler) edgeGroupInspect(w http.ResponseWriter, r *http.Request) if edgeGroup.Dynamic { endpoints, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints and endpoint groups for Edge group", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments and environment groups for Edge group", err} } edgeGroup.Endpoints = endpoints diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go index 5e56a35b7..5d81feed9 100644 --- a/api/http/handler/edgegroups/edgegroup_list.go +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -1,6 +1,7 @@ package edgegroups import ( + "fmt" "net/http" httperror "github.com/portainer/libhttp/error" @@ -10,7 +11,8 @@ import ( type decoratedEdgeGroup struct { portainer.EdgeGroup - HasEdgeStack bool `json:"HasEdgeStack"` + HasEdgeStack bool `json:"HasEdgeStack"` + EndpointTypes []portainer.EndpointType } // @id EdgeGroupList @@ -22,7 +24,7 @@ type decoratedEdgeGroup struct { // @produce json // @success 200 {array} portainer.EdgeGroup{HasEdgeStack=bool} "EdgeGroups" // @failure 500 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_groups [get] func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() @@ -46,17 +48,25 @@ func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *h decoratedEdgeGroups := []decoratedEdgeGroup{} for _, orgEdgeGroup := range edgeGroups { edgeGroup := decoratedEdgeGroup{ - EdgeGroup: orgEdgeGroup, + EdgeGroup: orgEdgeGroup, + EndpointTypes: []portainer.EndpointType{}, } if edgeGroup.Dynamic { - endpoints, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) + endpointIDs, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints and endpoint groups for Edge group", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments and environment groups for Edge group", err} } - edgeGroup.Endpoints = endpoints + edgeGroup.Endpoints = endpointIDs } + endpointTypes, err := getEndpointTypes(handler.DataStore.Endpoint(), edgeGroup.Endpoints) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint types for Edge group", err} + } + + edgeGroup.EndpointTypes = endpointTypes + edgeGroup.HasEdgeStack = usedEdgeGroups[edgeGroup.ID] decoratedEdgeGroups = append(decoratedEdgeGroups, edgeGroup) @@ -64,3 +74,22 @@ func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *h return response.JSON(w, decoratedEdgeGroups) } + +func getEndpointTypes(endpointService portainer.EndpointService, endpointIds []portainer.EndpointID) ([]portainer.EndpointType, error) { + typeSet := map[portainer.EndpointType]bool{} + for _, endpointID := range endpointIds { + endpoint, err := endpointService.Endpoint(endpointID) + if err != nil { + return nil, fmt.Errorf("failed fetching endpoint: %w", err) + } + + typeSet[endpoint.Type] = true + } + + endpointTypes := make([]portainer.EndpointType, 0, len(typeSet)) + for endpointType := range typeSet { + endpointTypes = append(endpointTypes, endpointType) + } + + return endpointTypes, nil +} diff --git a/api/http/handler/edgegroups/edgegroup_list_test.go b/api/http/handler/edgegroups/edgegroup_list_test.go new file mode 100644 index 000000000..36816fea9 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_list_test.go @@ -0,0 +1,53 @@ +package edgegroups + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func Test_getEndpointTypes(t *testing.T) { + endpoints := []portainer.Endpoint{ + {ID: 1, Type: portainer.DockerEnvironment}, + {ID: 2, Type: portainer.AgentOnDockerEnvironment}, + {ID: 3, Type: portainer.AzureEnvironment}, + {ID: 4, Type: portainer.EdgeAgentOnDockerEnvironment}, + {ID: 5, Type: portainer.KubernetesLocalEnvironment}, + {ID: 6, Type: portainer.AgentOnKubernetesEnvironment}, + {ID: 7, Type: portainer.EdgeAgentOnKubernetesEnvironment}, + } + + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints(endpoints)) + + tests := []struct { + endpointIds []portainer.EndpointID + expected []portainer.EndpointType + }{ + {endpointIds: []portainer.EndpointID{1}, expected: []portainer.EndpointType{portainer.DockerEnvironment}}, + {endpointIds: []portainer.EndpointID{2}, expected: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}}, + {endpointIds: []portainer.EndpointID{3}, expected: []portainer.EndpointType{portainer.AzureEnvironment}}, + {endpointIds: []portainer.EndpointID{4}, expected: []portainer.EndpointType{portainer.EdgeAgentOnDockerEnvironment}}, + {endpointIds: []portainer.EndpointID{5}, expected: []portainer.EndpointType{portainer.KubernetesLocalEnvironment}}, + {endpointIds: []portainer.EndpointID{6}, expected: []portainer.EndpointType{portainer.AgentOnKubernetesEnvironment}}, + {endpointIds: []portainer.EndpointID{7}, expected: []portainer.EndpointType{portainer.EdgeAgentOnKubernetesEnvironment}}, + {endpointIds: []portainer.EndpointID{7, 2}, expected: []portainer.EndpointType{portainer.EdgeAgentOnKubernetesEnvironment, portainer.AgentOnDockerEnvironment}}, + {endpointIds: []portainer.EndpointID{6, 4, 1}, expected: []portainer.EndpointType{portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnDockerEnvironment, portainer.DockerEnvironment}}, + {endpointIds: []portainer.EndpointID{1, 2, 3}, expected: []portainer.EndpointType{portainer.DockerEnvironment, portainer.AgentOnDockerEnvironment, portainer.AzureEnvironment}}, + } + + for _, test := range tests { + ans, err := getEndpointTypes(datastore.Endpoint(), test.endpointIds) + assert.NoError(t, err, "getEndpointTypes shouldn't fail") + + assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) + } +} + +func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) { + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) + + _, err := getEndpointTypes(datastore.Endpoint(), []portainer.EndpointID{1}) + assert.Error(t, err, "getEndpointTypes should fail") +} diff --git a/api/http/handler/edgegroups/edgegroup_update.go b/api/http/handler/edgegroups/edgegroup_update.go index 2a5105d70..15920c5b2 100644 --- a/api/http/handler/edgegroups/edgegroup_update.go +++ b/api/http/handler/edgegroups/edgegroup_update.go @@ -29,7 +29,7 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { return errors.New("TagIDs is mandatory for a dynamic Edge group") } if !payload.Dynamic && (payload.Endpoints == nil || len(payload.Endpoints) == 0) { - return errors.New("Endpoints is mandatory for a static Edge group") + return errors.New("Environments is mandatory for a static Edge group") } return nil } @@ -44,7 +44,7 @@ func (payload *edgeGroupUpdatePayload) Validate(r *http.Request) error { // @param id path int true "EdgeGroup Id" // @param body body edgeGroupUpdatePayload true "EdgeGroup data" // @success 200 {object} portainer.EdgeGroup -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @failure 500 // @router /edge_groups/{id} [put] func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -81,12 +81,12 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) } endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from database", err} } endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from database", err} } oldRelatedEndpoints := edge.EdgeGroupRelatedEndpoints(edgeGroup, endpoints, endpointGroups) @@ -99,7 +99,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) for _, endpointID := range payload.Endpoints { endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment from the database", err} } if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { @@ -124,7 +124,7 @@ func (handler *Handler) edgeGroupUpdate(w http.ResponseWriter, r *http.Request) for _, endpointID := range endpointsToUpdate { err = handler.updateEndpoint(endpointID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Endpoint relation changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist Environment relation changes inside the database", err} } } diff --git a/api/http/handler/edgegroups/handler.go b/api/http/handler/edgegroups/handler.go index 374ca4ab2..b9a5989f3 100644 --- a/api/http/handler/edgegroups/handler.go +++ b/api/http/handler/edgegroups/handler.go @@ -9,13 +9,13 @@ import ( "github.com/portainer/portainer/api/http/security" ) -// Handler is the HTTP handler used to handle endpoint group operations. +// Handler is the HTTP handler used to handle environment(endpoint) group operations. type Handler struct { *mux.Router DataStore portainer.DataStore } -// NewHandler creates a handler to manage endpoint group operations. +// NewHandler creates a handler to manage environment(endpoint) group operations. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), diff --git a/api/http/handler/edgejobs/edgejob_create.go b/api/http/handler/edgejobs/edgejob_create.go index 3e78914d5..b2c2c8f52 100644 --- a/api/http/handler/edgejobs/edgejob_create.go +++ b/api/http/handler/edgejobs/edgejob_create.go @@ -22,10 +22,10 @@ import ( // @accept json // @produce json // @param method query string true "Creation Method" Enums(file, string) -// @param body body edgeJobCreateFromFileContentPayload true "EdgeGroup data when method is string" -// @param body body edgeJobCreateFromFilePayload true "EdgeGroup data when method is file" +// @param body_string body edgeJobCreateFromFileContentPayload true "EdgeGroup data when method is string" +// @param body_file body edgeJobCreateFromFilePayload true "EdgeGroup data when method is file" // @success 200 {object} portainer.EdgeGroup -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @failure 500 // @router /edge_jobs [post] func (handler *Handler) edgeJobCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -66,7 +66,7 @@ func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) er } if payload.Endpoints == nil || len(payload.Endpoints) == 0 { - return errors.New("Invalid endpoints payload") + return errors.New("Invalid environment payload") } if govalidator.IsNull(payload.FileContent) { @@ -119,9 +119,9 @@ func (payload *edgeJobCreateFromFilePayload) Validate(r *http.Request) error { payload.CronExpression = cronExpression var endpoints []portainer.EndpointID - err = request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, false) + err = request.RetrieveMultiPartFormJSONValue(r, "Environments", &endpoints, false) if err != nil { - return errors.New("Invalid endpoints") + return errors.New("Invalid environments") } payload.Endpoints = endpoints @@ -206,7 +206,7 @@ func (handler *Handler) addAndPersistEdgeJob(edgeJob *portainer.EdgeJob, file [] } if len(edgeJob.Endpoints) == 0 { - return errors.New("Endpoints are mandatory for an Edge job") + return errors.New("Environments are mandatory for an Edge job") } scriptPath, err := handler.FileService.StoreEdgeJobFileFromBytes(strconv.Itoa(int(edgeJob.ID)), file) diff --git a/api/http/handler/edgejobs/edgejob_delete.go b/api/http/handler/edgejobs/edgejob_delete.go index e90d3e1fe..8217dcd39 100644 --- a/api/http/handler/edgejobs/edgejob_delete.go +++ b/api/http/handler/edgejobs/edgejob_delete.go @@ -22,7 +22,7 @@ import ( // @success 204 // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs/{id} [delete] func (handler *Handler) edgeJobDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgejobs/edgejob_file.go b/api/http/handler/edgejobs/edgejob_file.go index 85fe20ce2..1b5d4ae29 100644 --- a/api/http/handler/edgejobs/edgejob_file.go +++ b/api/http/handler/edgejobs/edgejob_file.go @@ -25,7 +25,7 @@ type edgeJobFileResponse struct { // @success 200 {object} edgeJobFileResponse // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs/{id}/file [get] func (handler *Handler) edgeJobFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgejobs/edgejob_inspect.go b/api/http/handler/edgejobs/edgejob_inspect.go index c86b8b80b..38c826227 100644 --- a/api/http/handler/edgejobs/edgejob_inspect.go +++ b/api/http/handler/edgejobs/edgejob_inspect.go @@ -26,7 +26,7 @@ type edgeJobInspectResponse struct { // @success 200 {object} portainer.EdgeJob // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs/{id} [get] func (handler *Handler) edgeJobInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgejobs/edgejob_list.go b/api/http/handler/edgejobs/edgejob_list.go index d3776df18..f2c93ebf1 100644 --- a/api/http/handler/edgejobs/edgejob_list.go +++ b/api/http/handler/edgejobs/edgejob_list.go @@ -17,7 +17,7 @@ import ( // @success 200 {array} portainer.EdgeJob // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs [get] // GET request on /api/edge_jobs func (handler *Handler) edgeJobList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_clear.go b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go index 7a4afd8ab..230b387af 100644 --- a/api/http/handler/edgejobs/edgejob_tasklogs_clear.go +++ b/api/http/handler/edgejobs/edgejob_tasklogs_clear.go @@ -23,7 +23,7 @@ import ( // @success 204 // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs/{id}/tasks/{taskID}/logs [delete] func (handler *Handler) edgeJobTasksClear(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_collect.go b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go index e08fe0c8b..919b2ff4c 100644 --- a/api/http/handler/edgejobs/edgejob_tasklogs_collect.go +++ b/api/http/handler/edgejobs/edgejob_tasklogs_collect.go @@ -22,7 +22,7 @@ import ( // @success 204 // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs/{id}/tasks/{taskID}/logs [post] func (handler *Handler) edgeJobTasksCollect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go b/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go index d5d25b91b..ce7813d28 100644 --- a/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go +++ b/api/http/handler/edgejobs/edgejob_tasklogs_inspect.go @@ -25,7 +25,7 @@ type fileResponse struct { // @success 200 {object} fileResponse // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs/{id}/tasks/{taskID}/logs [get] func (handler *Handler) edgeJobTaskLogsInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgejobs/edgejob_tasks_list.go b/api/http/handler/edgejobs/edgejob_tasks_list.go index 769dfc205..6f05946c9 100644 --- a/api/http/handler/edgejobs/edgejob_tasks_list.go +++ b/api/http/handler/edgejobs/edgejob_tasks_list.go @@ -28,7 +28,7 @@ type taskContainer struct { // @success 200 {array} taskContainer // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs/{id}/tasks [get] func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgejobs/edgejob_update.go b/api/http/handler/edgejobs/edgejob_update.go index a0f473b52..9e2a6eae0 100644 --- a/api/http/handler/edgejobs/edgejob_update.go +++ b/api/http/handler/edgejobs/edgejob_update.go @@ -40,7 +40,7 @@ func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error { // @success 200 {object} portainer.EdgeJob // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_jobs/{id} [post] func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index 06edc2278..5c12ad392 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -2,6 +2,7 @@ package edgestacks import ( "errors" + "fmt" "net/http" "strconv" "strings" @@ -29,7 +30,7 @@ import ( // @param body_repository body swarmStackFromGitRepositoryPayload true "Required when using method=repository" // @success 200 {object} portainer.EdgeStack // @failure 500 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_stacks [post] func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { method, err := request.RetrieveQueryParameter(r, "method", false) @@ -42,37 +43,6 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Edge stack", err} } - endpoints, err := handler.DataStore.Endpoint().Endpoints() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} - } - - endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} - } - - edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} - } - - relatedEndpoints, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups) - - for _, endpointID := range relatedEndpoints { - relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} - } - - relation.EdgeStacks[edgeStack.ID] = true - - err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} - } - } - return response.JSON(w, edgeStack) } @@ -95,6 +65,11 @@ type swarmStackFromFileContentPayload struct { StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` // List of identifiers of EdgeGroups EdgeGroups []portainer.EdgeGroupID `example:"1"` + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes' + // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) + // kubernetes deploytype is enabled only for kubernetes environments(endpoints) + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"` } func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { @@ -124,21 +99,64 @@ func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*porta stackID := handler.DataStore.EdgeStack().GetNextIdentifier() stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - EntryPoint: filesystem.ComposeFileDefaultName, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - Version: 1, + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + DeploymentType: payload.DeploymentType, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + Version: 1, + } + + relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) + if err != nil { + return nil, fmt.Errorf("unable to find environment relations in database: %w", err) + } + + relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) + if err != nil { + return nil, fmt.Errorf("unable to persist environment relation in database: %w", err) } stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) - if err != nil { - return nil, err + if stack.DeploymentType == portainer.EdgeStackDeploymentCompose { + stack.EntryPoint = filesystem.ComposeFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return nil, err + } + stack.ProjectPath = projectPath + + err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds) + if err != nil { + return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err) + } + + } else { + hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds) + if err != nil { + return nil, fmt.Errorf("unable to check for existence of docker endpoint: %w", err) + } + + if hasDockerEndpoint { + return nil, fmt.Errorf("edge stack with docker endpoint cannot be deployed with kubernetes config") + } + + stack.ManifestPath = filesystem.ManifestFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent)) + if err != nil { + return nil, err + } + + stack.ProjectPath = projectPath + } + + err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds) + if err != nil { + return nil, fmt.Errorf("Unable to update endpoint relations: %w", err) } - stack.ProjectPath = projectPath err = handler.DataStore.EdgeStack().CreateEdgeStack(stack) if err != nil { @@ -162,9 +180,14 @@ type swarmStackFromGitRepositoryPayload struct { // Password used in basic authentication. Required when RepositoryAuthentication is true. RepositoryPassword string `example:"myGitPassword"` // Path to the Stack file inside the Git repository - ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` + FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` // List of identifiers of EdgeGroups EdgeGroups []portainer.EdgeGroupID `example:"1"` + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes' + // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) + // kubernetes deploytype is enabled only for kubernetes environments(endpoints) + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"` } func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { @@ -177,8 +200,8 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") } - if govalidator.IsNull(payload.ComposeFilePathInRepository) { - payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + if govalidator.IsNull(payload.FilePathInRepository) { + payload.FilePathInRepository = filesystem.ComposeFileDefaultName } if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 { return errors.New("Edge Groups are mandatory for an Edge stack") @@ -200,13 +223,13 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por stackID := handler.DataStore.EdgeStack().GetNextIdentifier() stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - EntryPoint: payload.ComposeFilePathInRepository, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - Version: 1, + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + DeploymentType: payload.DeploymentType, + Version: 1, } projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -219,11 +242,37 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por repositoryPassword = "" } + relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) + if err != nil { + return nil, fmt.Errorf("failed fetching relations config: %w", err) + } + + relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) + if err != nil { + return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err) + } + err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword) if err != nil { return nil, err } + if stack.DeploymentType == portainer.EdgeStackDeploymentCompose { + stack.EntryPoint = payload.FilePathInRepository + + err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds) + if err != nil { + return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err) + } + } else { + stack.ManifestPath = payload.FilePathInRepository + } + + err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds) + if err != nil { + return nil, fmt.Errorf("Unable to update endpoint relations: %w", err) + } + err = handler.DataStore.EdgeStack().CreateEdgeStack(stack) if err != nil { return nil, err @@ -236,6 +285,7 @@ type swarmStackFromFileUploadPayload struct { Name string StackFileContent []byte EdgeGroups []portainer.EdgeGroupID + DeploymentType portainer.EdgeStackDeploymentType } func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { @@ -257,6 +307,13 @@ func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error return errors.New("Edge Groups are mandatory for an Edge stack") } payload.EdgeGroups = edgeGroups + + deploymentType, err := request.RetrieveNumericMultiPartFormValue(r, "DeploymentType", true) + if err != nil { + return errors.New("Invalid deployment type") + } + payload.DeploymentType = portainer.EdgeStackDeploymentType(deploymentType) + return nil } @@ -274,21 +331,54 @@ func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portai stackID := handler.DataStore.EdgeStack().GetNextIdentifier() stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - EntryPoint: filesystem.ComposeFileDefaultName, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - Version: 1, + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + DeploymentType: payload.DeploymentType, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + Version: 1, + } + + relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) + if err != nil { + return nil, fmt.Errorf("failed fetching relations config: %w", err) + } + + relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) + if err != nil { + return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err) } stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) - if err != nil { - return nil, err + if stack.DeploymentType == portainer.EdgeStackDeploymentCompose { + stack.EntryPoint = filesystem.ComposeFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return nil, err + } + stack.ProjectPath = projectPath + + err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds) + if err != nil { + return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err) + } + + } else { + stack.ManifestPath = filesystem.ManifestFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent)) + if err != nil { + return nil, err + } + stack.ProjectPath = projectPath + } + + err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds) + if err != nil { + return nil, fmt.Errorf("Unable to update endpoint relations: %w", err) } - stack.ProjectPath = projectPath err = handler.DataStore.EdgeStack().CreateEdgeStack(stack) if err != nil { @@ -311,3 +401,22 @@ func (handler *Handler) validateUniqueName(name string) error { } return nil } + +// updateEndpointRelations adds a relation between the Edge Stack to the related environments(endpoints) +func updateEndpointRelations(endpointRelationService portainer.EndpointRelationService, edgeStackID portainer.EdgeStackID, relatedEndpointIds []portainer.EndpointID) error { + for _, endpointID := range relatedEndpointIds { + relation, err := endpointRelationService.EndpointRelation(endpointID) + if err != nil { + return fmt.Errorf("unable to find endpoint relation in database: %w", err) + } + + relation.EdgeStacks[edgeStackID] = true + + err = endpointRelationService.UpdateEndpointRelation(endpointID, relation) + if err != nil { + return fmt.Errorf("unable to persist endpoint relation in database: %w", err) + } + } + + return nil +} diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/http/handler/edgestacks/edgestack_create_test.go new file mode 100644 index 000000000..8e1144e4a --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_test.go @@ -0,0 +1,38 @@ +package edgestacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func Test_updateEndpointRelation_successfulRuns(t *testing.T) { + edgeStackID := portainer.EdgeStackID(5) + endpointRelations := []portainer.EndpointRelation{ + {EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + {EndpointID: 2, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + {EndpointID: 3, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + {EndpointID: 4, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + {EndpointID: 5, EdgeStacks: map[portainer.EdgeStackID]bool{}}, + } + + relatedIds := []portainer.EndpointID{2, 3} + + dataStore := testhelpers.NewDatastore(testhelpers.WithEndpointRelations(endpointRelations)) + + err := updateEndpointRelations(dataStore.EndpointRelation(), edgeStackID, relatedIds) + + assert.NoError(t, err, "updateEndpointRelations should not fail") + + relatedSet := map[portainer.EndpointID]bool{} + for _, relationID := range relatedIds { + relatedSet[relationID] = true + } + + for _, relation := range endpointRelations { + shouldBeRelated := relatedSet[relation.EndpointID] + assert.Equal(t, shouldBeRelated, relation.EdgeStacks[edgeStackID]) + } +} diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go index a1937e03f..b9e61d594 100644 --- a/api/http/handler/edgestacks/edgestack_delete.go +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -22,7 +22,7 @@ import ( // @success 204 // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_stacks/{id} [delete] func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id") @@ -42,34 +42,27 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the edge stack from the database", err} } - endpoints, err := handler.DataStore.Endpoint().Endpoints() + relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments relations config from database", err} } - endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() + relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err} } - edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} - } - - relatedEndpoints, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups) - - for _, endpointID := range relatedEndpoints { + for _, endpointID := range relatedEndpointIds { relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation in database", err} } delete(relation.EdgeStacks, edgeStack.ID) err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relation in database", err} } } diff --git a/api/http/handler/edgestacks/edgestack_file.go b/api/http/handler/edgestacks/edgestack_file.go index 02bfbbe84..5584fd806 100644 --- a/api/http/handler/edgestacks/edgestack_file.go +++ b/api/http/handler/edgestacks/edgestack_file.go @@ -26,7 +26,7 @@ type stackFileResponse struct { // @success 200 {object} stackFileResponse // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_stacks/{id}/file [get] func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") @@ -41,7 +41,12 @@ func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} } - stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint)) + fileName := stack.EntryPoint + if stack.DeploymentType == portainer.EdgeStackDeploymentKubernetes { + fileName = stack.ManifestPath + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, fileName)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} } diff --git a/api/http/handler/edgestacks/edgestack_inspect.go b/api/http/handler/edgestacks/edgestack_inspect.go index c6450f321..463fca874 100644 --- a/api/http/handler/edgestacks/edgestack_inspect.go +++ b/api/http/handler/edgestacks/edgestack_inspect.go @@ -21,7 +21,7 @@ import ( // @success 200 {object} portainer.EdgeStack // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_stacks/{id} [get] func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id") diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go index 2d0cb423f..5be73dea1 100644 --- a/api/http/handler/edgestacks/edgestack_list.go +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -17,7 +17,7 @@ import ( // @success 200 {array} portainer.EdgeStack // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_stacks [get] func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() diff --git a/api/http/handler/edgestacks/edgestack_status_update.go b/api/http/handler/edgestacks/edgestack_status_update.go index 3eaea47b7..2f3c57dc3 100644 --- a/api/http/handler/edgestacks/edgestack_status_update.go +++ b/api/http/handler/edgestacks/edgestack_status_update.go @@ -23,7 +23,7 @@ func (payload *updateStatusPayload) Validate(r *http.Request) error { return errors.New("Invalid status") } if payload.EndpointID == nil { - return errors.New("Invalid EndpointID") + return errors.New("Invalid EnvironmentID") } if *payload.Status == portainer.StatusError && govalidator.IsNull(payload.Error) { return errors.New("Error message is mandatory when status is error") @@ -33,7 +33,7 @@ func (payload *updateStatusPayload) Validate(r *http.Request) error { // @id EdgeStackStatusUpdate // @summary Update an EdgeStack status -// @description Authorized only if the request is done by an Edge Endpoint +// @description Authorized only if the request is done by an Edge Environment(Endpoint) // @tags edge_stacks // @accept json // @produce json @@ -65,14 +65,14 @@ func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Req endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(*payload.EndpointID)) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } stack.Status[*payload.EndpointID] = portainer.EdgeStackStatus{ diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go index 695106bf6..80c148e58 100644 --- a/api/http/handler/edgestacks/edgestack_update.go +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -5,24 +5,24 @@ import ( "net/http" "strconv" - "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" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/internal/edge" ) type updateEdgeStackPayload struct { StackFileContent string Version *int - Prune *bool EdgeGroups []portainer.EdgeGroupID + DeploymentType portainer.EdgeStackDeploymentType } func (payload *updateEdgeStackPayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.StackFileContent) { + if payload.StackFileContent == "" { return errors.New("Invalid stack file content") } if payload.EdgeGroups != nil && len(payload.EdgeGroups) == 0 { @@ -43,7 +43,7 @@ func (payload *updateEdgeStackPayload) Validate(r *http.Request) error { // @success 200 {object} portainer.EdgeStack // @failure 500 // @failure 400 -// @failure 503 Edge compute features are disabled +// @failure 503 "Edge compute features are disabled" // @router /edge_stacks/{id} [put] func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") @@ -64,33 +64,23 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } + relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments relations config from database", err} + } + + relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err} + } + if payload.EdgeGroups != nil { - endpoints, err := handler.DataStore.Endpoint().Endpoints() + newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err} } - endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from database", err} - } - - edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err} - } - - oldRelated, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, endpoints, endpointGroups, edgeGroups) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related endpoints from database", err} - } - - newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, endpoints, endpointGroups, edgeGroups) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related endpoints from database", err} - } - - oldRelatedSet := EndpointSet(oldRelated) + oldRelatedSet := EndpointSet(relatedEndpointIds) newRelatedSet := EndpointSet(newRelated) endpointsToRemove := map[portainer.EndpointID]bool{} @@ -103,14 +93,14 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) for endpointID := range endpointsToRemove { relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation in database", err} } delete(relation.EdgeStacks, stack.ID) err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relation in database", err} } } @@ -124,29 +114,67 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) for endpointID := range endpointsToAdd { relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation in database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation in database", err} } relation.EdgeStacks[stack.ID] = true err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation in database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relation in database", err} } } stack.EdgeGroups = payload.EdgeGroups - + relatedEndpointIds = newRelated } - if payload.Prune != nil { - stack.Prune = *payload.Prune + if stack.DeploymentType != payload.DeploymentType { + // deployment type was changed - need to delete the old file + err = handler.FileService.RemoveDirectory(stack.ProjectPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clear old files", err} + } + + stack.EntryPoint = "" + stack.ManifestPath = "" + stack.DeploymentType = payload.DeploymentType } stackFolder := strconv.Itoa(int(stack.ID)) - _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + if payload.DeploymentType == portainer.EdgeStackDeploymentCompose { + if stack.EntryPoint == "" { + stack.EntryPoint = filesystem.ComposeFileDefaultName + } + + _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } + + err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to convert and persist updated Kubernetes manifest file on disk", err} + } + + } else { + if stack.ManifestPath == "" { + stack.ManifestPath = filesystem.ManifestFileDefaultName + } + + hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for existence of docker environment", err} + } + + if hasDockerEndpoint { + return &httperror.HandlerError{http.StatusBadRequest, "Edge stack with docker environment cannot be deployed with kubernetes config", err} + } + + _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + } } if payload.Version != nil && *payload.Version != stack.Version { diff --git a/api/http/handler/edgestacks/endpoints.go b/api/http/handler/edgestacks/endpoints.go new file mode 100644 index 000000000..c90dcc29a --- /dev/null +++ b/api/http/handler/edgestacks/endpoints.go @@ -0,0 +1,60 @@ +package edgestacks + +import ( + "fmt" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +func hasKubeEndpoint(endpointService portainer.EndpointService, endpointIDs []portainer.EndpointID) (bool, error) { + return hasEndpointPredicate(endpointService, endpointIDs, endpointutils.IsKubernetesEndpoint) +} + +func hasDockerEndpoint(endpointService portainer.EndpointService, endpointIDs []portainer.EndpointID) (bool, error) { + return hasEndpointPredicate(endpointService, endpointIDs, endpointutils.IsDockerEndpoint) +} + +func hasEndpointPredicate(endpointService portainer.EndpointService, endpointIDs []portainer.EndpointID, predicate func(*portainer.Endpoint) bool) (bool, error) { + for _, endpointID := range endpointIDs { + endpoint, err := endpointService.Endpoint(endpointID) + if err != nil { + return false, fmt.Errorf("failed to retrieve environment from database: %w", err) + } + + if predicate(endpoint) { + return true, nil + } + } + + return false, nil +} + +type endpointRelationsConfig struct { + endpoints []portainer.Endpoint + endpointGroups []portainer.EndpointGroup + edgeGroups []portainer.EdgeGroup +} + +func fetchEndpointRelationsConfig(dataStore portainer.DataStore) (*endpointRelationsConfig, error) { + endpoints, err := dataStore.Endpoint().Endpoints() + if err != nil { + return nil, fmt.Errorf("unable to retrieve environments from database: %w", err) + } + + endpointGroups, err := dataStore.EndpointGroup().EndpointGroups() + if err != nil { + return nil, fmt.Errorf("unable to retrieve environment groups from database: %w", err) + } + + edgeGroups, err := dataStore.EdgeGroup().EdgeGroups() + if err != nil { + return nil, fmt.Errorf("unable to retrieve edge groups from database: %w", err) + } + + return &endpointRelationsConfig{ + endpoints: endpoints, + endpointGroups: endpointGroups, + edgeGroups: edgeGroups, + }, nil +} diff --git a/api/http/handler/edgestacks/endpoints_test.go b/api/http/handler/edgestacks/endpoints_test.go new file mode 100644 index 000000000..6cc94b17c --- /dev/null +++ b/api/http/handler/edgestacks/endpoints_test.go @@ -0,0 +1,99 @@ +package edgestacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func Test_hasKubeEndpoint(t *testing.T) { + endpoints := []portainer.Endpoint{ + {ID: 1, Type: portainer.DockerEnvironment}, + {ID: 2, Type: portainer.AgentOnDockerEnvironment}, + {ID: 3, Type: portainer.AzureEnvironment}, + {ID: 4, Type: portainer.EdgeAgentOnDockerEnvironment}, + {ID: 5, Type: portainer.KubernetesLocalEnvironment}, + {ID: 6, Type: portainer.AgentOnKubernetesEnvironment}, + {ID: 7, Type: portainer.EdgeAgentOnKubernetesEnvironment}, + } + + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints(endpoints)) + + tests := []struct { + endpointIds []portainer.EndpointID + expected bool + }{ + {endpointIds: []portainer.EndpointID{1}, expected: false}, + {endpointIds: []portainer.EndpointID{2}, expected: false}, + {endpointIds: []portainer.EndpointID{3}, expected: false}, + {endpointIds: []portainer.EndpointID{4}, expected: false}, + {endpointIds: []portainer.EndpointID{5}, expected: true}, + {endpointIds: []portainer.EndpointID{6}, expected: true}, + {endpointIds: []portainer.EndpointID{7}, expected: true}, + {endpointIds: []portainer.EndpointID{7, 2}, expected: true}, + {endpointIds: []portainer.EndpointID{6, 4, 1}, expected: true}, + {endpointIds: []portainer.EndpointID{1, 2, 3}, expected: false}, + } + + for _, test := range tests { + + ans, err := hasKubeEndpoint(datastore.Endpoint(), test.endpointIds) + assert.NoError(t, err, "hasKubeEndpoint shouldn't fail") + + assert.Equal(t, test.expected, ans, "hasKubeEndpoint expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) + } +} + +func Test_hasKubeEndpoint_failWhenEndpointDontExist(t *testing.T) { + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) + + _, err := hasKubeEndpoint(datastore.Endpoint(), []portainer.EndpointID{1}) + assert.Error(t, err, "hasKubeEndpoint should fail") +} + +func Test_hasDockerEndpoint(t *testing.T) { + endpoints := []portainer.Endpoint{ + {ID: 1, Type: portainer.DockerEnvironment}, + {ID: 2, Type: portainer.AgentOnDockerEnvironment}, + {ID: 3, Type: portainer.AzureEnvironment}, + {ID: 4, Type: portainer.EdgeAgentOnDockerEnvironment}, + {ID: 5, Type: portainer.KubernetesLocalEnvironment}, + {ID: 6, Type: portainer.AgentOnKubernetesEnvironment}, + {ID: 7, Type: portainer.EdgeAgentOnKubernetesEnvironment}, + } + + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints(endpoints)) + + tests := []struct { + endpointIds []portainer.EndpointID + expected bool + }{ + {endpointIds: []portainer.EndpointID{1}, expected: true}, + {endpointIds: []portainer.EndpointID{2}, expected: true}, + {endpointIds: []portainer.EndpointID{3}, expected: false}, + {endpointIds: []portainer.EndpointID{4}, expected: true}, + {endpointIds: []portainer.EndpointID{5}, expected: false}, + {endpointIds: []portainer.EndpointID{6}, expected: false}, + {endpointIds: []portainer.EndpointID{7}, expected: false}, + {endpointIds: []portainer.EndpointID{7, 2}, expected: true}, + {endpointIds: []portainer.EndpointID{6, 4, 1}, expected: true}, + {endpointIds: []portainer.EndpointID{1, 2, 3}, expected: true}, + } + + for _, test := range tests { + + ans, err := hasDockerEndpoint(datastore.Endpoint(), test.endpointIds) + assert.NoError(t, err, "hasDockerEndpoint shouldn't fail") + + assert.Equal(t, test.expected, ans, "hasDockerEndpoint expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans) + } +} + +func Test_hasDockerEndpoint_failWhenEndpointDontExist(t *testing.T) { + datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{})) + + _, err := hasDockerEndpoint(datastore.Endpoint(), []portainer.EndpointID{1}) + assert.Error(t, err, "hasDockerEndpoint should fail") +} diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go index 2e0580d6d..d9bf0e39e 100644 --- a/api/http/handler/edgestacks/handler.go +++ b/api/http/handler/edgestacks/handler.go @@ -1,24 +1,29 @@ package edgestacks import ( + "fmt" "net/http" + "path" + "strconv" "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/filesystem" "github.com/portainer/portainer/api/http/security" ) -// Handler is the HTTP handler used to handle endpoint group operations. +// Handler is the HTTP handler used to handle environment(endpoint) group operations. type Handler struct { *mux.Router - requestBouncer *security.RequestBouncer - DataStore portainer.DataStore - FileService portainer.FileService - GitService portainer.GitService + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore + FileService portainer.FileService + GitService portainer.GitService + KubernetesDeployer portainer.KubernetesDeployer } -// NewHandler creates a handler to manage endpoint group operations. +// NewHandler creates a handler to manage environment(endpoint) group operations. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), @@ -40,3 +45,34 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut) return h } + +func (handler *Handler) convertAndStoreKubeManifestIfNeeded(edgeStack *portainer.EdgeStack, relatedEndpointIds []portainer.EndpointID) error { + hasKubeEndpoint, err := hasKubeEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds) + if err != nil { + return fmt.Errorf("unable to check if edge stack has kube environments: %w", err) + } + + if !hasKubeEndpoint { + return nil + } + + composeConfig, err := handler.FileService.GetFileContent(path.Join(edgeStack.ProjectPath, edgeStack.EntryPoint)) + if err != nil { + return fmt.Errorf("unable to retrieve Compose file from disk: %w", err) + } + + kompose, err := handler.KubernetesDeployer.ConvertCompose(composeConfig) + if err != nil { + return fmt.Errorf("failed converting compose file to kubernetes manifest: %w", err) + } + + komposeFileName := filesystem.ManifestFileDefaultName + _, err = handler.FileService.StoreEdgeStackFileFromBytes(strconv.Itoa(int(edgeStack.ID)), komposeFileName, kompose) + if err != nil { + return fmt.Errorf("failed to store kube manifest file: %w", err) + } + + edgeStack.ManifestPath = komposeFileName + + return nil +} diff --git a/api/http/handler/edgetemplates/handler.go b/api/http/handler/edgetemplates/handler.go index 963ddb931..21a344aed 100644 --- a/api/http/handler/edgetemplates/handler.go +++ b/api/http/handler/edgetemplates/handler.go @@ -10,14 +10,14 @@ import ( "github.com/portainer/portainer/api/http/security" ) -// Handler is the HTTP handler used to handle edge endpoint operations. +// Handler is the HTTP handler used to handle edge environment(endpoint) operations. type Handler struct { *mux.Router requestBouncer *security.RequestBouncer DataStore portainer.DataStore } -// NewHandler creates a handler to manage endpoint operations. +// NewHandler creates a handler to manage environment(endpoint) operations. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), diff --git a/api/http/handler/endpointedge/endpoint_edgejob_logs.go b/api/http/handler/endpointedge/endpoint_edgejob_logs.go index 59f55298d..629783bf9 100644 --- a/api/http/handler/endpointedge/endpoint_edgejob_logs.go +++ b/api/http/handler/endpointedge/endpoint_edgejob_logs.go @@ -25,7 +25,7 @@ func (payload *logsPayload) Validate(r *http.Request) error { // @tags edge, endpoints // @accept json // @produce json -// @param id path string true "Endpoint Id" +// @param id path string true "environment(endpoint) Id" // @param jobID path string true "Job Id" // @success 200 // @failure 500 @@ -34,19 +34,19 @@ func (payload *logsPayload) Validate(r *http.Request) error { func (handler *Handler) endpointEdgeJobsLogs(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "jobID") diff --git a/api/http/handler/endpointedge/endpoint_edgestack_inspect.go b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go index 9e1057b20..11b85ff9b 100644 --- a/api/http/handler/endpointedge/endpoint_edgestack_inspect.go +++ b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go @@ -1,6 +1,7 @@ package endpointedge import ( + "errors" "net/http" "path" @@ -8,22 +9,22 @@ import ( "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" + "github.com/portainer/portainer/api/internal/endpointutils" ) type configResponse struct { - Prune bool StackFileContent string Name string } -// @summary Inspect an Edge Stack for an Endpoint +// @summary Inspect an Edge Stack for an Environment(Endpoint) // @description // @tags edge, endpoints, edge_stacks // @accept json // @produce json -// @param id path string true "Endpoint Id" -// @param stackID path string true "EdgeStack Id" +// @param id path string true "environment(endpoint) Id" +// @param stackId path string true "EdgeStack Id" // @success 200 {object} configResponse // @failure 500 // @failure 400 @@ -32,19 +33,19 @@ type configResponse struct { func (handler *Handler) endpointEdgeStackInspect(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) - if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "stackId") @@ -53,19 +54,33 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http. } edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID)) - if err == errors.ErrObjectNotFound { + if err == bolterrors.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err} } - stackFileContent, err := handler.FileService.GetFileContent(path.Join(edgeStack.ProjectPath, edgeStack.EntryPoint)) + fileName := edgeStack.EntryPoint + if endpointutils.IsDockerEndpoint(endpoint) { + if fileName == "" { + return &httperror.HandlerError{http.StatusBadRequest, "Docker is not supported by this stack", errors.New("Docker is not supported by this stack")} + } + } + + if endpointutils.IsKubernetesEndpoint(endpoint) { + fileName = edgeStack.ManifestPath + + if fileName == "" { + return &httperror.HandlerError{http.StatusBadRequest, "Kubernetes is not supported by this stack", errors.New("Kubernetes is not supported by this stack")} + } + } + + stackFileContent, err := handler.FileService.GetFileContent(path.Join(edgeStack.ProjectPath, fileName)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} } return response.JSON(w, configResponse{ - Prune: edgeStack.Prune, StackFileContent: string(stackFileContent), Name: edgeStack.Name, }) diff --git a/api/http/handler/endpointedge/handler.go b/api/http/handler/endpointedge/handler.go index 6ca96d3fa..e8a9c8dee 100644 --- a/api/http/handler/endpointedge/handler.go +++ b/api/http/handler/endpointedge/handler.go @@ -10,7 +10,7 @@ import ( "github.com/portainer/portainer/api/http/security" ) -// Handler is the HTTP handler used to handle edge endpoint operations. +// Handler is the HTTP handler used to handle edge environment(endpoint) operations. type Handler struct { *mux.Router requestBouncer *security.RequestBouncer @@ -19,7 +19,7 @@ type Handler struct { ReverseTunnelService portainer.ReverseTunnelService } -// NewHandler creates a handler to manage endpoint operations. +// NewHandler creates a handler to manage environment(endpoint) operations. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), diff --git a/api/http/handler/endpointgroups/endpointgroup_create.go b/api/http/handler/endpointgroups/endpointgroup_create.go index eb19b9d9e..093163cc9 100644 --- a/api/http/handler/endpointgroups/endpointgroup_create.go +++ b/api/http/handler/endpointgroups/endpointgroup_create.go @@ -12,19 +12,19 @@ import ( ) type endpointGroupCreatePayload struct { - // Endpoint group name - Name string `validate:"required" example:"my-endpoint-group"` - // Endpoint group description + // Environment(Endpoint) group name + Name string `validate:"required" example:"my-environment-group"` + // Environment(Endpoint) group description Description string `example:"description"` - // List of endpoint identifiers that will be part of this group + // List of environment(endpoint) identifiers that will be part of this group AssociatedEndpoints []portainer.EndpointID `example:"1,3"` - // List of tag identifiers to which this endpoint group is associated + // List of tag identifiers to which this environment(endpoint) group is associated TagIDs []portainer.TagID `example:"1,2"` } func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { - return errors.New("Invalid endpoint group name") + return errors.New("Invalid environment group name") } if payload.TagIDs == nil { payload.TagIDs = []portainer.TagID{} @@ -32,14 +32,14 @@ func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error { return nil } -// @summary Create an Endpoint Group -// @description Create a new endpoint group. +// @summary Create an Environment(Endpoint) Group +// @description Create a new environment(endpoint) group. // @description **Access policy**: administrator // @tags endpoint_groups // @security jwt // @accept json // @produce json -// @param body body endpointGroupCreatePayload true "Endpoint Group details" +// @param body body endpointGroupCreatePayload true "Environment(Endpoint) Group details" // @success 200 {object} portainer.EndpointGroup "Success" // @failure 400 "Invalid request" // @failure 500 "Server error" @@ -61,12 +61,12 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque err = handler.DataStore.EndpointGroup().CreateEndpointGroup(endpointGroup) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the endpoint group inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the environment group inside the database", err} } endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err} } for _, id := range payload.AssociatedEndpoints { @@ -76,12 +76,12 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque err := handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update environment", err} } err = handler.updateEndpointRelations(&endpoint, endpointGroup) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relations changes inside the database", err} } break diff --git a/api/http/handler/endpointgroups/endpointgroup_delete.go b/api/http/handler/endpointgroups/endpointgroup_delete.go index 4edf260a3..f2375d0a6 100644 --- a/api/http/handler/endpointgroups/endpointgroup_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_delete.go @@ -12,8 +12,8 @@ import ( ) // @id EndpointGroupDelete -// @summary Remove an endpoint group -// @description Remove an endpoint group. +// @summary Remove an environment(endpoint) group +// @description Remove an environment(endpoint) group. // @description **Access policy**: administrator // @tags endpoint_groups // @security jwt @@ -28,28 +28,28 @@ import ( func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment group identifier route variable", err} } if endpointGroupID == 1 { - return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", errors.New("Cannot remove the default endpoint group")} + return &httperror.HandlerError{http.StatusForbidden, "Unable to remove the default 'Unassigned' group", errors.New("Cannot remove the default environment group")} } endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment group with the specified identifier inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment group with the specified identifier inside the database", err} } err = handler.DataStore.EndpointGroup().DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID)) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the endpoint group from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the environment group from the database", err} } endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment from the database", err} } for _, endpoint := range endpoints { @@ -57,12 +57,12 @@ func (handler *Handler) endpointGroupDelete(w http.ResponseWriter, r *http.Reque endpoint.GroupID = portainer.EndpointGroupID(1) err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, &endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update environment", err} } err = handler.updateEndpointRelations(&endpoint, nil) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relations changes inside the database", err} } } } diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go index b28370827..204bf9a19 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_add.go @@ -11,13 +11,13 @@ import ( ) // @id EndpointGroupAddEndpoint -// @summary Add an endpoint to an endpoint group -// @description Add an endpoint to an endpoint group +// @summary Add an environment(endpoint) to an environment(endpoint) group +// @description Add an environment(endpoint) to an environment(endpoint) group // @description **Access policy**: administrator // @tags endpoint_groups // @security jwt // @param id path int true "EndpointGroup identifier" -// @param endpointId path int true "Endpoint identifier" +// @param endpointId path int true "Environment(Endpoint) identifier" // @success 204 "Success" // @failure 400 "Invalid request" // @failure 404 "EndpointGroup not found" @@ -26,38 +26,38 @@ import ( func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment group identifier route variable", err} } endpointID, err := request.RetrieveNumericRouteVariableValue(r, "endpointId") if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment group with the specified identifier inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment group with the specified identifier inside the database", err} } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } endpoint.GroupID = endpointGroup.ID err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} } err = handler.updateEndpointRelations(endpoint, endpointGroup) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relations changes inside the database", err} } return response.Empty(w) diff --git a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go index ecba9adaa..ef563d0ae 100644 --- a/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go +++ b/api/http/handler/endpointgroups/endpointgroup_endpoint_delete.go @@ -11,12 +11,12 @@ import ( ) // @id EndpointGroupDeleteEndpoint -// @summary Removes endpoint from an endpoint group +// @summary Removes environment(endpoint) from an environment(endpoint) group // @description **Access policy**: administrator // @tags endpoint_groups // @security jwt // @param id path int true "EndpointGroup identifier" -// @param endpointId path int true "Endpoint identifier" +// @param endpointId path int true "Environment(Endpoint) identifier" // @success 204 "Success" // @failure 400 "Invalid request" // @failure 404 "EndpointGroup not found" @@ -25,38 +25,38 @@ import ( func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment group identifier route variable", err} } endpointID, err := request.RetrieveNumericRouteVariableValue(r, "endpointId") if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } _, err = handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment group with the specified identifier inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment group with the specified identifier inside the database", err} } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } endpoint.GroupID = portainer.EndpointGroupID(1) err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} } err = handler.updateEndpointRelations(endpoint, nil) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relations changes inside the database", err} } return response.Empty(w) diff --git a/api/http/handler/endpointgroups/endpointgroup_inspect.go b/api/http/handler/endpointgroups/endpointgroup_inspect.go index eb573c145..e689b4ecc 100644 --- a/api/http/handler/endpointgroups/endpointgroup_inspect.go +++ b/api/http/handler/endpointgroups/endpointgroup_inspect.go @@ -10,30 +10,30 @@ import ( "github.com/portainer/portainer/api/bolt/errors" ) -// @summary Inspect an Endpoint group -// @description Retrieve details abont an endpoint group. +// @summary Inspect an Environment(Endpoint) group +// @description Retrieve details abont an environment(endpoint) group. // @description **Access policy**: administrator // @tags endpoint_groups // @security jwt // @accept json // @produce json -// @param id path int true "Endpoint group identifier" +// @param id path int true "Environment(Endpoint) group identifier" // @success 200 {object} portainer.EndpointGroup "Success" // @failure 400 "Invalid request" // @failure 404 "EndpointGroup not found" // @failure 500 "Server error" -// @router /endpoint_groups/:id [get] +// @router /endpoint_groups/{id} [get] func (handler *Handler) endpointGroupInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment group identifier route variable", err} } endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment group with the specified identifier inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment group with the specified identifier inside the database", err} } return response.JSON(w, endpointGroup) diff --git a/api/http/handler/endpointgroups/endpointgroup_list.go b/api/http/handler/endpointgroups/endpointgroup_list.go index b011d6a2c..acfbbe792 100644 --- a/api/http/handler/endpointgroups/endpointgroup_list.go +++ b/api/http/handler/endpointgroups/endpointgroup_list.go @@ -9,21 +9,21 @@ import ( ) // @id EndpointGroupList -// @summary List Endpoint groups -// @description List all endpoint groups based on the current user authorizations. Will -// @description return all endpoint groups if using an administrator account otherwise it will -// @description only return authorized endpoint groups. +// @summary List Environment(Endpoint) groups +// @description List all environment(endpoint) groups based on the current user authorizations. Will +// @description return all environment(endpoint) groups if using an administrator account otherwise it will +// @description only return authorized environment(endpoint) groups. // @description **Access policy**: restricted // @tags endpoint_groups // @security jwt // @produce json -// @success 200 {array} portainer.EndpointGroup "Endpoint group" +// @success 200 {array} portainer.EndpointGroup "Environment(Endpoint) group" // @failure 500 "Server error" // @router /endpoint_groups [get] func (handler *Handler) endpointGroupList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err} } securityContext, err := security.RetrieveRestrictedRequestContext(r) diff --git a/api/http/handler/endpointgroups/endpointgroup_update.go b/api/http/handler/endpointgroups/endpointgroup_update.go index 7d8c284f4..04cb05edd 100644 --- a/api/http/handler/endpointgroups/endpointgroup_update.go +++ b/api/http/handler/endpointgroups/endpointgroup_update.go @@ -13,11 +13,11 @@ import ( ) type endpointGroupUpdatePayload struct { - // Endpoint group name - Name string `example:"my-endpoint-group"` - // Endpoint group description + // Environment(Endpoint) group name + Name string `example:"my-environment-group"` + // Environment(Endpoint) group description Description string `example:"description"` - // List of tag identifiers associated to the endpoint group + // List of tag identifiers associated to the environment(endpoint) group TagIDs []portainer.TagID `example:"3,4"` UserAccessPolicies portainer.UserAccessPolicies TeamAccessPolicies portainer.TeamAccessPolicies @@ -28,8 +28,8 @@ func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { } // @id EndpointGroupUpdate -// @summary Update an endpoint group -// @description Update an endpoint group. +// @summary Update an environment(endpoint) group +// @description Update an environment(endpoint) group. // @description **Access policy**: administrator // @tags endpoint_groups // @security jwt @@ -41,11 +41,11 @@ func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error { // @failure 400 "Invalid request" // @failure 404 "EndpointGroup not found" // @failure 500 "Server error" -// @router /endpoint_groups/:id [put] +// @router /endpoint_groups/{id} [put] func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment group identifier route variable", err} } var payload endpointGroupUpdatePayload @@ -56,9 +56,9 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(portainer.EndpointGroupID(endpointGroupID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment group with the specified identifier inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment group with the specified identifier inside the database", err} } if payload.Name != "" { @@ -123,7 +123,7 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque if updateAuthorizations { endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err} } for _, endpoint := range endpoints { @@ -140,13 +140,13 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque err = handler.DataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, endpointGroup) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment group changes inside the database", err} } if tagsChanged { endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err} } @@ -154,7 +154,7 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque if endpoint.GroupID == endpointGroup.ID { err = handler.updateEndpointRelations(&endpoint, endpointGroup) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relations changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relations changes inside the database", err} } } } diff --git a/api/http/handler/endpointgroups/handler.go b/api/http/handler/endpointgroups/handler.go index 08760bd66..69731e586 100644 --- a/api/http/handler/endpointgroups/handler.go +++ b/api/http/handler/endpointgroups/handler.go @@ -10,14 +10,14 @@ import ( "github.com/portainer/portainer/api/http/security" ) -// Handler is the HTTP handler used to handle endpoint group operations. +// Handler is the HTTP handler used to handle environment(endpoint) group operations. type Handler struct { *mux.Router AuthorizationService *authorization.Service DataStore portainer.DataStore } -// NewHandler creates a handler to manage endpoint group operations. +// NewHandler creates a handler to manage environment(endpoint) group operations. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go index 037cb4dfb..ec3da1e7b 100644 --- a/api/http/handler/endpointproxy/handler.go +++ b/api/http/handler/endpointproxy/handler.go @@ -29,6 +29,10 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) h.PathPrefix("/{id}/kubernetes").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI))) + h.PathPrefix("/{id}/agent/docker").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) + h.PathPrefix("/{id}/agent/kubernetes").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToKubernetesAPI))) h.PathPrefix("/{id}/storidge").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToStoridgeAPI))) return h diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go index 8176edf8a..b838f0edc 100644 --- a/api/http/handler/endpointproxy/proxy_azure.go +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -14,19 +14,19 @@ import ( func (handler *Handler) proxyRequestsToAzureAPI(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } var proxy http.Handler diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index c4671506d..0f0db7af6 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -3,6 +3,7 @@ package endpointproxy import ( "errors" "strconv" + "strings" "time" httperror "github.com/portainer/libhttp/error" @@ -16,24 +17,24 @@ import ( func (handler *Handler) proxyRequestsToDockerAPI(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { if endpoint.EdgeID == "" { - return &httperror.HandlerError{http.StatusInternalServerError, "No Edge agent registered with the endpoint", errors.New("No agent available")} + return &httperror.HandlerError{http.StatusInternalServerError, "No Edge agent registered with the environment", errors.New("No agent available")} } tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID) @@ -65,6 +66,12 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http. } id := strconv.Itoa(endpointID) - http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r) + + prefix := "/" + id + "/agent/docker"; + if !strings.HasPrefix(r.URL.Path, prefix) { + prefix = "/" + id + "/docker"; + } + + http.StripPrefix(prefix, proxy).ServeHTTP(w, r) return nil } diff --git a/api/http/handler/endpointproxy/proxy_kubernetes.go b/api/http/handler/endpointproxy/proxy_kubernetes.go index 1f6ed3bfd..2e3323079 100644 --- a/api/http/handler/endpointproxy/proxy_kubernetes.go +++ b/api/http/handler/endpointproxy/proxy_kubernetes.go @@ -17,24 +17,24 @@ import ( func (handler *Handler) proxyRequestsToKubernetesAPI(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { if endpoint.EdgeID == "" { - return &httperror.HandlerError{http.StatusInternalServerError, "No Edge agent registered with the endpoint", errors.New("No agent available")} + return &httperror.HandlerError{http.StatusInternalServerError, "No Edge agent registered with the environment", errors.New("No agent available")} } tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID) @@ -65,17 +65,18 @@ func (handler *Handler) proxyRequestsToKubernetesAPI(w http.ResponseWriter, r *h } } + // For KubernetesLocalEnvironment requestPrefix := fmt.Sprintf("/%d/kubernetes", endpointID) + if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { - if isKubernetesRequest(strings.TrimPrefix(r.URL.String(), requestPrefix)) { - requestPrefix = fmt.Sprintf("/%d", endpointID) + requestPrefix = fmt.Sprintf("/%d", endpointID) + + agentPrefix := fmt.Sprintf("/%d/agent/kubernetes", endpointID) + if strings.HasPrefix(r.URL.Path, agentPrefix) { + requestPrefix = agentPrefix } } http.StripPrefix(requestPrefix, proxy).ServeHTTP(w, r) return nil } - -func isKubernetesRequest(requestURL string) bool { - return strings.HasPrefix(requestURL, "/api") || strings.HasPrefix(requestURL, "/healthz") -} diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go index 6cc59ff17..147845bc0 100644 --- a/api/http/handler/endpointproxy/proxy_storidge.go +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -17,19 +17,19 @@ import ( func (handler *Handler) proxyRequestsToStoridgeAPI(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } var storidgeExtension *portainer.EndpointExtension @@ -40,7 +40,7 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt } if storidgeExtension == nil { - return &httperror.HandlerError{http.StatusServiceUnavailable, "Storidge extension not supported on this endpoint", errors.New("This extension is not supported")} + return &httperror.HandlerError{http.StatusServiceUnavailable, "Storidge extension not supported on this environment", errors.New("This extension is not supported")} } proxyExtensionKey := strconv.Itoa(endpointID) + "_" + strconv.Itoa(int(portainer.StoridgeEndpointExtension)) + "_" + storidgeExtension.URL diff --git a/api/http/handler/endpoints/endpoint_association_delete.go b/api/http/handler/endpoints/endpoint_association_delete.go new file mode 100644 index 000000000..092f9bd30 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_association_delete.go @@ -0,0 +1,89 @@ +package endpoints + +import ( + "encoding/base64" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + + 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 environment(endpoint) +// @description De-association an edge environment(endpoint). +// @description **Access policy**: administrator +// @security jwt +// @tags endpoints +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {object} portainer.Endpoint "Success" +// @failure 400 "Invalid request" +// @failure 404 "Environment(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 environment 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 environment with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} + } + + if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment type", errors.New("Invalid environment type")} + } + + endpoint.EdgeID = "" + endpoint.Snapshots = []portainer.DockerSnapshot{} + endpoint.Kubernetes.Snapshots = []portainer.KubernetesSnapshot{} + + endpoint.EdgeKey, err = handler.updateEdgeKey(endpoint.EdgeKey) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Invalid EdgeKey", err} + } + + err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting environment in database", err} + } + + handler.ReverseTunnelService.SetTunnelStatusToIdle(endpoint.ID) + + return response.JSON(w, endpoint) +} + +func (handler *Handler) updateEdgeKey(edgeKey string) (string, error) { + oldEdgeKeyByte, err := base64.RawStdEncoding.DecodeString(edgeKey) + if err != nil { + return "", err + } + + oldEdgeKeyStr := string(oldEdgeKeyByte) + + httpPort := getPort(handler.BindAddress) + httpsPort := getPort(handler.BindAddressHTTPS) + + // replace "http://" with "https://" and replace ":9000" with ":9443", in the case of default values + // oldEdgeKeyStr example: http://10.116.1.178:9000|10.116.1.178:8000|46:99:4a:8d:a6:de:6a:bd:d8:e2:1c:99:81:60:54:55|52 + r := regexp.MustCompile(fmt.Sprintf("^(http://)([^|]+)(:%s)(|.*)", httpPort)) + newEdgeKeyStr := r.ReplaceAllString(oldEdgeKeyStr, fmt.Sprintf("https://$2:%s$4", httpsPort)) + + return base64.RawStdEncoding.EncodeToString([]byte(newEdgeKeyStr)), nil +} + +func getPort(url string) string { + items := strings.Split(url, ":") + return items[len(items)-1] +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 946fa7d7e..54d3ca6f0 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -53,13 +53,13 @@ const ( func (payload *endpointCreatePayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { - return errors.New("Invalid endpoint name") + return errors.New("Invalid environment name") } payload.Name = name endpointCreationType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointCreationType", false) if err != nil || endpointCreationType == 0 { - return errors.New("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge Agent environment) or 5 (Local Kubernetes environment)") + return errors.New("Invalid environment type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge Agent environment) or 5 (Local Kubernetes environment)") } payload.EndpointCreationType = endpointCreationEnum(endpointCreationType) @@ -133,7 +133,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { default: endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true) if err != nil { - return errors.New("Invalid endpoint URL") + return errors.New("Invalid environment URL") } payload.URL = endpointURL @@ -148,28 +148,28 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { } // @id EndpointCreate -// @summary Create a new endpoint -// @description Create a new endpoint that will be used to manage an environment. +// @summary Create a new environment(endpoint) +// @description Create a new environment(endpoint) that will be used to manage an environment(endpoint). // @description **Access policy**: administrator // @tags endpoints // @security jwt // @accept multipart/form-data // @produce json -// @param Name formData string true "Name that will be used to identify this endpoint (example: my-endpoint)" -// @param EndpointCreationType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5) +// @param Name formData string true "Name that will be used to identify this environment(endpoint) (example: my-environment)" +// @param EndpointCreationType formData integer true "Environment(Endpoint) type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5) // @param URL formData string false "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine)" // @param PublicURL formData string false "URL or IP address where exposed containers will be reachable. Defaults to URL if not specified (example: docker.mydomain.tld:2375)" -// @param GroupID formData int false "Endpoint group identifier. If not specified will default to 1 (unassigned)." -// @param TLS formData bool false "Require TLS to connect against this endpoint" +// @param GroupID formData int false "Environment(Endpoint) group identifier. If not specified will default to 1 (unassigned)." +// @param TLS formData bool false "Require TLS to connect against this environment(endpoint)" // @param TLSSkipVerify formData bool false "Skip server verification when using TLS" // @param TLSSkipClientVerify formData bool false "Skip client verification when using TLS" // @param TLSCACertFile formData file false "TLS CA certificate file" // @param TLSCertFile formData file false "TLS client certificate file" // @param TLSKeyFile formData file false "TLS client key file" -// @param AzureApplicationID formData string false "Azure application ID. Required if endpoint type is set to 3" -// @param AzureTenantID formData string false "Azure tenant ID. Required if endpoint type is set to 3" -// @param AzureAuthenticationKey formData string false "Azure authentication key. Required if endpoint type is set to 3" -// @param TagIDs formData []int false "List of tag identifiers to which this endpoint is associated" +// @param AzureApplicationID formData string false "Azure application ID. Required if environment(endpoint) type is set to 3" +// @param AzureTenantID formData string false "Azure tenant ID. Required if environment(endpoint) type is set to 3" +// @param AzureAuthenticationKey formData string false "Azure authentication key. Required if environment(endpoint) type is set to 3" +// @param TagIDs formData []int false "List of tag identifiers to which this environment(endpoint) is associated" // @param EdgeCheckinInterval formData int false "The check in interval for edge agent (in seconds)" // @success 200 {object} portainer.Endpoint "Success" // @failure 400 "Invalid request" @@ -189,7 +189,7 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment group inside the database", err} } edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() @@ -238,7 +238,7 @@ func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portain if payload.EndpointCreationType == agentEnvironment { agentPlatform, err := handler.pingAndCheckPlatform(payload) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to get endpoint type", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to get environment type", err} } if agentPlatform == portainer.AgentPlatformDocker { @@ -288,7 +288,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the environment", err} } return endpoint, nil @@ -299,7 +299,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) portainerURL, err := url.Parse(payload.URL) if err != nil { - return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint URL", err} + return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid environment URL", err} } portainerHost, _, err := net.SplitHostPort(portainerURL.Host) @@ -308,7 +308,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) } if portainerHost == "localhost" { - return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint URL", errors.New("cannot use localhost as endpoint URL")} + return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid environment URL", errors.New("cannot use localhost as environment URL")} } edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID) @@ -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, @@ -335,7 +335,7 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} + return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the environment", err} } return endpoint, nil @@ -455,12 +455,12 @@ func (handler *Handler) snapshotAndPersistEndpoint(endpoint *portainer.Endpoint) if strings.Contains(err.Error(), "Invalid request signature") { err = errors.New("agent already paired with another Portainer instance") } - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to initiate communications with endpoint", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to initiate communications with environment", err} } err = handler.saveEndpointAndUpdateAuthorizations(endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} + return &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the environment", err} } return nil diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 9fda9b74c..13624237e 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -12,28 +12,28 @@ import ( ) // @id EndpointDelete -// @summary Remove an endpoint -// @description Remove an endpoint. +// @summary Remove an environment(endpoint) +// @description Remove an environment(endpoint). // @description **Access policy**: administrator // @tags endpoints // @security jwt -// @param id path int true "Endpoint identifier" +// @param id path int true "Environment(Endpoint) identifier" // @success 204 "Success" // @failure 400 "Invalid request" -// @failure 404 "Endpoint not found" +// @failure 404 "Environment(Endpoint) not found" // @failure 500 "Server error" // @router /endpoints/{id} [delete] func (handler *Handler) endpointDelete(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } if endpoint.TLSConfig.TLS { @@ -46,14 +46,14 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * err = handler.DataStore.Endpoint().DeleteEndpoint(portainer.EndpointID(endpointID)) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove environment from the database", err} } handler.ProxyManager.DeleteEndpointProxy(endpoint) err = handler.DataStore.EndpointRelation().DeleteEndpointRelation(endpoint.ID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint relation from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove environment relation from the database", err} } for _, tagID := range endpoint.TagIDs { @@ -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) } diff --git a/api/http/handler/endpoints/endpoint_dockerhub_status.go b/api/http/handler/endpoints/endpoint_dockerhub_status.go index b454f9378..5be4d39a4 100644 --- a/api/http/handler/endpoints/endpoint_dockerhub_status.go +++ b/api/http/handler/endpoints/endpoint_dockerhub_status.go @@ -22,31 +22,48 @@ 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 { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } if !endpointutils.IsLocalEndpoint(endpoint) { 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) diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go index 36112e5ea..7e08fc1b8 100644 --- a/api/http/handler/endpoints/endpoint_extension_add.go +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -32,14 +32,14 @@ func (payload *endpointExtensionAddPayload) Validate(r *http.Request) error { func (handler *Handler) endpointExtensionAdd(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } var payload endpointExtensionAddPayload @@ -69,7 +69,7 @@ func (handler *Handler) endpointExtensionAdd(w http.ResponseWriter, r *http.Requ err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} } return response.JSON(w, extension) diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go index 13e06a1f5..75d798cd7 100644 --- a/api/http/handler/endpoints/endpoint_extension_remove.go +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -15,14 +15,14 @@ import ( func (handler *Handler) endpointExtensionRemove(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } extensionType, err := request.RetrieveNumericRouteVariableValue(r, "extensionType") @@ -38,7 +38,7 @@ func (handler *Handler) endpointExtensionRemove(w http.ResponseWriter, r *http.R err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} } return response.Empty(w) diff --git a/api/http/handler/endpoints/endpoint_inspect.go b/api/http/handler/endpoints/endpoint_inspect.go index bab2452f5..61a6c0b91 100644 --- a/api/http/handler/endpoints/endpoint_inspect.go +++ b/api/http/handler/endpoints/endpoint_inspect.go @@ -11,34 +11,34 @@ import ( ) // @id EndpointInspect -// @summary Inspect an endpoint -// @description Retrieve details about an endpoint. +// @summary Inspect an environment(endpoint) +// @description Retrieve details about an environment(endpoint). // @description **Access policy**: restricted // @tags endpoints // @security jwt // @produce json -// @param id path int true "Endpoint identifier" +// @param id path int true "Environment(Endpoint) identifier" // @success 200 {object} portainer.Endpoint "Success" // @failure 400 "Invalid request" -// @failure 404 "Endpoint not found" +// @failure 404 "Environment(Endpoint) not found" // @failure 500 "Server error" // @router /endpoints/{id} [get] func (handler *Handler) endpointInspect(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } hideFields(endpoint) diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index c1dd76209..d87c278bd 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -14,24 +14,24 @@ import ( ) // @id EndpointList -// @summary List endpoints -// @description List all endpoints based on the current user authorizations. Will -// @description return all endpoints if using an administrator account otherwise it will -// @description only return authorized endpoints. +// @summary List environments(endpoints) +// @description List all environments(endpoints) based on the current user authorizations. Will +// @description return all environments(endpoints) if using an administrator account otherwise it will +// @description only return authorized environments(endpoints). // @description **Access policy**: restricted // @tags endpoints // @security jwt // @produce json // @param start query int false "Start searching from" // @param search query string false "Search query" -// @param groupId query int false "List endpoints of this group" +// @param groupId query int false "List environments(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 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" +// @param types query []int false "List environments(endpoints) of this type" +// @param tagIds query []int false "search environments(endpoints) with these tags (depends on tagsPartialMatch)" +// @param tagsPartialMatch query bool false "If true, will return environment(endpoint) which has one of tagIds, if false (or missing) will return only environments(endpoints) that has all the tags" +// @param endpointIds query []int false "will return only these environments(endpoints)" // @success 200 {array} portainer.Endpoint "Endpoints" -// @failure 500 Server error +// @failure 500 "Server error" // @router /endpoints [get] func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { start, _ := request.RetrieveNumericQueryParameter(r, "start", true) @@ -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) @@ -58,12 +60,12 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err} } endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err} } settings, err := handler.DataStore.Settings().Settings() @@ -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) } } diff --git a/api/http/handler/endpoints/endpoint_registries_inspect.go b/api/http/handler/endpoints/endpoint_registries_inspect.go new file mode 100644 index 000000000..03c0fe66c --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registries_inspect.go @@ -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 environment 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) +} diff --git a/api/http/handler/endpoints/endpoint_registries_list.go b/api/http/handler/endpoints/endpoint_registries_list.go new file mode 100644 index 000000000..7d276e000 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registries_list.go @@ -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" + "github.com/portainer/portainer/api/internal/endpointutils" +) + +// 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 environment 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 environment with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment 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 environment'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 + } +} diff --git a/api/http/handler/endpoints/endpoint_registry_access.go b/api/http/handler/endpoints/endpoint_registry_access.go new file mode 100644 index 000000000..d74fe721e --- /dev/null +++ b/api/http/handler/endpoints/endpoint_registry_access.go @@ -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 environment 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 environment with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an environment 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 environment", 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 environment with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find an environment 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 +} diff --git a/api/http/handler/endpoints/endpoint_settings_update.go b/api/http/handler/endpoints/endpoint_settings_update.go index 5cccf5aed..0950f2d24 100644 --- a/api/http/handler/endpoints/endpoint_settings_update.go +++ b/api/http/handler/endpoints/endpoint_settings_update.go @@ -36,24 +36,24 @@ func (payload *endpointSettingsUpdatePayload) Validate(r *http.Request) error { } // @id EndpointSettingsUpdate -// @summary Update settings for an endpoint -// @description Update settings for an endpoint. +// @summary Update settings for an environments(endpoints) +// @description Update settings for an environments(endpoints). // @description **Access policy**: administrator // @security jwt // @tags endpoints // @accept json // @produce json -// @param id path int true "Endpoint identifier" -// @param body body endpointSettingsUpdatePayload true "Endpoint details" +// @param id path int true "Environment(Endpoint) identifier" +// @param body body endpointSettingsUpdatePayload true "Environment(Endpoint) details" // @success 200 {object} portainer.Endpoint "Success" // @failure 400 "Invalid request" -// @failure 404 "Endpoint not found" +// @failure 404 "Environment(Endpoint) not found" // @failure 500 "Server error" -// @router /api/endpoints/:id/settings [put] +// @router /api/endpoints/{id}/settings [put] func (handler *Handler) endpointSettingsUpdate(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } var payload endpointSettingsUpdatePayload @@ -64,9 +64,9 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } securitySettings := endpoint.SecuritySettings @@ -111,7 +111,7 @@ func (handler *Handler) endpointSettingsUpdate(w http.ResponseWriter, r *http.Re err = handler.DataStore.Endpoint().UpdateEndpoint(portainer.EndpointID(endpointID), endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting endpoint in database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Failed persisting environment in database", err} } return response.JSON(w, endpoint) diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 4fc16acbe..8d9ed811c 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -12,39 +12,39 @@ import ( ) // @id EndpointSnapshot -// @summary Snapshots an endpoint -// @description Snapshots an endpoint +// @summary Snapshots an environments(endpoints) +// @description Snapshots an environments(endpoints) // @description **Access policy**: restricted // @tags endpoints // @security jwt -// @param id path int true "Endpoint identifier" +// @param id path int true "Environment(Endpoint) identifier" // @success 204 "Success" // @failure 400 "Invalid request" -// @failure 404 "Endpoint not found" +// @failure 404 "Environment(Endpoint) not found" // @failure 500 "Server error" // @router /endpoints/{id}/snapshot [post] func (handler *Handler) endpointSnapshot(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } if !snapshot.SupportDirectSnapshot(endpoint) { - return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this endpoint", err} + return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for this environment", err} } snapshotError := handler.SnapshotService.SnapshotEndpoint(endpoint) latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) if latestEndpointReference == nil { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err} } latestEndpointReference.Status = portainer.EndpointStatusUp @@ -57,7 +57,7 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} } return response.Empty(w) diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go index 3c986a1ec..67aea973e 100644 --- a/api/http/handler/endpoints/endpoint_snapshots.go +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -11,8 +11,8 @@ import ( ) // @id EndpointSnapshots -// @summary Snapshot all endpoints -// @description Snapshot all endpoints +// @summary Snapshot all environments(endpoints) +// @description Snapshot all environments(endpoints) // @description **Access policy**: administrator // @tags endpoints // @security jwt @@ -22,7 +22,7 @@ import ( func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err} } for _, endpoint := range endpoints { @@ -34,13 +34,13 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) if latestEndpointReference == nil { - log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("background schedule error (environment snapshot). Environment not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) continue } endpoint.Status = portainer.EndpointStatusUp if snapshotError != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + log.Printf("background schedule error (environment snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) endpoint.Status = portainer.EndpointStatusDown } @@ -49,7 +49,7 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request err = handler.DataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} } } diff --git a/api/http/handler/endpoints/endpoint_status_inspect.go b/api/http/handler/endpoints/endpoint_status_inspect.go index 3577e3aac..9aa9bc4aa 100644 --- a/api/http/handler/endpoints/endpoint_status_inspect.go +++ b/api/http/handler/endpoints/endpoint_status_inspect.go @@ -35,49 +35,49 @@ type edgeJobResponse struct { } type endpointStatusInspectResponse struct { - // Status represents the endpoint status + // Status represents the environment(endpoint) status Status string `json:"status" example:"REQUIRED"` // The tunnel port Port int `json:"port" example:"8732"` - // List of requests for jobs to run on the endpoint + // List of requests for jobs to run on the environment(endpoint) Schedules []edgeJobResponse `json:"schedules"` // The current value of CheckinInterval CheckinInterval int `json:"checkin" example:"5"` // Credentials string `json:"credentials" example:""` - // List of stacks to be deployed on the endpoints + // List of stacks to be deployed on the environments(endpoints) Stacks []stackStatusResponse `json:"stacks"` } // @id EndpointStatusInspect -// @summary Get endpoint status -// @description Endpoint for edge agent to check status of environment -// @description **Access policy**: restricted only to Edge endpoints +// @summary Get environment(endpoint) status +// @description Environment(Endpoint) for edge agent to check status of environment(endpoint) +// @description **Access policy**: restricted only to Edge environments(endpoints) // @tags endpoints // @security jwt -// @param id path int true "Endpoint identifier" +// @param id path int true "Environment(Endpoint) identifier" // @success 200 {object} endpointStatusInspectResponse "Success" // @failure 400 "Invalid request" -// @failure 403 "Permission denied to access endpoint" -// @failure 404 "Endpoint not found" +// @failure 403 "Permission denied to access environment(endpoint)" +// @failure 404 "Environment(Endpoint) not found" // @failure 500 "Server error" // @router /endpoints/{id}/status [get] func (handler *Handler) endpointStatusInspect(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } if endpoint.EdgeID == "" { @@ -107,7 +107,7 @@ func (handler *Handler) endpointStatusInspect(w http.ResponseWriter, r *http.Req err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to Unable to persist endpoint changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to Unable to persist environment changes inside the database", err} } settings, err := handler.DataStore.Settings().Settings() diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 99dfd9571..dfef69542 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -16,8 +16,8 @@ import ( ) type endpointUpdatePayload struct { - // Name that will be used to identify this endpoint - Name *string `example:"my-endpoint"` + // Name that will be used to identify this environment(endpoint) + Name *string `example:"my-environment"` // URL or IP address of a Docker host URL *string `example:"docker.mydomain.tld:2375"` // URL or IP address where exposed containers will be reachable.\ @@ -25,13 +25,13 @@ type endpointUpdatePayload struct { PublicURL *string `example:"docker.mydomain.tld:2375"` // Group identifier GroupID *int `example:"1"` - // Require TLS to connect against this endpoint + // Require TLS to connect against this environment(endpoint) TLS *bool `example:"true"` // Skip server verification when using TLS TLSSkipVerify *bool `example:"false"` // Skip client verification when using TLS TLSSkipClientVerify *bool `example:"false"` - // The status of the endpoint (1 - up, 2 - down) + // The status of the environment(endpoint) (1 - up, 2 - down) Status *int `example:"1"` // Azure application ID AzureApplicationID *string `example:"eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4"` @@ -39,7 +39,7 @@ type endpointUpdatePayload struct { AzureTenantID *string `example:"34ddc78d-4fel-2358-8cc1-df84c8o839f5"` // Azure authentication key AzureAuthenticationKey *string `example:"cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk="` - // List of tag identifiers to which this endpoint is associated + // List of tag identifiers to which this environment(endpoint) is associated TagIDs []portainer.TagID `example:"1,2"` UserAccessPolicies portainer.UserAccessPolicies TeamAccessPolicies portainer.TeamAccessPolicies @@ -54,24 +54,24 @@ func (payload *endpointUpdatePayload) Validate(r *http.Request) error { } // @id EndpointUpdate -// @summary Update an endpoint -// @description Update an endpoint. +// @summary Update an environment(endpoint) +// @description Update an environment(endpoint). // @description **Access policy**: administrator // @security jwt // @tags endpoints // @accept json // @produce json -// @param id path int true "Endpoint identifier" -// @param body body endpointUpdatePayload true "Endpoint details" +// @param id path int true "Environment(Endpoint) identifier" +// @param body body endpointUpdatePayload true "Environment(Endpoint) details" // @success 200 {object} portainer.Endpoint "Success" // @failure 400 "Invalid request" -// @failure 404 "Endpoint not found" +// @failure 404 "Environment(Endpoint) not found" // @failure 500 "Server error" // @router /endpoints/{id} [put] func (handler *Handler) endpointUpdate(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} + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment identifier route variable", err} } var payload endpointUpdatePayload @@ -82,9 +82,9 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } if payload.Name != nil { @@ -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 @@ -251,7 +257,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * if payload.URL != nil || payload.TLS != nil || endpoint.Type == portainer.AzureEnvironment { _, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the environment", err} } } @@ -266,18 +272,18 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err} } if (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) && (groupIDChanged || tagsChanged) { relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpoint.ID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint relation inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation inside the database", err} } endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpoint.GroupID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find endpoint group inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment group inside the database", err} } edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() @@ -301,7 +307,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpoint.ID, relation) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint relation changes inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relation changes inside the database", err} } } diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index cd82092e7..ee2a94794 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -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" @@ -19,7 +20,7 @@ func hideFields(endpoint *portainer.Endpoint) { } } -// Handler is the HTTP handler used to handle endpoint operations. +// Handler is the HTTP handler used to handle environment(endpoint) operations. type Handler struct { *mux.Router requestBouncer *security.RequestBouncer @@ -28,11 +29,14 @@ type Handler struct { ProxyManager *proxy.Manager ReverseTunnelService portainer.ReverseTunnelService SnapshotService portainer.SnapshotService + K8sClientFactory *cli.ClientFactory ComposeStackManager portainer.ComposeStackManager AuthorizationService *authorization.Service + BindAddress string + BindAddressHTTPS string } -// NewHandler creates a handler to manage endpoint operations. +// NewHandler creates a handler to manage environment(endpoint) operations. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ Router: mux.NewRouter(), @@ -43,6 +47,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 +59,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 +69,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 } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index c706f05f5..e2d6bcc77 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -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" @@ -17,11 +16,14 @@ import ( "github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" + "github.com/portainer/portainer/api/http/handler/helm" + "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/resourcecontrols" "github.com/portainer/portainer/api/http/handler/roles" "github.com/portainer/portainer/api/http/handler/settings" + "github.com/portainer/portainer/api/http/handler/ssl" "github.com/portainer/portainer/api/http/handler/stacks" "github.com/portainer/portainer/api/http/handler/status" "github.com/portainer/portainer/api/http/handler/tags" @@ -39,7 +41,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 @@ -47,13 +48,17 @@ type Handler struct { EndpointEdgeHandler *endpointedge.Handler EndpointGroupHandler *endpointgroups.Handler EndpointHandler *endpoints.Handler + EndpointHelmHandler *helm.Handler EndpointProxyHandler *endpointproxy.Handler + HelmTemplatesHandler *helm.Handler + KubernetesHandler *kubernetes.Handler FileHandler *file.Handler MOTDHandler *motd.Handler RegistryHandler *registries.Handler ResourceControlHandler *resourcecontrols.Handler RoleHandler *roles.Handler SettingsHandler *settings.Handler + SSLHandler *ssl.Handler StackHandler *stacks.Handler StatusHandler *status.Handler TagHandler *tags.Handler @@ -67,14 +72,14 @@ type Handler struct { } // @title PortainerCE API -// @version 2.6.3 +// @version 2.9.0 // @description.markdown api-description.md // @termsOfService // @contact.email info@portainer.io -// @license.name -// @license.url +// @license.name zlib +// @license.url https://github.com/portainer/portainer/blob/develop/LICENSE // @host // @BasePath /api @@ -88,8 +93,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 @@ -99,11 +102,13 @@ type Handler struct { // @tag.name edge_templates // @tag.description Manage Edge Templates // @tag.name edge -// @tag.description Manage Edge related endpoint settings +// @tag.description Manage Edge related environment(endpoint) settings // @tag.name endpoints -// @tag.description Manage Docker environments +// @tag.description Manage Docker environments(endpoints) // @tag.name endpoint_groups -// @tag.description Manage endpoint groups +// @tag.description Manage environment(endpoint) groups +// @tag.name kubernetes +// @tag.description Manage Kubernetes cluster // @tag.name motd // @tag.description Fetch the message of the day // @tag.name registries @@ -116,8 +121,6 @@ type Handler struct { // @tag.description Manage Portainer settings // @tag.name status // @tag.description Information about the Portainer instance -// @tag.name stacks -// @tag.description Manage Docker stacks // @tag.name users // @tag.description Manage users // @tag.name tags @@ -130,6 +133,8 @@ type Handler struct { // @tag.description Manage App Templates // @tag.name stacks // @tag.description Manage stacks +// @tag.name ssl +// @tag.description Manage ssl settings // @tag.name upload // @tag.description Upload files // @tag.name webhooks @@ -146,8 +151,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"): @@ -162,6 +165,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.EdgeTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/kubernetes"): + http.StripPrefix("/api", h.KubernetesHandler).ServeHTTP(w, r) + + // Helm subpath under kubernetes -> /api/endpoints/{id}/kubernetes/helm + case strings.HasPrefix(r.URL.Path, "/api/endpoints/") && strings.Contains(r.URL.Path, "/kubernetes/helm"): + http.StripPrefix("/api/endpoints", h.EndpointHelmHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/endpoints"): switch { case strings.Contains(r.URL.Path, "/docker/"): @@ -172,6 +182,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/azure/"): http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/agent/"): + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/edge/"): http.StripPrefix("/api/endpoints", h.EndpointEdgeHandler).ServeHTTP(w, r) default: @@ -193,12 +205,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/tags"): http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/templates/helm"): + http.StripPrefix("/api", h.HelmTemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/templates"): http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/upload"): http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/users"): http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/ssl"): + http.StripPrefix("/api", h.SSLHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/teams"): http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/team_memberships"): diff --git a/api/http/handler/helm/handler.go b/api/http/handler/helm/handler.go new file mode 100644 index 000000000..ed72fe7ec --- /dev/null +++ b/api/http/handler/helm/handler.go @@ -0,0 +1,103 @@ +package helm + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/portainer/libhelm" + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes" +) + +const ( + handlerActivityContext = "Kubernetes" +) + +type requestBouncer interface { + AuthenticatedAccess(h http.Handler) http.Handler +} + +// Handler is the HTTP handler used to handle environment(endpoint) group operations. +type Handler struct { + *mux.Router + requestBouncer requestBouncer + dataStore portainer.DataStore + kubeConfigService kubernetes.KubeConfigService + helmPackageManager libhelm.HelmPackageManager +} + +// NewHandler creates a handler to manage environment(endpoint) group operations. +func NewHandler(bouncer requestBouncer, dataStore portainer.DataStore, helmPackageManager libhelm.HelmPackageManager, kubeConfigService kubernetes.KubeConfigService) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + requestBouncer: bouncer, + dataStore: dataStore, + helmPackageManager: helmPackageManager, + kubeConfigService: kubeConfigService, + } + + h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) + + // `helm list -o json` + h.Handle("/{id}/kubernetes/helm", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmList))).Methods(http.MethodGet) + + // `helm delete RELEASE_NAME` + h.Handle("/{id}/kubernetes/helm/{release}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmDelete))).Methods(http.MethodDelete) + + // `helm install [NAME] [CHART] flags` + h.Handle("/{id}/kubernetes/helm", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost) + + h.Handle("/{id}/kubernetes/helm/repositories", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userGetHelmRepos))).Methods(http.MethodGet) + h.Handle("/{id}/kubernetes/helm/repositories", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userCreateHelmRepo))).Methods(http.MethodPost) + + return h +} + +// NewTemplateHandler creates a template handler to manage environment(endpoint) group operations. +func NewTemplateHandler(bouncer requestBouncer, helmPackageManager libhelm.HelmPackageManager) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + helmPackageManager: helmPackageManager, + requestBouncer: bouncer, + } + + h.Handle("/templates/helm", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmRepoSearch))).Methods(http.MethodGet) + + // helm show [COMMAND] [CHART] [REPO] flags + h.Handle("/templates/helm/{command:chart|values|readme}", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmShow))).Methods(http.MethodGet) + + return h +} + +// getHelmClusterAccess obtains the core k8s cluster access details from request. +// The cluster access includes the cluster server url, the user's bearer token and the tls certificate. +// The cluster access is passed in as kube config CLI params to helm binary. +func (handler *Handler) getHelmClusterAccess(r *http.Request) (*options.KubernetesClusterAccess, *httperror.HandlerError) { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment on request context", err} + } + + bearerToken, err := security.ExtractBearerToken(r) + if err != nil { + return nil, &httperror.HandlerError{http.StatusUnauthorized, "Unauthorized", err} + } + + kubeConfigInternal := handler.kubeConfigService.GetKubeConfigInternal(endpoint.ID, bearerToken) + return &options.KubernetesClusterAccess{ + ClusterServerURL: kubeConfigInternal.ClusterServerURL, + CertificateAuthorityFile: kubeConfigInternal.CertificateAuthorityFile, + AuthToken: kubeConfigInternal.AuthToken, + }, nil +} diff --git a/api/http/handler/helm/helm_delete.go b/api/http/handler/helm/helm_delete.go new file mode 100644 index 000000000..e3bfe6a4e --- /dev/null +++ b/api/http/handler/helm/helm_delete.go @@ -0,0 +1,56 @@ +package helm + +import ( + "net/http" + + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +// @id HelmDelete +// @summary Delete Helm Release +// @description +// @description **Access policy**: authorized +// @tags helm +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param release path string true "The name of the release/application to uninstall" +// @param namespace query string true "An optional namespace" +// @success 204 "Success" +// @failure 400 "Invalid environment(endpoint) id or bad request" +// @failure 401 "Unauthorized" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error or helm error" +// @router /endpoints/{id}/kubernetes/helm/{release} [delete] +func (handler *Handler) helmDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + release, err := request.RetrieveRouteVariableValue(r, "release") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "No release specified", err} + } + + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + uninstallOpts := options.UninstallOptions{ + Name: release, + KubernetesClusterAccess: clusterAccess, + } + + q := r.URL.Query() + if namespace := q.Get("namespace"); namespace != "" { + uninstallOpts.Namespace = namespace + } + + err = handler.helmPackageManager.Uninstall(uninstallOpts) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Helm returned an error", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/helm/helm_delete_test.go b/api/http/handler/helm/helm_delete_test.go new file mode 100644 index 000000000..6c83b788c --- /dev/null +++ b/api/http/handler/helm/helm_delete_test.go @@ -0,0 +1,53 @@ +package helm + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/libhelm/binary/test" + "github.com/portainer/libhelm/options" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes" + "github.com/stretchr/testify/assert" + + bolt "github.com/portainer/portainer/api/bolt/bolttest" + helper "github.com/portainer/portainer/api/internal/testhelpers" +) + +func Test_helmDelete(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1}) + is.NoError(err, "Error creating environment") + + err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + is.NoError(err, "Error creating a user") + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + kubeConfigService := kubernetes.NewKubeConfigCAService("", "") + h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService) + + is.NotNil(h, "Handler should not fail") + + // Install a single chart directly, to be deleted by the handler + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} + h.helmPackageManager.Install(options) + + t.Run("helmDelete succeeds with admin user", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/1/kubernetes/helm/%s", options.Name), nil) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + req.Header.Add("Authorization", "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusNoContent, rr.Code, "Status should be 204") + }) +} diff --git a/api/http/handler/helm/helm_install.go b/api/http/handler/helm/helm_install.go new file mode 100644 index 000000000..3985a39f8 --- /dev/null +++ b/api/http/handler/helm/helm_install.go @@ -0,0 +1,135 @@ +package helm + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/portainer/libhelm/options" + "github.com/portainer/libhelm/release" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/kubernetes/validation" +) + +type installChartPayload struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Chart string `json:"chart"` + Repo string `json:"repo"` + Values string `json:"values"` +} + +var errChartNameInvalid = errors.New("invalid chart name. " + + "Chart name must consist of lower case alphanumeric characters, '-' or '.'," + + " and must start and end with an alphanumeric character", +) + +// @id HelmInstall +// @summary Install Helm Chart +// @description +// @description **Access policy**: authorized +// @tags helm +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param payload body installChartPayload true "Chart details" +// @success 201 {object} release.Release "Created" +// @failure 401 "Unauthorized" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/kubernetes/helm [post] +func (handler *Handler) helmInstall(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload installChartPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid Helm install payload", + Err: err, + } + } + + release, err := handler.installChart(r, payload) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to install a chart", + Err: err, + } + } + + w.WriteHeader(http.StatusCreated) + return response.JSON(w, release) +} + +func (p *installChartPayload) Validate(_ *http.Request) error { + var required []string + if p.Repo == "" { + required = append(required, "repo") + } + if p.Name == "" { + required = append(required, "name") + } + if p.Namespace == "" { + required = append(required, "namespace") + } + if p.Chart == "" { + required = append(required, "chart") + } + if len(required) > 0 { + return fmt.Errorf("required field(s) missing: %s", strings.Join(required, ", ")) + } + + if errs := validation.IsDNS1123Subdomain(p.Name); len(errs) > 0 { + return errChartNameInvalid + } + + return nil +} + +func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*release.Release, error) { + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return nil, httperr.Err + } + installOpts := options.InstallOptions{ + Name: p.Name, + Chart: p.Chart, + Namespace: p.Namespace, + Repo: p.Repo, + KubernetesClusterAccess: &options.KubernetesClusterAccess{ + ClusterServerURL: clusterAccess.ClusterServerURL, + CertificateAuthorityFile: clusterAccess.CertificateAuthorityFile, + AuthToken: clusterAccess.AuthToken, + }, + } + + if p.Values != "" { + file, err := os.CreateTemp("", "helm-values") + if err != nil { + return nil, err + } + defer os.Remove(file.Name()) + _, err = file.WriteString(p.Values) + if err != nil { + file.Close() + return nil, err + } + err = file.Close() + if err != nil { + return nil, err + } + installOpts.ValuesFile = file.Name() + } + + release, err := handler.helmPackageManager.Install(installOpts) + if err != nil { + return nil, err + } + return release, nil +} diff --git a/api/http/handler/helm/helm_install_test.go b/api/http/handler/helm/helm_install_test.go new file mode 100644 index 000000000..b98ecd32f --- /dev/null +++ b/api/http/handler/helm/helm_install_test.go @@ -0,0 +1,65 @@ +package helm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/libhelm/binary/test" + "github.com/portainer/libhelm/options" + "github.com/portainer/libhelm/release" + portainer "github.com/portainer/portainer/api" + bolt "github.com/portainer/portainer/api/bolt/bolttest" + "github.com/portainer/portainer/api/http/security" + helper "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/api/kubernetes" + "github.com/stretchr/testify/assert" +) + +func Test_helmInstall(t *testing.T) { + is := assert.New(t) + + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1}) + is.NoError(err, "error creating environment") + + err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + is.NoError(err, "error creating a user") + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + kubeConfigService := kubernetes.NewKubeConfigCAService("", "") + h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService) + + is.NotNil(h, "Handler should not fail") + + // Install a single chart. We expect to get these values back + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default", Repo: "https://charts.bitnami.com/bitnami"} + optdata, err := json.Marshal(options) + is.NoError(err) + + t.Run("helmInstall succeeds with admin user", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/1/kubernetes/helm", bytes.NewBuffer(optdata)) + ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) + req = req.WithContext(ctx) + req.Header.Add("Authorization", "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusCreated, rr.Code, "Status should be 201") + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + resp := release.Release{} + err = json.Unmarshal(body, &resp) + is.NoError(err, "response should be json") + is.EqualValues(options.Name, resp.Name, "Name doesn't match") + is.EqualValues(options.Namespace, resp.Namespace, "Namespace doesn't match") + }) +} diff --git a/api/http/handler/helm/helm_list.go b/api/http/handler/helm/helm_list.go new file mode 100644 index 000000000..d559af9c6 --- /dev/null +++ b/api/http/handler/helm/helm_list.go @@ -0,0 +1,64 @@ +package helm + +import ( + "net/http" + + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +// @id HelmList +// @summary List Helm Releases +// @description +// @description **Access policy**: authorized +// @tags helm +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param namespace query string true "specify an optional namespace" +// @param filter query string true "specify an optional filter" +// @param selector query string true "specify an optional selector" +// @success 200 {array} release.ReleaseElement "Success" +// @failure 400 "Invalid environment(endpoint) identifier" +// @failure 401 "Unauthorized" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error" +// @router /endpoints/{id}/kubernetes/helm [get] +func (handler *Handler) helmList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + clusterAccess, httperr := handler.getHelmClusterAccess(r) + if httperr != nil { + return httperr + } + + listOpts := options.ListOptions{ + KubernetesClusterAccess: clusterAccess, + } + + params := r.URL.Query() + + // optional namespace. The library defaults to "default" + namespace, _ := request.RetrieveQueryParameter(r, "namespace", true) + if namespace != "" { + listOpts.Namespace = namespace + } + + // optional filter + if filter := params.Get("filter"); filter != "" { + listOpts.Filter = filter + } + + // optional selector + if selector := params.Get("selector"); selector != "" { + listOpts.Selector = selector + } + + releases, err := handler.helmPackageManager.List(listOpts) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Helm returned an error", err} + } + + return response.JSON(w, releases) +} diff --git a/api/http/handler/helm/helm_list_test.go b/api/http/handler/helm/helm_list_test.go new file mode 100644 index 000000000..cc50a24aa --- /dev/null +++ b/api/http/handler/helm/helm_list_test.go @@ -0,0 +1,60 @@ +package helm + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/portainer/libhelm/binary/test" + "github.com/portainer/libhelm/options" + "github.com/portainer/libhelm/release" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes" + "github.com/stretchr/testify/assert" + + bolt "github.com/portainer/portainer/api/bolt/bolttest" + helper "github.com/portainer/portainer/api/internal/testhelpers" +) + +func Test_helmList(t *testing.T) { + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1}) + assert.NoError(t, err, "error creating environment") + + err = store.User().CreateUser(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) + assert.NoError(t, err, "error creating a user") + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + kubeConfigService := kubernetes.NewKubeConfigCAService("", "") + h := NewHandler(helper.NewTestRequestBouncer(), store, helmPackageManager, kubeConfigService) + + // Install a single chart. We expect to get these values back + options := options.InstallOptions{Name: "nginx-1", Chart: "nginx", Namespace: "default"} + h.helmPackageManager.Install(options) + + t.Run("helmList", func(t *testing.T) { + is := assert.New(t) + + req := httptest.NewRequest(http.MethodGet, "/1/kubernetes/helm", nil) + req.Header.Add("Authorization", "Bearer dummytoken") + + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200 OK") + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + + data := []release.ReleaseElement{} + json.Unmarshal(body, &data) + if is.Equal(1, len(data), "Expected one chart entry") { + is.EqualValues(options.Name, data[0].Name, "Name doesn't match") + is.EqualValues(options.Chart, data[0].Chart, "Chart doesn't match") + } + }) +} diff --git a/api/http/handler/helm/helm_repo_search.go b/api/http/handler/helm/helm_repo_search.go new file mode 100644 index 000000000..499255bf9 --- /dev/null +++ b/api/http/handler/helm/helm_repo_search.go @@ -0,0 +1,56 @@ +package helm + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/pkg/errors" + "github.com/portainer/libhelm" + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" +) + +// @id HelmRepoSearch +// @summary Search Helm Charts +// @description +// @description **Access policy**: authorized +// @tags helm +// @param repo query string true "Helm repository URL" +// @security jwt +// @produce json +// @success 200 {object} string "Success" +// @failure 400 "Bad request" +// @failure 401 "Unauthorized" +// @failure 404 "Not found" +// @failure 500 "Server error" +// @router /templates/helm [get] +func (handler *Handler) helmRepoSearch(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + repo := r.URL.Query().Get("repo") + if repo == "" { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.New("missing `repo` query parameter")} + } + + _, err := url.ParseRequestURI(repo) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))} + } + + searchOpts := options.SearchRepoOptions{ + Repo: repo, + } + + result, err := libhelm.SearchRepo(searchOpts) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Search failed", + Err: err, + } + } + + w.Header().Set("Content-Type", "text/plain") + w.Write(result) + + return nil +} diff --git a/api/http/handler/helm/helm_repo_search_test.go b/api/http/handler/helm/helm_repo_search_test.go new file mode 100644 index 000000000..beea99f92 --- /dev/null +++ b/api/http/handler/helm/helm_repo_search_test.go @@ -0,0 +1,51 @@ +package helm + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/portainer/libhelm/binary/test" + "github.com/stretchr/testify/assert" + + helper "github.com/portainer/portainer/api/internal/testhelpers" +) + +func Test_helmRepoSearch(t *testing.T) { + helper.IntegrationTest(t) + is := assert.New(t) + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager) + + assert.NotNil(t, h, "Handler should not fail") + + repos := []string{"https://charts.bitnami.com/bitnami", "https://portainer.github.io/k8s"} + + for _, repo := range repos { + t.Run(repo, func(t *testing.T) { + repoUrlEncoded := url.QueryEscape(repo) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/templates/helm?repo=%s", repoUrlEncoded), nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code, "Status should be 200 OK") + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + is.NotEmpty(body, "Body should not be empty") + }) + } + + t.Run("fails on invalid URL", func(t *testing.T) { + repo := "abc.com" + repoUrlEncoded := url.QueryEscape(repo) + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/templates/helm?repo=%s", repoUrlEncoded), nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(http.StatusBadRequest, rr.Code, "Status should be 400 Bad request") + }) +} diff --git a/api/http/handler/helm/helm_show.go b/api/http/handler/helm/helm_show.go new file mode 100644 index 000000000..5ecfcffae --- /dev/null +++ b/api/http/handler/helm/helm_show.go @@ -0,0 +1,70 @@ +package helm + +import ( + "fmt" + "log" + "net/http" + "net/url" + + "github.com/pkg/errors" + "github.com/portainer/libhelm/options" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" +) + +// @id HelmShow +// @summary Show Helm Chart Information +// @description +// @description **Access policy**: authorized +// @tags helm_chart +// @param repo query string true "Helm repository URL" +// @param chart query string true "Chart name" +// @param command path string true "chart/values/readme" +// @security jwt +// @accept json +// @produce text/plain +// @success 200 {object} string "Success" +// @failure 401 "Unauthorized" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error" +// @router /templates/helm/{command} [get] +func (handler *Handler) helmShow(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + repo := r.URL.Query().Get("repo") + if repo == "" { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.New("missing `repo` query parameter")} + } + _, err := url.ParseRequestURI(repo) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.Wrap(err, fmt.Sprintf("provided URL %q is not valid", repo))} + } + + chart := r.URL.Query().Get("chart") + if chart == "" { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Bad request", Err: errors.New("missing `chart` query parameter")} + } + + cmd, err := request.RetrieveRouteVariableValue(r, "command") + if err != nil { + cmd = "all" + log.Printf("[DEBUG] [internal,helm] [message: command not provided, defaulting to %s]", cmd) + } + + showOptions := options.ShowOptions{ + OutputFormat: options.ShowOutputFormat(cmd), + Chart: chart, + Repo: repo, + } + result, err := handler.helmPackageManager.Show(showOptions) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to show chart", + Err: err, + } + } + + w.Header().Set("Content-Type", "text/plain") + w.Write(result) + + return nil +} diff --git a/api/http/handler/helm/helm_show_test.go b/api/http/handler/helm/helm_show_test.go new file mode 100644 index 000000000..c619dd01e --- /dev/null +++ b/api/http/handler/helm/helm_show_test.go @@ -0,0 +1,47 @@ +package helm + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/portainer/libhelm/binary/test" + helper "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func Test_helmShow(t *testing.T) { + is := assert.New(t) + + helmPackageManager := test.NewMockHelmBinaryPackageManager("") + h := NewTemplateHandler(helper.NewTestRequestBouncer(), helmPackageManager) + + is.NotNil(h, "Handler should not fail") + + commands := map[string]string{ + "values": test.MockDataValues, + "chart": test.MockDataChart, + "readme": test.MockDataReadme, + } + + for cmd, expect := range commands { + t.Run(cmd, func(t *testing.T) { + is.NotNil(h, "Handler should not fail") + + repoUrlEncoded := url.QueryEscape("https://charts.bitnami.com/bitnami") + chart := "nginx" + req := httptest.NewRequest("GET", fmt.Sprintf("/templates/helm/%s?repo=%s&chart=%s", cmd, repoUrlEncoded, chart), nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + is.Equal(rr.Code, http.StatusOK, "Status should be 200 OK") + + body, err := io.ReadAll(rr.Body) + is.NoError(err, "ReadAll should not return error") + is.EqualValues(string(body), expect, "Unexpected search response") + }) + } +} diff --git a/api/http/handler/helm/user_helm_repos.go b/api/http/handler/helm/user_helm_repos.go new file mode 100644 index 000000000..d21584468 --- /dev/null +++ b/api/http/handler/helm/user_helm_repos.go @@ -0,0 +1,127 @@ +package helm + +import ( + "net/http" + "strings" + + "github.com/pkg/errors" + + "github.com/portainer/libhelm" + + 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/http/security" +) + +type helmUserRepositoryResponse struct { + GlobalRepository string `json:"GlobalRepository"` + UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"` +} + +type addHelmRepoUrlPayload struct { + URL string `json:"url"` +} + +func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error { + return libhelm.ValidateHelmRepositoryURL(p.URL) +} + +// @id HelmUserRepositoryCreate +// @summary Create a user helm repository +// @description Create a user helm repository. +// @description **Access policy**: authenticated +// @tags helm +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @param payload body addHelmRepoUrlPayload true "Helm Repository" +// @success 200 {object} portainer.HelmUserRepository "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /endpoints/{id}/kubernetes/helm/repositories [post] +func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + userID := portainer.UserID(tokenData.ID) + + p := new(addHelmRepoUrlPayload) + err = request.DecodeAndValidateJSONPayload(r, p) + if err != nil { + return &httperror.HandlerError{ + StatusCode: http.StatusBadRequest, + Message: "Invalid Helm repository URL", + Err: err, + } + } + // lowercase, remove trailing slash + p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/") + + records, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to access the DataStore", err} + } + + // check if repo already exists - by doing case insensitive comparison + for _, record := range records { + if strings.EqualFold(record.URL, p.URL) { + errMsg := "Helm repo already registered for user" + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: errMsg, Err: errors.New(errMsg)} + } + } + + record := portainer.HelmUserRepository{ + UserID: userID, + URL: p.URL, + } + + err = handler.dataStore.HelmUserRepository().CreateHelmUserRepository(&record) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to save a user Helm repository URL", err} + } + + return response.JSON(w, record) +} + +// @id HelmUserRepositoriesList +// @summary List a users helm repositories +// @description Inspect a user helm repositories. +// @description **Access policy**: authenticated +// @tags helm +// @security jwt +// @produce json +// @param id path int true "User identifier" +// @success 200 {object} helmUserRepositoryResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 500 "Server error" +// @router /endpoints/{id}/kubernetes/helm/repositories [get] +func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user authentication token", err} + } + userID := portainer.UserID(tokenData.ID) + + settings, err := handler.dataStore.Settings().Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} + } + + userRepos, err := handler.dataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user Helm repositories", err} + } + + resp := helmUserRepositoryResponse{ + GlobalRepository: settings.HelmRepositoryURL, + UserRepositories: userRepos, + } + + return response.JSON(w, resp) +} diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go new file mode 100644 index 000000000..31cd0a5e2 --- /dev/null +++ b/api/http/handler/kubernetes/handler.go @@ -0,0 +1,71 @@ +package kubernetes + +import ( + "errors" + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/middlewares" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes/cli" +) + +// Handler is the HTTP handler which will natively deal with to external environments(endpoints). +type Handler struct { + *mux.Router + dataStore portainer.DataStore + kubernetesClientFactory *cli.ClientFactory + authorizationService *authorization.Service + JwtService portainer.JWTService +} + +// NewHandler creates a handler to process pre-proxied requests to external APIs. +func NewHandler(bouncer *security.RequestBouncer, authorizationService *authorization.Service, dataStore portainer.DataStore, kubernetesClientFactory *cli.ClientFactory) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + dataStore: dataStore, + kubernetesClientFactory: kubernetesClientFactory, + authorizationService: authorizationService, + } + + kubeRouter := h.PathPrefix("/kubernetes/{id}").Subrouter() + + kubeRouter.Use(bouncer.AuthenticatedAccess) + kubeRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) + kubeRouter.Use(kubeOnlyMiddleware) + + kubeRouter.PathPrefix("/config").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesConfig))).Methods(http.MethodGet) + kubeRouter.PathPrefix("/nodes_limits").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.getKubernetesNodesLimits))).Methods(http.MethodGet) + + // namespaces + // in the future this piece of code might be in another package (or a few different packages - namespaces/namespace?) + // to keep it simple, we've decided to leave it like this. + namespaceRouter := kubeRouter.PathPrefix("/namespaces/{namespace}").Subrouter() + namespaceRouter.Handle("/system", bouncer.RestrictedAccess(httperror.LoggerHandler(h.namespacesToggleSystem))).Methods(http.MethodPut) + + return h +} + +func kubeOnlyMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { + endpoint, err := middlewares.FetchEndpoint(request) + if err != nil { + httperror.WriteError(rw, http.StatusInternalServerError, "Unable to find an environment on request context", err) + return + } + + if !endpointutils.IsKubernetesEndpoint(endpoint) { + errMessage := "Environment is not a kubernetes environment" + httperror.WriteError(rw, http.StatusBadRequest, errMessage, errors.New(errMessage)) + return + } + + next.ServeHTTP(rw, request) + }) +} diff --git a/api/http/handler/kubernetes/kubernetes_config.go b/api/http/handler/kubernetes/kubernetes_config.go new file mode 100644 index 000000000..df62601ed --- /dev/null +++ b/api/http/handler/kubernetes/kubernetes_config.go @@ -0,0 +1,109 @@ +package kubernetes + +import ( + "errors" + "fmt" + "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" + kcli "github.com/portainer/portainer/api/kubernetes/cli" +) + +// @id GetKubernetesConfig +// @summary Generates kubeconfig file enabling client communication with k8s api server +// @description Generates kubeconfig file enabling client communication with k8s api server +// @description **Access policy**: authorized +// @tags kubernetes +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) or ServiceAccount not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/config [get] +func (handler *Handler) getKubernetesConfig(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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 environment with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} + } + + bearerToken, err := handler.JwtService.GenerateTokenForKubeconfig(tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to generate JWT token", err} + } + + cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + apiServerURL := getProxyUrl(r, endpointID) + + config, err := cli.GetKubeConfig(r.Context(), apiServerURL, bearerToken, tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to generate Kubeconfig", err} + } + + filenameBase := fmt.Sprintf("%s-%s", tokenData.Username, endpoint.Name) + contentAcceptHeader := r.Header.Get("Accept") + if contentAcceptHeader == "text/yaml" { + yaml, err := kcli.GenerateYAML(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed to generate Kubeconfig", err} + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.yaml", filenameBase)) + return YAML(w, yaml) + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; %s.json", filenameBase)) + return response.JSON(w, config) +} + +// getProxyUrl generates portainer proxy url which acts as proxy to k8s api server +func getProxyUrl(r *http.Request, endpointID int) string { + return fmt.Sprintf("https://%s/api/endpoints/%d/kubernetes", r.Host, endpointID) +} + +// YAML writes yaml response as string to writer. Returns a pointer to a HandlerError if encoding fails. +// This could be moved to a more useful place; but that place is most likely not in this project. +// It should actually go in https://github.com/portainer/libhttp - since that is from where we use response.JSON. +// We use `data interface{}` as parameter - since im trying to keep it as close to (or the same as) response.JSON method signature: +// https://github.com/portainer/libhttp/blob/d20481a3da823c619887c440a22fdf4fa8f318f2/response/response.go#L13 +func YAML(rw http.ResponseWriter, data interface{}) *httperror.HandlerError { + rw.Header().Set("Content-Type", "text/yaml") + + strData, ok := data.(string) + if !ok { + return &httperror.HandlerError{ + StatusCode: http.StatusInternalServerError, + Message: "Unable to write YAML response", + Err: errors.New("failed to convert input to string"), + } + } + + fmt.Fprint(rw, strData) + + return nil +} diff --git a/api/http/handler/kubernetes/kubernetes_nodes_limits.go b/api/http/handler/kubernetes/kubernetes_nodes_limits.go new file mode 100644 index 000000000..7bcaed27b --- /dev/null +++ b/api/http/handler/kubernetes/kubernetes_nodes_limits.go @@ -0,0 +1,53 @@ +package kubernetes + +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" +) + +// @id getKubernetesNodesLimits +// @summary Get CPU and memory limits of all nodes within k8s cluster +// @description Get CPU and memory limits of all nodes within k8s cluster +// @description **Access policy**: authorized +// @tags kubernetes +// @security jwt +// @accept json +// @produce json +// @param id path int true "Environment(Endpoint) identifier" +// @success 200 {object} portainer.K8sNodesLimits "Success" +// @failure 400 "Invalid request" +// @failure 401 "Unauthorized" +// @failure 403 "Permission denied" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/nodes_limits [get] +func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid environment 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 environment with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} + } + + cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + nodesLimits, err := cli.GetNodesLimits() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve nodes limits", err} + } + + return response.JSON(w, nodesLimits) +} diff --git a/api/http/handler/kubernetes/namespaces_toggle_system.go b/api/http/handler/kubernetes/namespaces_toggle_system.go new file mode 100644 index 000000000..52948a08f --- /dev/null +++ b/api/http/handler/kubernetes/namespaces_toggle_system.go @@ -0,0 +1,65 @@ +package kubernetes + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer/api/http/middlewares" +) + +type namespacesToggleSystemPayload struct { + // Toggle the system state of this namespace to true or false + System bool `example:"true"` +} + +func (payload *namespacesToggleSystemPayload) Validate(r *http.Request) error { + return nil +} + +// @id KubernetesNamespacesToggleSystem +// @summary Toggle the system state for a namespace +// @description Toggle the system state for a namespace +// @description **Access policy**: administrator or environment(endpoint) admin +// @security jwt +// @tags kubernetes +// @accept json +// @param id path int true "Environment(Endpoint) identifier" +// @param namespace path string true "Namespace name" +// @param body body namespacesToggleSystemPayload true "Update details" +// @success 200 "Success" +// @failure 400 "Invalid request" +// @failure 404 "Environment(Endpoint) not found" +// @failure 500 "Server error" +// @router /kubernetes/{id}/namespaces/{namespace}/system [put] +func (handler *Handler) namespacesToggleSystem(rw http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpoint, err := middlewares.FetchEndpoint(r) + if err != nil { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment on request context", err} + } + + namespaceName, err := request.RetrieveRouteVariableValue(r, "namespace") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid namespace identifier route variable", err} + } + + var payload namespacesToggleSystemPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + kubeClient, err := handler.kubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create kubernetes client", err} + } + + err = kubeClient.ToggleSystemState(namespaceName, payload.System) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to toggle system status", err} + } + + return response.Empty(rw) + +} diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 035385346..e8dbaeefc 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -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 } diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go index 307101177..cc9398a61 100644 --- a/api/http/handler/registries/registry_configure.go +++ b/api/http/handler/registries/registry_configure.go @@ -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} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index 00b2c0259..017121887 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -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) } diff --git a/api/http/handler/registries/registry_create_test.go b/api/http/handler/registries/registry_create_test.go new file mode 100644 index 000000000..722301164 --- /dev/null +++ b/api/http/handler/registries/registry_create_test.go @@ -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) +} diff --git a/api/http/handler/registries/registry_delete.go b/api/http/handler/registries/registry_delete.go index a5cf8d417..d5db6769a 100644 --- a/api/http/handler/registries/registry_delete.go +++ b/api/http/handler/registries/registry_delete.go @@ -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} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index 7803f420d..29b57fbc2 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -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) } diff --git a/api/http/handler/registries/registry_list.go b/api/http/handler/registries/registry_list.go index a387f7f32..8e9519f68 100644 --- a/api/http/handler/registries/registry_list.go +++ b/api/http/handler/registries/registry_list.go @@ -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) } diff --git a/api/http/handler/registries/registry_update.go b/api/http/handler/registries/registry_update.go index 85540d72d..bf89fab82 100644 --- a/api/http/handler/registries/registry_update.go +++ b/api/http/handler/registries/registry_update.go @@ -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 } diff --git a/api/http/handler/registries/registry_update_test.go b/api/http/handler/registries/registry_update_test.go new file mode 100644 index 000000000..d2767ff4f --- /dev/null +++ b/api/http/handler/registries/registry_update_test.go @@ -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) + +} diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index aab33e59e..dbe8c243e 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -22,6 +22,8 @@ type publicSettingsResponse struct { OAuthLogoutURI string `json:"OAuthLogoutURI" example:"https://gitlab.com/oauth/logout"` // Whether telemetry is enabled EnableTelemetry bool `json:"EnableTelemetry" example:"true"` + // The expiry of a Kubeconfig + KubeconfigExpiry string `example:"24h" default:"0"` } // @id SettingsPublic @@ -49,6 +51,7 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp AuthenticationMethod: appSettings.AuthenticationMethod, EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures, EnableTelemetry: appSettings.EnableTelemetry, + KubeconfigExpiry: appSettings.KubeconfigExpiry, } //if OAuth authentication is on, compose the related fields from application settings if publicSettings.AuthenticationMethod == portainer.AuthenticationOAuth { diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 36150d536..7d8fde4fc 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -1,11 +1,13 @@ package settings import ( - "errors" "net/http" + "strings" "time" "github.com/asaskevich/govalidator" + "github.com/pkg/errors" + "github.com/portainer/libhelm" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -22,7 +24,7 @@ type settingsUpdatePayload struct { AuthenticationMethod *int `example:"1"` LDAPSettings *portainer.LDAPSettings `example:""` OAuthSettings *portainer.OAuthSettings `example:""` - // The interval in which endpoint snapshots are created + // The interval in which environment(endpoint) snapshots are created SnapshotInterval *string `example:"5m"` // URL to the templates that will be displayed in the UI when navigating to App Templates TemplatesURL *string `example:"https://raw.githubusercontent.com/portainer/templates/master/templates.json"` @@ -32,8 +34,12 @@ type settingsUpdatePayload struct { EnableEdgeComputeFeatures *bool `example:"true"` // The duration of a user session UserSessionTimeout *string `example:"5m"` + // The expiry of a Kubeconfig + KubeconfigExpiry *string `example:"24h" default:"0"` // Whether telemetry is enabled EnableTelemetry *bool `example:"false"` + // Helm repository URL + HelmRepositoryURL *string `example:"https://charts.bitnami.com/bitnami"` } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { @@ -46,12 +52,24 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error { if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) { return errors.New("Invalid external templates URL. Must correspond to a valid URL format") } + if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" { + err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL) + if err != nil { + return errors.Wrap(err, "Invalid Helm repository URL. Must correspond to a valid URL format") + } + } if payload.UserSessionTimeout != nil { _, err := time.ParseDuration(*payload.UserSessionTimeout) if err != nil { return errors.New("Invalid user session timeout") } } + if payload.KubeconfigExpiry != nil { + _, err := time.ParseDuration(*payload.KubeconfigExpiry) + if err != nil { + return errors.New("Invalid Kubeconfig Expiry") + } + } return nil } @@ -93,6 +111,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.TemplatesURL = *payload.TemplatesURL } + if payload.HelmRepositoryURL != nil { + settings.HelmRepositoryURL = strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/") + } + if payload.BlackListedLabels != nil { settings.BlackListedLabels = payload.BlackListedLabels } @@ -135,6 +157,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval } + if payload.KubeconfigExpiry != nil { + settings.KubeconfigExpiry = *payload.KubeconfigExpiry + } + if payload.UserSessionTimeout != nil { settings.UserSessionTimeout = *payload.UserSessionTimeout diff --git a/api/http/handler/ssl/handler.go b/api/http/handler/ssl/handler.go new file mode 100644 index 000000000..8a82f4995 --- /dev/null +++ b/api/http/handler/ssl/handler.go @@ -0,0 +1,29 @@ +package ssl + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/ssl" +) + +// Handler is the HTTP handler used to handle MOTD operations. +type Handler struct { + *mux.Router + SSLService *ssl.Service +} + +// NewHandler returns a new Handler +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + h.Handle("/ssl", + bouncer.AdminAccess(httperror.LoggerHandler(h.sslInspect))).Methods(http.MethodGet) + h.Handle("/ssl", + bouncer.AdminAccess(httperror.LoggerHandler(h.sslUpdate))).Methods(http.MethodPut) + + return h +} diff --git a/api/http/handler/ssl/ssl_inspect.go b/api/http/handler/ssl/ssl_inspect.go new file mode 100644 index 000000000..b41faa6c5 --- /dev/null +++ b/api/http/handler/ssl/ssl_inspect.go @@ -0,0 +1,29 @@ +package ssl + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" +) + +// @id SSLInspect +// @summary Inspect the ssl settings +// @description Retrieve the ssl settings. +// @description **Access policy**: administrator +// @tags ssl +// @security jwt +// @produce json +// @success 200 {object} portainer.SSLSettings "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access settings" +// @failure 500 "Server error" +// @router /ssl [get] +func (handler *Handler) sslInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SSLService.GetSSLSettings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed to fetch certificate info", err} + } + + return response.JSON(w, settings) +} diff --git a/api/http/handler/ssl/ssl_update.go b/api/http/handler/ssl/ssl_update.go new file mode 100644 index 000000000..34dd74171 --- /dev/null +++ b/api/http/handler/ssl/ssl_update.go @@ -0,0 +1,62 @@ +package ssl + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" +) + +type sslUpdatePayload struct { + Cert *string + Key *string + HTTPEnabled *bool +} + +func (payload *sslUpdatePayload) Validate(r *http.Request) error { + if (payload.Cert == nil || payload.Key == nil) && payload.Cert != payload.Key { + return errors.New("both certificate and key files should be provided") + } + + return nil +} + +// @id SSLUpdate +// @summary Update the ssl settings +// @description Update the ssl settings. +// @description **Access policy**: administrator +// @tags ssl +// @security jwt +// @accept json +// @produce json +// @param body body sslUpdatePayload true "SSL Settings" +// @success 204 "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied to access settings" +// @failure 500 "Server error" +// @router /ssl [put] +func (handler *Handler) sslUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload sslUpdatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + if payload.Cert != nil { + err = handler.SSLService.SetCertificates([]byte(*payload.Cert), []byte(*payload.Key)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed to save certificate", err} + } + } + + if payload.HTTPEnabled != nil { + err = handler.SSLService.SetHTTPEnabled(*payload.HTTPEnabled) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed to force https", err} + } + } + + return response.Empty(w) +} diff --git a/api/http/handler/stacks/autoupdate.go b/api/http/handler/stacks/autoupdate.go new file mode 100644 index 000000000..867e71fc2 --- /dev/null +++ b/api/http/handler/stacks/autoupdate.go @@ -0,0 +1,38 @@ +package stacks + +import ( + "log" + "net/http" + "time" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks" +) + +func startAutoupdate(stackID portainer.StackID, interval string, scheduler *scheduler.Scheduler, stackDeployer stacks.StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) (jobID string, e *httperror.HandlerError) { + d, err := time.ParseDuration(interval) + if err != nil { + return "", &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Unable to parse stack's auto update interval", Err: err} + } + + jobID = scheduler.StartJobEvery(d, func() { + if err := stacks.RedeployWhenChanged(stackID, stackDeployer, datastore, gitService); err != nil { + log.Printf("[ERROR] [http,stacks] [message: failed redeploying] [err: %s]\n", err) + } + }) + + return jobID, nil +} + +func stopAutoupdate(stackID portainer.StackID, jobID string, scheduler scheduler.Scheduler) { + if jobID == "" { + return + } + + if err := scheduler.StopJob(jobID); err != nil { + log.Printf("[WARN] could not stop the job for the stack %v", stackID) + } + +} diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 0a3b8e526..fe6d30c97 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -1,7 +1,6 @@ package stacks import ( - "errors" "fmt" "net/http" "path" @@ -9,10 +8,12 @@ import ( "time" "github.com/asaskevich/govalidator" + "github.com/pkg/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" ) @@ -21,7 +22,7 @@ type composeStackFromFileContentPayload struct { Name string `example:"myStack" validate:"required"` // Content of the Stack file StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` - // A list of environment variables used during stack deployment + // A list of environment(endpoint) variables used during stack deployment Env []portainer.Pair `example:""` } @@ -100,7 +101,6 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, type composeStackFromGitRepositoryPayload struct { // Name of the stack Name string `example:"myStack" validate:"required"` - // URL of a Git repository hosting the Stack file RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"` // Reference name of a Git repository hosting the Stack file @@ -112,9 +112,12 @@ type composeStackFromGitRepositoryPayload struct { // Password used in basic authentication. Required when RepositoryAuthentication is true. RepositoryPassword string `example:"myGitPassword"` // Path to the Stack file inside the Git repository - ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` - - // A list of environment variables used during stack deployment + ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"` + // Applicable when deploying with multiple stack files + AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"` + // Optional auto update configuration + AutoUpdate *portainer.StackAutoUpdate + // A list of environment(endpoint) variables used during stack deployment Env []portainer.Pair } @@ -122,14 +125,18 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e if govalidator.IsNull(payload.Name) { return errors.New("Invalid stack name") } - if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { return errors.New("Invalid repository URL. Must correspond to a valid URL format") } - if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { - return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") + if govalidator.IsNull(payload.RepositoryReferenceName) { + payload.RepositoryReferenceName = defaultGitReferenceName + } + if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) { + return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") + } + if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + return err } - return nil } @@ -141,42 +148,72 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite } payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name) - if payload.ComposeFilePathInRepository == "" { - payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + if payload.ComposeFile == "" { + payload.ComposeFile = filesystem.ComposeFileDefaultName } isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err} } if !isUnique { - errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name) - return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.Name), Err: errStackAlreadyExists} + } + + //make sure the webhook ID is unique + if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" { + isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for webhook ID collision", Err: err} + } + if !isUnique { + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists} + } } stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerComposeStack, - EndpointID: endpoint.ID, - EntryPoint: payload.ComposeFilePathInRepository, - Env: payload.Env, + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerComposeStack, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFile, + AdditionalFiles: payload.AdditionalFiles, + AutoUpdate: payload.AutoUpdate, + Env: payload.Env, + GitConfig: &gittypes.RepoConfig{ + URL: payload.RepositoryURL, + ReferenceName: payload.RepositoryReferenceName, + ConfigFilePath: payload.ComposeFile, + }, Status: portainer.StackStatusActive, CreationDate: time.Now().Unix(), } + if payload.RepositoryAuthentication { + stack.GitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + } + } + projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) stack.ProjectPath = projectPath doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + err = handler.clone(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} } + commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} + } + stack.GitConfig.ConfigHash = commitId + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) if configErr != nil { return configErr @@ -187,6 +224,15 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} } + if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { + jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + if e != nil { + return e + } + + stack.AutoUpdate.JobID = jobID + } + stack.CreatedBy = config.user.Username err = handler.DataStore.Stack().CreateStack(stack) @@ -290,7 +336,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 +347,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, @@ -338,7 +377,7 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) error { isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) if err != nil { - return err + return errors.Wrap(err, "failed to check user priviliges deploying a stack") } securitySettings := &config.endpoint.SecuritySettings @@ -351,27 +390,19 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) !securitySettings.AllowContainerCapabilitiesForRegularUsers) && !isAdminOrEndpointAdmin { - composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) - stackContent, err := handler.FileService.GetFileContent(composeFilePath) - if err != nil { - return err - } + for _, file := range append([]string{config.stack.EntryPoint}, config.stack.AdditionalFiles...) { + path := path.Join(config.stack.ProjectPath, file) + stackContent, err := handler.FileService.GetFileContent(path) + if err != nil { + return errors.Wrapf(err, "failed to get stack file content `%q`", path) + } - err = handler.isValidStackFile(stackContent, securitySettings) - if err != nil { - return err + err = handler.isValidStackFile(stackContent, securitySettings) + if err != nil { + return errors.Wrap(err, "compose file is invalid") + } } } - handler.stackCreationMutex.Lock() - defer handler.stackCreationMutex.Unlock() - - handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) - - err = handler.ComposeStackManager.Up(config.stack, config.endpoint) - if err != nil { - return err - } - - return handler.SwarmStackManager.Logout(config.endpoint) + return handler.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries) } diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 4de14d3a3..08b46069b 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -1,13 +1,15 @@ package stacks import ( - "errors" + "fmt" "io/ioutil" "net/http" "path/filepath" "strconv" "time" + "github.com/pkg/errors" + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" @@ -15,10 +17,11 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/http/client" + k "github.com/portainer/portainer/api/kubernetes" ) -const defaultReferenceName = "refs/heads/master" - type kubernetesStringDeploymentPayload struct { ComposeFormat bool Namespace string @@ -36,6 +39,12 @@ type kubernetesGitDeploymentPayload struct { FilePathInRepository string } +type kubernetesManifestURLDeploymentPayload struct { + Namespace string + ComposeFormat bool + ManifestURL string +} + func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.StackFileContent) { return errors.New("Invalid stack file content") @@ -60,7 +69,14 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error { return errors.New("Invalid file path in repository") } if govalidator.IsNull(payload.RepositoryReferenceName) { - payload.RepositoryReferenceName = defaultReferenceName + payload.RepositoryReferenceName = defaultGitReferenceName + } + return nil +} + +func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) { + return errors.New("Invalid manifest URL") } return nil } @@ -69,33 +85,52 @@ type createKubernetesStackResponse struct { Output string `json:"Output"` } -func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload kubernetesStringDeploymentPayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} } + user, err := handler.DataStore.User().User(userID) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} + } + stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Type: portainer.KubernetesStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ManifestFileDefaultName, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), + ID: portainer.StackID(stackID), + Type: portainer.KubernetesStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ManifestFileDefaultName, + Namespace: payload.Namespace, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), + CreatedBy: user.Username, + IsComposeFormat: payload.ComposeFormat, } stackFolder := strconv.Itoa(int(stack.ID)) projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { - return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err} + fileType := "Manifest" + if stack.IsComposeFormat { + fileType = "Compose" + } + errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType) + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err} } stack.ProjectPath = projectPath doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace) + output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ + StackID: stackID, + Name: stack.Name, + Owner: stack.CreatedBy, + Kind: "content", + }) + if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} } @@ -105,6 +140,8 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err} } + doCleanUp = false + resp := &createKubernetesStackResponse{ Output: output, } @@ -112,20 +149,40 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit return response.JSON(w, resp) } -func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload kubernetesGitDeploymentPayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} } + user, err := handler.DataStore.User().User(userID) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} + } + stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Type: portainer.KubernetesStack, - EndpointID: endpoint.ID, - EntryPoint: payload.FilePathInRepository, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), + ID: portainer.StackID(stackID), + Type: portainer.KubernetesStack, + EndpointID: endpoint.ID, + EntryPoint: payload.FilePathInRepository, + GitConfig: &gittypes.RepoConfig{ + URL: payload.RepositoryURL, + ReferenceName: payload.RepositoryReferenceName, + ConfigFilePath: payload.FilePathInRepository, + }, + Namespace: payload.Namespace, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), + CreatedBy: user.Username, + IsComposeFormat: payload.ComposeFormat, + } + + if payload.RepositoryAuthentication { + stack.GitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + } } projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -134,12 +191,24 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) + commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} + } + stack.GitConfig.ConfigHash = commitID + stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err} } - output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace) + output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ + StackID: stackID, + Name: stack.Name, + Owner: stack.CreatedBy, + Kind: "git", + }) + if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} } @@ -149,26 +218,98 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err} } + doCleanUp = false + resp := &createKubernetesStackResponse{ Output: output, } + return response.JSON(w, resp) } -func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) { +func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { + var payload kubernetesManifestURLDeploymentPayload + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} + } + + user, err := handler.DataStore.User().User(userID) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err} + } + + stackID := handler.DataStore.Stack().GetNextIdentifier() + stack := &portainer.Stack{ + ID: portainer.StackID(stackID), + Type: portainer.KubernetesStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ManifestFileDefaultName, + Namespace: payload.Namespace, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), + CreatedBy: user.Username, + IsComposeFormat: payload.ComposeFormat, + } + + var manifestContent []byte + manifestContent, err = client.Get(payload.ManifestURL, 30) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve manifest from URL", Err: err} + } + + stackFolder := strconv.Itoa(int(stack.ID)) + projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, manifestContent) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err} + } + stack.ProjectPath = projectPath + + doCleanUp := true + defer handler.cleanUp(stack, &doCleanUp) + + output, err := handler.deployKubernetesStack(r, endpoint, string(manifestContent), payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ + StackID: stackID, + Name: stack.Name, + Owner: stack.CreatedBy, + Kind: "url", + }) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} + } + + err = handler.DataStore.Stack().CreateStack(stack) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err} + } + + doCleanUp = false + + resp := &createKubernetesStackResponse{ + Output: output, + } + + return response.JSON(w, resp) +} + +func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) { handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() + manifest := []byte(stackConfig) if composeFormat { - convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(stackConfig) + convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(manifest) if err != nil { - return "", err + return "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest") } - stackConfig = string(convertedConfig) + manifest = convertedConfig } - return handler.KubernetesDeployer.Deploy(request, endpoint, stackConfig, namespace) + manifest, err := k.AddAppLabels(manifest, appLabels) + if err != nil { + return "", errors.Wrap(err, "failed to add application labels") + } + return handler.KubernetesDeployer.Deploy(request, endpoint, string(manifest), namespace) } func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) { diff --git a/api/http/handler/stacks/create_kubernetes_stack_test.go b/api/http/handler/stacks/create_kubernetes_stack_test.go index f1b47286e..2bcd35ab5 100644 --- a/api/http/handler/stacks/create_kubernetes_stack_test.go +++ b/api/http/handler/stacks/create_kubernetes_stack_test.go @@ -23,6 +23,10 @@ func (g *git) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName s return g.ClonePublicRepository(repositoryURL, referenceName, destination) } +func (g *git) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) { + return "", nil +} + func TestCloneAndConvertGitRepoFile(t *testing.T) { dir, err := os.MkdirTemp("", "kube-create-stack") assert.NoError(t, err, "failed to create a tmp dir") diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 747f1b456..7e615acc3 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -1,18 +1,20 @@ package stacks import ( - "errors" "fmt" "net/http" "path" "strconv" "time" + "github.com/pkg/errors" + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" ) @@ -23,7 +25,7 @@ type swarmStackFromFileContentPayload struct { SwarmID string `example:"jpofkc0i9uo9wtx1zesuk649w" validate:"required"` // Content of the Stack file StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` - // A list of environment variables used during stack deployment + // A list of environment(endpoint) variables used during stack deployment Env []portainer.Pair } @@ -47,6 +49,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} @@ -105,7 +109,7 @@ type swarmStackFromGitRepositoryPayload struct { Name string `example:"myStack" validate:"required"` // Swarm cluster identifier SwarmID string `example:"jpofkc0i9uo9wtx1zesuk649w" validate:"required"` - // A list of environment variables used during stack deployment + // A list of environment(endpoint) variables used during stack deployment Env []portainer.Pair // URL of a Git repository hosting the Stack file @@ -119,7 +123,11 @@ type swarmStackFromGitRepositoryPayload struct { // Password used in basic authentication. Required when RepositoryAuthentication is true. RepositoryPassword string `example:"myGitPassword"` // Path to the Stack file inside the Git repository - ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` + ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"` + // Applicable when deploying with multiple stack files + AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"` + // Optional auto update configuration + AutoUpdate *portainer.StackAutoUpdate } func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { @@ -132,11 +140,14 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { return errors.New("Invalid repository URL. Must correspond to a valid URL format") } - if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { - return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") + if govalidator.IsNull(payload.RepositoryReferenceName) { + payload.RepositoryReferenceName = defaultGitReferenceName } - if govalidator.IsNull(payload.ComposeFilePathInRepository) { - payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) { + return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") + } + if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + return err } return nil } @@ -145,42 +156,74 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, var payload swarmStackFromGitRepositoryPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: 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} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err} } if !isUnique { - errorMessage := fmt.Sprintf("A stack with the name '%s' is already running", payload.Name) - return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.Name), Err: errStackAlreadyExists} + } + + //make sure the webhook ID is unique + if payload.AutoUpdate != nil && payload.AutoUpdate.Webhook != "" { + isUnique, err := handler.checkUniqueWebhookID(payload.AutoUpdate.Webhook) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for webhook ID collision", Err: err} + } + if !isUnique { + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists} + } } stackID := handler.DataStore.Stack().GetNextIdentifier() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerSwarmStack, - SwarmID: payload.SwarmID, - EndpointID: endpoint.ID, - EntryPoint: payload.ComposeFilePathInRepository, + ID: portainer.StackID(stackID), + Name: payload.Name, + Type: portainer.DockerSwarmStack, + SwarmID: payload.SwarmID, + EndpointID: endpoint.ID, + EntryPoint: payload.ComposeFile, + AdditionalFiles: payload.AdditionalFiles, + AutoUpdate: payload.AutoUpdate, + GitConfig: &gittypes.RepoConfig{ + URL: payload.RepositoryURL, + ReferenceName: payload.RepositoryReferenceName, + ConfigFilePath: payload.ComposeFile, + }, Env: payload.Env, Status: portainer.StackStatusActive, CreationDate: time.Now().Unix(), } + if payload.RepositoryAuthentication { + stack.GitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + } + } + projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) stack.ProjectPath = projectPath doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - err = handler.cloneAndSaveConfig(stack, projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.ComposeFilePathInRepository, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + err = handler.clone(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} } + commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} + } + stack.GitConfig.ConfigHash = commitId + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) if configErr != nil { return configErr @@ -188,14 +231,23 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, err = handler.deploySwarmStack(config) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} + } + + if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { + jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + if e != nil { + return e + } + + stack.AutoUpdate.JobID = jobID } stack.CreatedBy = config.user.Username err = handler.DataStore.Stack().CreateStack(stack) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err} } doCleanUp = false @@ -244,6 +296,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 +354,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 +366,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, @@ -345,39 +392,25 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) if err != nil { - return err + return errors.Wrap(err, "failed to validate user admin privileges") } settings := &config.endpoint.SecuritySettings if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin { - composeFilePath := path.Join(config.stack.ProjectPath, config.stack.EntryPoint) + for _, file := range append([]string{config.stack.EntryPoint}, config.stack.AdditionalFiles...) { + path := path.Join(config.stack.ProjectPath, file) + stackContent, err := handler.FileService.GetFileContent(path) + if err != nil { + return errors.WithMessage(err, "failed to get stack file content") + } - stackContent, err := handler.FileService.GetFileContent(composeFilePath) - if err != nil { - return err - } - - err = handler.isValidStackFile(stackContent, settings) - if err != nil { - return err + err = handler.isValidStackFile(stackContent, settings) + if err != nil { + return errors.WithMessage(err, "swarm stack file content validation failed") + } } } - handler.stackCreationMutex.Lock() - defer handler.stackCreationMutex.Unlock() - - handler.SwarmStackManager.Login(config.dockerhub, config.registries, config.endpoint) - - err = handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint) - if err != nil { - return err - } - - err = handler.SwarmStackManager.Logout(config.endpoint) - if err != nil { - return err - } - - return nil + return handler.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune) } diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index dcdfce41b..f5ba2c983 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -2,23 +2,30 @@ package stacks import ( "context" - "errors" + "fmt" "net/http" "strings" "sync" "github.com/docker/docker/api/types" "github.com/gorilla/mux" + "github.com/pkg/errors" httperror "github.com/portainer/libhttp/error" portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks" ) +const defaultGitReferenceName = "refs/heads/master" + var ( - errStackAlreadyExists = errors.New("A stack already exists with this name") - errStackNotExternal = errors.New("Not an external stack") + errStackAlreadyExists = errors.New("A stack already exists with this name") + errWebhookIDAlreadyExists = errors.New("A webhook ID already exists") + errStackNotExternal = errors.New("Not an external stack") ) // Handler is the HTTP handler used to handle stack operations. @@ -34,6 +41,8 @@ type Handler struct { SwarmStackManager portainer.SwarmStackManager ComposeStackManager portainer.ComposeStackManager KubernetesDeployer portainer.KubernetesDeployer + Scheduler *scheduler.Scheduler + StackDeployer stacks.StackDeployer } // NewHandler creates a handler to manage stack operations. @@ -57,7 +66,9 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h.Handle("/stacks/{id}", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut) h.Handle("/stacks/{id}/git", - bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdateGit))).Methods(http.MethodPut) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdateGit))).Methods(http.MethodPost) + h.Handle("/stacks/{id}/git/redeploy", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackGitRedeploy))).Methods(http.MethodPut) h.Handle("/stacks/{id}/file", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) h.Handle("/stacks/{id}/migrate", @@ -66,6 +77,9 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStart))).Methods(http.MethodPost) h.Handle("/stacks/{id}/stop", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackStop))).Methods(http.MethodPost) + h.Handle("/stacks/webhooks/{webhookID}", + httperror.LoggerHandler(h.webhookInvoke)).Methods(http.MethodPost) + return h } @@ -159,3 +173,34 @@ func (handler *Handler) checkUniqueName(endpoint *portainer.Endpoint, name strin return true, nil } + +func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) { + _, err := handler.DataStore.Stack().StackByWebhookID(webhookID) + if err == bolterrors.ErrObjectNotFound { + return true, nil + } + return false, err +} + +func (handler *Handler) clone(projectPath, repositoryURL, refName string, auth bool, username, password string) error { + if !auth { + username = "" + password = "" + } + + err := handler.GitService.CloneRepository(projectPath, repositoryURL, refName, username, password) + if err != nil { + return fmt.Errorf("unable to clone git repository: %w", err) + } + + return nil +} + +func (handler *Handler) latestCommitID(repositoryURL, refName string, auth bool, username, password string) (string, error) { + if !auth { + username = "" + password = "" + } + + return handler.GitService.LatestCommitID(repositoryURL, refName, username, password) +} diff --git a/api/http/handler/stacks/helper.go b/api/http/handler/stacks/helper.go new file mode 100644 index 000000000..dd42330c4 --- /dev/null +++ b/api/http/handler/stacks/helper.go @@ -0,0 +1,24 @@ +package stacks + +import ( + "time" + + "github.com/asaskevich/govalidator" + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" +) + +func validateStackAutoUpdate(autoUpdate *portainer.StackAutoUpdate) error { + if autoUpdate == nil { + return nil + } + if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) { + return errors.New("invalid Webhook format") + } + if autoUpdate.Interval != "" { + if _, err := time.ParseDuration(autoUpdate.Interval); err != nil { + return errors.New("invalid Interval format") + } + } + return nil +} diff --git a/api/http/handler/stacks/helper_test.go b/api/http/handler/stacks/helper_test.go new file mode 100644 index 000000000..c3e564349 --- /dev/null +++ b/api/http/handler/stacks/helper_test.go @@ -0,0 +1,42 @@ +package stacks + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func Test_ValidateStackAutoUpdate(t *testing.T) { + tests := []struct { + name string + value *portainer.StackAutoUpdate + wantErr bool + }{ + { + name: "webhook is not a valid UUID", + value: &portainer.StackAutoUpdate{Webhook: "fake-webhook"}, + wantErr: true, + }, + { + name: "incorrect interval value", + value: &portainer.StackAutoUpdate{Interval: "1dd2hh3mm"}, + wantErr: true, + }, + { + name: "valid auto update", + value: &portainer.StackAutoUpdate{ + Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada", + Interval: "5h30m40s10ms", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateStackAutoUpdate(tt.value) + assert.Equalf(t, tt.wantErr, err != nil, "received %+v", err) + }) + } +} diff --git a/api/http/handler/stacks/stack_associate.go b/api/http/handler/stacks/stack_associate.go index 55397a323..14a04ed89 100644 --- a/api/http/handler/stacks/stack_associate.go +++ b/api/http/handler/stacks/stack_associate.go @@ -2,6 +2,9 @@ package stacks import ( "fmt" + "net/http" + "time" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -9,8 +12,6 @@ import ( bolterrors "github.com/portainer/portainer/api/bolt/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/stackutils" - "net/http" - "time" ) // PUT request on /api/stacks/:id/associate?endpointId=&swarmId=&orphanedRunning= @@ -87,5 +88,10 @@ func (handler *Handler) stackAssociate(w http.ResponseWriter, r *http.Request) * stack.ResourceControl = resourceControl + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" + } + return response.JSON(w, stack) } diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 53533ca33..225dcb42d 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -1,19 +1,17 @@ package stacks import ( - "errors" - "fmt" "log" "net/http" "github.com/docker/cli/cli/compose/loader" "github.com/docker/cli/cli/compose/types" + "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" - gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/endpointutils" @@ -34,22 +32,22 @@ func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { // @id StackCreate // @summary Deploy a new stack -// @description Deploy a new stack into a Docker environment specified via the endpoint identifier. +// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier. // @description **Access policy**: restricted // @tags stacks // @security jwt -// @accept json, multipart/form-data +// @accept json,multipart/form-data // @produce json // @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack) or 2 (Compose stack)." Enums(1,2) // @param method query string true "Stack deployment method. Possible values: file, string or repository." Enums(string, file, repository) -// @param endpointId query int true "Identifier of the endpoint that will be used to deploy the stack" +// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack" // @param body_swarm_string body swarmStackFromFileContentPayload false "Required when using method=string and type=1" // @param body_swarm_repository body swarmStackFromGitRepositoryPayload false "Required when using method=repository and type=1" // @param body_compose_string body composeStackFromFileContentPayload false "Required when using method=string and type=2" // @param body_compose_repository body composeStackFromGitRepositoryPayload false "Required when using method=repository and type=2" // @param Name formData string false "Name of the stack. required when method is file" // @param SwarmID formData string false "Swarm cluster identifier. Required when method equals file and type equals 1. required when method is file" -// @param Env formData string false "Environment variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]. Optional, used when method equals file and type equals 1." +// @param Env formData string false "Environment(Endpoint) variables passed during deployment, represented as a JSON array [{'name': 'name', 'value': 'value'}]. Optional, used when method equals file and type equals 1." // @param file formData file false "Stack file. required when method is file" // @success 200 {object} portainer.CustomTemplate // @failure 400 "Invalid request" @@ -73,9 +71,9 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } if endpointutils.IsDockerEndpoint(endpoint) && !endpoint.SecuritySettings.AllowStackManagementForRegularUsers { @@ -98,7 +96,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } tokenData, err := security.RetrieveTokenData(r) @@ -112,7 +110,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt case portainer.DockerComposeStack: return handler.createComposeStack(w, r, method, endpoint, tokenData.ID) case portainer.KubernetesStack: - return handler.createKubernetesStack(w, r, method, endpoint) + return handler.createKubernetesStack(w, r, method, endpoint, tokenData.ID) } return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)} @@ -129,7 +127,7 @@ func (handler *Handler) createComposeStack(w http.ResponseWriter, r *http.Reques return handler.createComposeStackFromFileUpload(w, r, endpoint, userID) } - return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string, repository or file", Err: errors.New(request.ErrInvalidQueryParameter)} } func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { @@ -142,15 +140,17 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request, return handler.createSwarmStackFromFileUpload(w, r, endpoint, userID) } - return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string, repository or file", Err: errors.New(request.ErrInvalidQueryParameter)} } -func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { switch method { case "string": - return handler.createKubernetesStackFromFileContent(w, r, endpoint) + return handler.createKubernetesStackFromFileContent(w, r, endpoint, userID) case "repository": - return handler.createKubernetesStackFromGitRepository(w, r, endpoint) + return handler.createKubernetesStackFromGitRepository(w, r, endpoint, userID) + case "url": + return handler.createKubernetesStackFromManifestURL(w, r, endpoint, userID) } return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)} } @@ -232,24 +232,11 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port } stack.ResourceControl = resourceControl + + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" + } + return response.JSON(w, stack) } - -func (handler *Handler) cloneAndSaveConfig(stack *portainer.Stack, projectPath, repositoryURL, refName, configFilePath string, auth bool, username, password string) error { - if !auth { - username = "" - password = "" - } - - err := handler.GitService.CloneRepository(projectPath, repositoryURL, refName, username, password) - if err != nil { - return fmt.Errorf("unable to clone git repository: %w", err) - } - - stack.GitConfig = &gittypes.RepoConfig{ - URL: repositoryURL, - ReferenceName: refName, - ConfigFilePath: configFilePath, - } - return nil -} diff --git a/api/http/handler/stacks/stack_create_test.go b/api/http/handler/stacks/stack_create_test.go deleted file mode 100644 index 414948378..000000000 --- a/api/http/handler/stacks/stack_create_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package stacks - -import ( - "testing" - - portainer "github.com/portainer/portainer/api" - gittypes "github.com/portainer/portainer/api/git/types" - "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/testhelpers" - "github.com/stretchr/testify/assert" -) - -func Test_stackHandler_cloneAndSaveConfig_shouldCallGitCloneAndSaveConfigOnStack(t *testing.T) { - handler := NewHandler(&security.RequestBouncer{}) - handler.GitService = testhelpers.NewGitService() - - url := "url" - refName := "ref" - configPath := "path" - stack := &portainer.Stack{} - err := handler.cloneAndSaveConfig(stack, "", url, refName, configPath, false, "", "") - assert.NoError(t, err, "clone and save should not fail") - - assert.Equal(t, gittypes.RepoConfig{ - URL: url, - ReferenceName: refName, - ConfigFilePath: configPath, - }, *stack.GitConfig) -} diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 11d2c31b5..cd5e2cd26 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -1,6 +1,7 @@ package stacks import ( + "context" "errors" "net/http" "strconv" @@ -23,7 +24,7 @@ import ( // @security jwt // @param id path int true "Stack identifier" // @param external query boolean false "Set to true to delete an external stack. Only external Swarm stacks are supported" -// @param endpointId query int false "Endpoint identifier used to remove an external stack (required when external is set to true)" +// @param endpointId query int false "Environment(Endpoint) identifier used to remove an external stack (required when external is set to true)" // @success 204 "Success" // @failure 400 "Invalid request" // @failure 403 "Permission denied" @@ -71,9 +72,9 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the environment associated to the stack inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment associated to the stack inside the database", err} } resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) @@ -84,18 +85,25 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt if !isOrphaned { err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } } } + // stop scheduler updates of the stack before removal + if stack.AutoUpdate != nil { + stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + } + err = handler.deleteStack(stack, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} @@ -141,14 +149,14 @@ func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWrit endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the environment associated to the stack inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment associated to the stack inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } stack = &portainer.Stack{ @@ -169,5 +177,5 @@ func (handler *Handler) deleteStack(stack *portainer.Stack, endpoint *portainer. return handler.SwarmStackManager.Remove(stack, endpoint) } - return handler.ComposeStackManager.Down(stack, endpoint) + return handler.ComposeStackManager.Down(context.TODO(), stack, endpoint) } diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index f21083bb1..e5c5056d3 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -54,29 +54,31 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) if err == bolterrors.ErrObjectNotFound { if !securityContext.IsAdmin { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } if endpoint != nil { err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} + } } } diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index 7f445397f..b1cb8638b 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -1,9 +1,10 @@ package stacks import ( - "github.com/portainer/portainer/api/http/errors" "net/http" + "github.com/portainer/portainer/api/http/errors" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -48,35 +49,42 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) if err == bolterrors.ErrObjectNotFound { if !securityContext.IsAdmin { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } if endpoint != nil { err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} - } + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} + } - if resourceControl != nil { - stack.ResourceControl = resourceControl + if resourceControl != nil { + stack.ResourceControl = resourceControl + } } } + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" + } + return response.JSON(w, stack) } diff --git a/api/http/handler/stacks/stack_list.go b/api/http/handler/stacks/stack_list.go index 81255d032..aeb950bda 100644 --- a/api/http/handler/stacks/stack_list.go +++ b/api/http/handler/stacks/stack_list.go @@ -1,9 +1,10 @@ package stacks import ( - httperrors "github.com/portainer/portainer/api/http/errors" "net/http" + httperrors "github.com/portainer/portainer/api/http/errors" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -41,7 +42,7 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from database", err} } stacks, err := handler.DataStore.Stack().Stacks() @@ -80,6 +81,13 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe stacks = authorization.FilterAuthorizedStacks(stacks, user, userTeamIDs) } + for _, stack := range stacks { + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" + } + } + return response.JSON(w, stacks) } diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 9f13ef1f5..c631f8fe3 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -16,7 +16,7 @@ import ( ) type stackMigratePayload struct { - // Endpoint identifier of the target endpoint where the stack will be relocated + // Environment(Endpoint) identifier of the target environment(endpoint) where the stack will be relocated EndpointID int `example:"2" validate:"required"` // Swarm cluster identifier, must match the identifier of the cluster where the stack will be relocated SwarmID string `example:"jpofkc0i9uo9wtx1zesuk649w"` @@ -26,20 +26,20 @@ type stackMigratePayload struct { func (payload *stackMigratePayload) Validate(r *http.Request) error { if payload.EndpointID == 0 { - return errors.New("Invalid endpoint identifier. Must be a positive number") + return errors.New("Invalid environment identifier. Must be a positive number") } return nil } // @id StackMigrate -// @summary Migrate a stack to another endpoint -// @description Migrate a stack from an endpoint to another endpoint. It will re-create the stack inside the target endpoint before removing the original stack. +// @summary Migrate a stack to another environment(endpoint) +// @description Migrate a stack from an environment(endpoint) to another environment(endpoint). It will re-create the stack inside the target environment(endpoint) before removing the original stack. // @description **Access policy**: restricted // @tags stacks // @security jwt // @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 endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." // @param body body stackMigratePayload true "Stack migration details" // @success 200 {object} portainer.Stack "Success" // @failure 400 "Invalid request" @@ -68,37 +68,39 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } } // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 - // The EndpointID property is not available for these stacks, this API endpoint - // can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack. + // The EndpointID property is not available for these stacks, this API environment(endpoint) + // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) if err != nil { return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} @@ -109,9 +111,9 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht targetEndpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(payload.EndpointID)) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } stack.EndpointID = portainer.EndpointID(payload.EndpointID) @@ -130,7 +132,7 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht } if !isUnique { - errorMessage := fmt.Sprintf("A stack with the name '%s' is already running on endpoint '%s'", stack.Name, targetEndpoint.Name) + errorMessage := fmt.Sprintf("A stack with the name '%s' is already running on environment '%s'", stack.Name, targetEndpoint.Name) return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} } @@ -150,6 +152,11 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} } + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" + } + return response.JSON(w, stack) } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index dc4163e92..3a6ca0285 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -1,6 +1,7 @@ package stacks import ( + "context" "errors" "fmt" "net/http" @@ -49,14 +50,14 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } isUnique, err := handler.checkUniqueName(endpoint, stack.Name, stack.ID, stack.SwarmID != "") @@ -68,23 +69,36 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} } - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } } if stack.Status == portainer.StackStatusActive { return &httperror.HandlerError{http.StatusBadRequest, "Stack is already active", errors.New("Stack is already active")} } + if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" { + stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + + jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + if e != nil { + return e + } + + stack.AutoUpdate.JobID = jobID + } + err = handler.startStack(stack, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start stack", err} @@ -96,13 +110,18 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} } + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" + } + return response.JSON(w, stack) } func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { switch stack.Type { case portainer.DockerComposeStack: - return handler.ComposeStackManager.Up(stack, endpoint) + return handler.ComposeStackManager.Up(context.TODO(), stack, endpoint) case portainer.DockerSwarmStack: return handler.SwarmStackManager.Deploy(stack, true, endpoint) } diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index 700cec976..6aea8e375 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -1,6 +1,7 @@ package stacks import ( + "context" "errors" "net/http" @@ -47,33 +48,41 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} + } + if !access { + return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + } } if stack.Status == portainer.StackStatusInactive { return &httperror.HandlerError{http.StatusBadRequest, "Stack is already inactive", errors.New("Stack is already inactive")} } + // stop scheduler updates of the stack before stopping + if stack.AutoUpdate != nil && stack.AutoUpdate.JobID != "" { + stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + stack.AutoUpdate.JobID = "" + } + err = handler.stopStack(stack, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to stop stack", err} @@ -85,13 +94,18 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update stack status", err} } + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" + } + return response.JSON(w, stack) } func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error { switch stack.Type { case portainer.DockerComposeStack: - return handler.ComposeStackManager.Down(stack, endpoint) + return handler.ComposeStackManager.Down(context.TODO(), stack, endpoint) case portainer.DockerSwarmStack: return handler.SwarmStackManager.Remove(stack, endpoint) } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 34c64129c..02147b546 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -1,11 +1,12 @@ package stacks import ( - "errors" "net/http" "strconv" "time" + "github.com/pkg/errors" + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -20,7 +21,7 @@ import ( type updateComposeStackPayload struct { // New content of the Stack file StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"` - // A list of environment variables used during stack deployment + // A list of environment(endpoint) variables used during stack deployment Env []portainer.Pair } @@ -34,7 +35,7 @@ func (payload *updateComposeStackPayload) Validate(r *http.Request) error { type updateSwarmStackPayload struct { // New content of the Stack file StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"` - // A list of environment variables used during stack deployment + // A list of environment(endpoint) variables used during stack deployment Env []portainer.Pair // Prune services that are no longer referenced (only available for Swarm stacks) Prune bool `example:"true"` @@ -56,12 +57,12 @@ func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { // @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 endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." // @param body body updateSwarmStackPayload true "Stack details" // @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 { @@ -72,17 +73,17 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err} } // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 - // The EndpointID property is not available for these stacks, this API endpoint - // can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack. + // The EndpointID property is not available for these stacks, this API environment(endpoint) + // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err} } if endpointID != int(stack.EndpointID) { stack.EndpointID = portainer.EndpointID(endpointID) @@ -90,32 +91,36 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the environment associated to the stack inside the database", Err: err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the environment associated to the stack inside the database", Err: err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} - } - - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access environment", Err: err} } securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} + //only check resource control when it is a DockerSwarmStack or a DockerComposeStack + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err} + } + + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err} + } + if !access { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied} + } } updateError := handler.updateAndDeployStack(r, stack, endpoint) @@ -123,9 +128,22 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt return updateError } + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Cannot find context user", Err: errors.Wrap(err, "failed to fetch the user")} + } + stack.UpdatedBy = user.Username + stack.UpdateDate = time.Now().Unix() + stack.Status = portainer.StackStatusActive + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err} + } + + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" } return response.JSON(w, stack) @@ -134,15 +152,20 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { if stack.Type == portainer.DockerSwarmStack { return handler.updateSwarmStack(r, stack, endpoint) + } else if stack.Type == portainer.DockerComposeStack { + return handler.updateComposeStack(r, stack, endpoint) + } else if stack.Type == portainer.KubernetesStack { + return handler.updateKubernetesStack(r, stack, endpoint) + } else { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unsupported stack", Err: errors.Errorf("unsupported stack type: %v", stack.Type)} } - return handler.updateComposeStack(r, stack, endpoint) } func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { var payload updateComposeStackPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} } stack.Env = payload.Env @@ -150,7 +173,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta stackFolder := strconv.Itoa(int(stack.ID)) _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist updated Compose file on disk", Err: err} } config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) @@ -158,12 +181,9 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta return configErr } - stack.UpdateDate = time.Now().Unix() - stack.UpdatedBy = config.user.Username - err = handler.deployComposeStack(config) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} } return nil @@ -173,7 +193,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack var payload updateSwarmStackPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} } stack.Env = payload.Env @@ -181,7 +201,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack stackFolder := strconv.Itoa(int(stack.ID)) _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist updated Compose file on disk", Err: err} } config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune) @@ -189,13 +209,9 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack return configErr } - stack.UpdateDate = time.Now().Unix() - stack.UpdatedBy = config.user.Username - stack.Status = portainer.StackStatusActive - err = handler.deploySwarmStack(config) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} } return nil diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index dd46e409e..45576fe61 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -2,10 +2,7 @@ package stacks import ( "errors" - "fmt" - "log" "net/http" - "time" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" @@ -13,37 +10,43 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" - "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/stackutils" ) -type updateStackGitPayload struct { +type stackGitUpdatePayload struct { + AutoUpdate *portainer.StackAutoUpdate + Env []portainer.Pair RepositoryReferenceName string RepositoryAuthentication bool RepositoryUsername string RepositoryPassword string } -func (payload *updateStackGitPayload) Validate(r *http.Request) error { - if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { - return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") +func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.RepositoryReferenceName) { + payload.RepositoryReferenceName = defaultGitReferenceName + } + + if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + return err } return nil } // @id StackUpdateGit -// @summary Redeploy a stack -// @description Pull and redeploy a stack via Git +// @summary Update a stack's Git configs +// @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate // @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" +// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." +// @param body body stackGitUpdatePayload true "Git configs for pull and redeploy a stack" // @success 200 {object} portainer.Stack "Success" // @failure 400 "Invalid request" // @failure 403 "Permission denied" @@ -53,26 +56,31 @@ func (payload *updateStackGitPayload) Validate(r *http.Request) error { func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid stack identifier route variable", err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err} + } + + var payload stackGitUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} } stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} - } - - if stack.GitConfig == nil { - return &httperror.HandlerError{http.StatusBadRequest, "Stack is not created from git", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err} + } else if stack.GitConfig == nil { + msg := "No Git config in the found stack" + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: msg, Err: errors.New(msg)} } // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 - // The EndpointID property is not available for these stacks, this API endpoint - // can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack. + // The EndpointID property is not available for these stacks, this API environment(endpoint) + // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err} } if endpointID != int(stack.EndpointID) { stack.EndpointID = portainer.EndpointID(endpointID) @@ -80,117 +88,77 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the environment associated to the stack inside the database", Err: err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the environment associated to the stack inside the database", Err: err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access environment", Err: err} } - resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} - } - - securityContext, err := security.RetrieveRestrictedRequestContext(r) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } - - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} - } - if !access { - return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} - } - - var payload updateStackGitPayload - err = request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - stack.GitConfig.ReferenceName = payload.RepositoryReferenceName - - backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath) - err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to move git repository directory", err} - } - - repositoryUsername := payload.RepositoryUsername - repositoryPassword := payload.RepositoryPassword - if !payload.RepositoryAuthentication { - repositoryUsername = "" - repositoryPassword = "" - } - - err = handler.GitService.CloneRepository(stack.ProjectPath, stack.GitConfig.URL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword) - if err != nil { - restoreError := filesystem.MoveDirectory(backupProjectPath, stack.ProjectPath) - if restoreError != nil { - log.Printf("[WARN] [http,stacks,git] [error: %s] [message: failed restoring backup folder]", restoreError) - } - - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clone git repository", err} - } - - defer func() { - err = handler.FileService.RemoveDirectory(backupProjectPath) + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if err != nil { - log.Printf("[WARN] [http,stacks,git] [error: %s] [message: unable to remove git repository directory]", err) + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err} } - }() - httpErr := handler.deployStack(r, stack, endpoint) - if httpErr != nil { - return httpErr + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} + } + + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err} + } + if !access { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied} + } } + //stop the autoupdate job if there is any + if stack.AutoUpdate != nil { + stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + } + + //update retrieved stack data based on the payload + stack.GitConfig.ReferenceName = payload.RepositoryReferenceName + stack.AutoUpdate = payload.AutoUpdate + stack.Env = payload.Env + + stack.GitConfig.Authentication = nil + if payload.RepositoryAuthentication { + password := payload.RepositoryPassword + if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil { + password = stack.GitConfig.Authentication.Password + } + stack.GitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: password, + } + } + + if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { + jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + if e != nil { + return e + } + + stack.AutoUpdate.JobID = jobID + } + + //save the updated stack to DB err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err} + } + + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" } return response.JSON(w, stack) } - -func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { - if stack.Type == portainer.DockerSwarmStack { - config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) - if httpErr != nil { - return httpErr - } - - err := handler.deploySwarmStack(config) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} - } - - stack.UpdateDate = time.Now().Unix() - stack.UpdatedBy = config.user.Username - stack.Status = portainer.StackStatusActive - - return nil - } - - config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint) - if httpErr != nil { - return httpErr - } - - err := handler.deployComposeStack(config) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} - } - - stack.UpdateDate = time.Now().Unix() - stack.UpdatedBy = config.user.Username - stack.Status = portainer.StackStatusActive - - return nil -} diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go new file mode 100644 index 000000000..d5d87c4f0 --- /dev/null +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -0,0 +1,238 @@ +package stacks + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "path/filepath" + "time" + + "github.com/asaskevich/govalidator" + "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/filesystem" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/stackutils" + k "github.com/portainer/portainer/api/kubernetes" +) + +type stackGitRedployPayload struct { + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string + Env []portainer.Pair +} + +func (payload *stackGitRedployPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.RepositoryReferenceName) { + payload.RepositoryReferenceName = defaultGitReferenceName + } + return nil +} + +// @id StackGitRedeploy +// @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 environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack." +// @param body body stackGitRedployPayload 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/redeploy [put] +func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err} + } + + stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err} + } + + if stack.GitConfig == nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Stack is not created from git", Err: err} + } + + // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 + // The EndpointID property is not available for these stacks, this API environment(endpoint) + // can use the optional EndpointID query parameter to associate a valid environment(endpoint) identifier to the stack. + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err} + } + if endpointID != int(stack.EndpointID) { + stack.EndpointID = portainer.EndpointID(endpointID) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the environment associated to the stack inside the database", Err: err} + } else if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the environment associated to the stack inside the database", Err: err} + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access environment", 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} + } + + //only check resource control when it is a DockerSwarmStack or a DockerComposeStack + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err} + } + + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err} + } + if !access { + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied} + } + } + + var payload stackGitRedployPayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} + } + + stack.GitConfig.ReferenceName = payload.RepositoryReferenceName + stack.Env = payload.Env + + backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath) + err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to move git repository directory", Err: err} + } + + repositoryUsername := "" + repositoryPassword := "" + if payload.RepositoryAuthentication { + repositoryPassword = payload.RepositoryPassword + if repositoryPassword == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil { + repositoryPassword = stack.GitConfig.Authentication.Password + } + repositoryUsername = payload.RepositoryUsername + } + + err = handler.GitService.CloneRepository(stack.ProjectPath, stack.GitConfig.URL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword) + if err != nil { + restoreError := filesystem.MoveDirectory(backupProjectPath, stack.ProjectPath) + if restoreError != nil { + log.Printf("[WARN] [http,stacks,git] [error: %s] [message: failed restoring backup folder]", restoreError) + } + + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err} + } + + defer func() { + err = handler.FileService.RemoveDirectory(backupProjectPath) + if err != nil { + log.Printf("[WARN] [http,stacks,git] [error: %s] [message: unable to remove git repository directory]", err) + } + }() + + httpErr := handler.deployStack(r, stack, endpoint) + if httpErr != nil { + return httpErr + } + + newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable get latest commit id", Err: errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID)} + } + stack.GitConfig.ConfigHash = newHash + + user, err := handler.DataStore.User().User(securityContext.UserID) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Cannot find context user", Err: errors.Wrap(err, "failed to fetch the user")} + } + stack.UpdatedBy = user.Username + stack.UpdateDate = time.Now().Unix() + stack.Status = portainer.StackStatusActive + + err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: errors.Wrap(err, "failed to update the stack")} + } + + if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { + // sanitize password in the http response to minimise possible security leaks + stack.GitConfig.Authentication.Password = "" + } + + return response.JSON(w, stack) +} + +func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + switch stack.Type { + case portainer.DockerSwarmStack: + config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + if httpErr != nil { + return httpErr + } + + if err := handler.deploySwarmStack(config); err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} + } + + case portainer.DockerComposeStack: + config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint) + if httpErr != nil { + return httpErr + } + + if err := handler.deployComposeStack(config); err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} + } + + case portainer.KubernetesStack: + if stack.Namespace == "" { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Invalid namespace", Err: errors.New("Namespace must not be empty when redeploying kubernetes stacks")} + } + content, err := ioutil.ReadFile(filepath.Join(stack.ProjectPath, stack.GitConfig.ConfigFilePath)) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to read deployment.yml manifest file", Err: errors.Wrap(err, "failed to read manifest file")} + } + _, err = handler.deployKubernetesStack(r, endpoint, string(content), stack.IsComposeFormat, stack.Namespace, k.KubeAppLabels{ + StackID: int(stack.ID), + Name: stack.Name, + Owner: stack.CreatedBy, + Kind: "git", + }) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to redeploy Kubernetes stack", Err: errors.WithMessage(err, "failed to deploy kube application")} + } + + default: + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unsupported stack", Err: errors.Errorf("unsupported stack type: %v", stack.Type)} + } + + return nil +} diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go new file mode 100644 index 000000000..114552b68 --- /dev/null +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -0,0 +1,97 @@ +package stacks + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/asaskevich/govalidator" + "github.com/pkg/errors" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" + k "github.com/portainer/portainer/api/kubernetes" +) + +type kubernetesFileStackUpdatePayload struct { + StackFileContent string +} + +type kubernetesGitStackUpdatePayload struct { + RepositoryReferenceName string + RepositoryAuthentication bool + RepositoryUsername string + RepositoryPassword string +} + +func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.StackFileContent) { + return errors.New("Invalid stack file content") + } + return nil +} + +func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.RepositoryReferenceName) { + payload.RepositoryReferenceName = defaultGitReferenceName + } + return nil +} + +func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { + + if stack.GitConfig != nil { + var payload kubernetesGitStackUpdatePayload + + if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} + } + + stack.GitConfig.ReferenceName = payload.RepositoryReferenceName + if payload.RepositoryAuthentication { + password := payload.RepositoryPassword + if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil { + password = stack.GitConfig.Authentication.Password + } + stack.GitConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: password, + } + } else { + stack.GitConfig.Authentication = nil + } + return nil + } + + var payload kubernetesFileStackUpdatePayload + + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} + } + + _, err = handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, stack.IsComposeFormat, stack.Namespace, k.KubeAppLabels{ + StackID: int(stack.ID), + Name: stack.Name, + Owner: stack.CreatedBy, + Kind: "content", + }) + + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack via file content", Err: err} + } + + stackFolder := strconv.Itoa(int(stack.ID)) + _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + fileType := "Manifest" + if stack.IsComposeFormat { + fileType = "Compose" + } + errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType) + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err} + } + + return nil +} diff --git a/api/http/handler/stacks/webhook_invoke.go b/api/http/handler/stacks/webhook_invoke.go new file mode 100644 index 000000000..01c9d701b --- /dev/null +++ b/api/http/handler/stacks/webhook_invoke.go @@ -0,0 +1,54 @@ +package stacks + +import ( + "log" + "net/http" + + "github.com/gofrs/uuid" + + "github.com/portainer/libhttp/response" + + bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/stacks" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" +) + +func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + webhookID, err := retrieveUUIDRouteVariableValue(r, "webhookID") + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid webhook identifier route variable", Err: err} + } + + stack, err := handler.DataStore.Stack().StackByWebhookID(webhookID.String()) + if err != nil { + statusCode := http.StatusInternalServerError + if err == bolterrors.ErrObjectNotFound { + statusCode = http.StatusNotFound + } + return &httperror.HandlerError{StatusCode: statusCode, Message: "Unable to find the stack by webhook ID", Err: err} + } + + if err = stacks.RedeployWhenChanged(stack.ID, handler.StackDeployer, handler.DataStore, handler.GitService); err != nil { + log.Printf("[ERROR] %s\n", err) + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to update the stack", Err: err} + } + + return response.Empty(w) +} + +func retrieveUUIDRouteVariableValue(r *http.Request, name string) (uuid.UUID, error) { + webhookID, err := request.RetrieveRouteVariableValue(r, name) + if err != nil { + return uuid.Nil, err + } + + uid, err := uuid.FromString(webhookID) + + if err != nil { + return uuid.Nil, err + } + + return uid, nil +} diff --git a/api/http/handler/stacks/webhook_invoke_test.go b/api/http/handler/stacks/webhook_invoke_test.go new file mode 100644 index 000000000..cc6656519 --- /dev/null +++ b/api/http/handler/stacks/webhook_invoke_test.go @@ -0,0 +1,59 @@ +package stacks + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofrs/uuid" + "github.com/stretchr/testify/assert" + + portainer "github.com/portainer/portainer/api" + + "github.com/portainer/portainer/api/bolt/bolttest" +) + +func TestHandler_webhookInvoke(t *testing.T) { + store, teardown := bolttest.MustNewTestStore(true) + defer teardown() + + webhookID := newGuidString(t) + store.StackService.CreateStack(&portainer.Stack{ + AutoUpdate: &portainer.StackAutoUpdate{ + Webhook: webhookID, + }, + }) + + h := NewHandler(nil) + h.DataStore = store + + t.Run("invalid uuid results in http.StatusBadRequest", func(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest("notuuid") + h.Router.ServeHTTP(w, req) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + t.Run("registered webhook ID in http.StatusNoContent", func(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest(webhookID) + h.Router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNoContent, w.Code) + }) + t.Run("unregistered webhook ID in http.StatusNotFound", func(t *testing.T) { + w := httptest.NewRecorder() + req := newRequest(newGuidString(t)) + h.Router.ServeHTTP(w, req) + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func newGuidString(t *testing.T) string { + uuid, err := uuid.NewV4() + assert.NoError(t, err) + + return uuid.String() +} + +func newRequest(webhookID string) *http.Request { + return httptest.NewRequest(http.MethodPost, "/stacks/webhooks/"+webhookID, nil) +} diff --git a/api/http/handler/tags/tag_delete.go b/api/http/handler/tags/tag_delete.go index f1465dfce..9d756cb89 100644 --- a/api/http/handler/tags/tag_delete.go +++ b/api/http/handler/tags/tag_delete.go @@ -43,7 +43,7 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe for endpointID := range tag.Endpoints { endpoint, err := handler.DataStore.Endpoint().Endpoint(endpointID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment from the database", err} } tagIdx := findTagIndex(endpoint.TagIDs, tagID) @@ -51,7 +51,7 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe endpoint.TagIDs = removeElement(endpoint.TagIDs, tagIdx) err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update environment", err} } } } @@ -59,7 +59,7 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe for endpointGroupID := range tag.EndpointGroups { endpointGroup, err := handler.DataStore.EndpointGroup().EndpointGroup(endpointGroupID) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint group from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment group from the database", err} } tagIdx := findTagIndex(endpointGroup.TagIDs, tagID) @@ -67,14 +67,14 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe endpointGroup.TagIDs = removeElement(endpointGroup.TagIDs, tagIdx) err = handler.DataStore.EndpointGroup().UpdateEndpointGroup(endpointGroup.ID, endpointGroup) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update environment group", err} } } } endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err} } edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups() @@ -91,7 +91,7 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe if (tag.Endpoints[endpoint.ID] || tag.EndpointGroups[endpoint.GroupID]) && (endpoint.Type == portainer.EdgeAgentOnDockerEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment) { err = handler.updateEndpointRelations(endpoint, edgeGroups, edgeStacks) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint relations in the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update environment relations in the database", err} } } } @@ -103,7 +103,7 @@ func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httpe edgeGroup.TagIDs = removeElement(edgeGroup.TagIDs, tagIdx) err = handler.DataStore.EdgeGroup().UpdateEdgeGroup(edgeGroup.ID, edgeGroup) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint group", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update environment group", err} } } } diff --git a/api/http/handler/teams/team_delete.go b/api/http/handler/teams/team_delete.go index cc1246191..36b3c4aee 100644 --- a/api/http/handler/teams/team_delete.go +++ b/api/http/handler/teams/team_delete.go @@ -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 @@ -16,6 +17,7 @@ import ( // @description **Access policy**: administrator // @tags teams // @security jwt +// @param id path string true "Team Id" // @success 204 "Success" // @failure 400 "Invalid request" // @failure 403 "Permission denied" @@ -29,7 +31,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 +47,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") +} diff --git a/api/http/handler/teams/team_memberships.go b/api/http/handler/teams/team_memberships.go index 03b86ce62..422ef2ac5 100644 --- a/api/http/handler/teams/team_memberships.go +++ b/api/http/handler/teams/team_memberships.go @@ -18,6 +18,7 @@ import ( // @tags team_memberships // @security jwt // @produce json +// @param id path string true "Team Id" // @success 200 {array} portainer.TeamMembership "Success" // @failure 400 "Invalid request" // @failure 403 "Permission denied" diff --git a/api/http/handler/upload/upload_tls.go b/api/http/handler/upload/upload_tls.go index a82d357db..f95942eb5 100644 --- a/api/http/handler/upload/upload_tls.go +++ b/api/http/handler/upload/upload_tls.go @@ -12,7 +12,7 @@ import ( // @id UploadTLS // @summary Upload TLS files -// @description Use this endpoint to upload TLS files. +// @description Use this environment(endpoint) to upload TLS files. // @description **Access policy**: administrator // @tags upload // @security jwt diff --git a/api/http/handler/users/handler.go b/api/http/handler/users/handler.go index 5ada9b87a..f6c399c7e 100644 --- a/api/http/handler/users/handler.go +++ b/api/http/handler/users/handler.go @@ -4,7 +4,7 @@ import ( "errors" httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "net/http" diff --git a/api/http/handler/users/user_update.go b/api/http/handler/users/user_update.go index fdd7e1a65..02e337f3b 100644 --- a/api/http/handler/users/user_update.go +++ b/api/http/handler/users/user_update.go @@ -17,6 +17,7 @@ import ( type userUpdatePayload struct { Username string `validate:"required" example:"bob"` Password string `validate:"required" example:"cg9Wgky3"` + UserTheme string `example:"dark"` // User role (1 for administrator account and 2 for regular account) Role int `validate:"required" enums:"1,2" example:"2"` } @@ -104,6 +105,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http user.Role = portainer.UserRole(payload.Role) } + if payload.UserTheme != "" { + user.UserTheme = payload.UserTheme + } + err = handler.DataStore.User().UpdateUser(user.ID, user) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user changes inside the database", err} diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index 5449bd07a..2fbc12772 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -46,9 +46,9 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) * 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} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment 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} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err} } imageTag, _ := request.RetrieveQueryParameter(r, "tag", true) diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go index 7b28ec655..102cb35dc 100644 --- a/api/http/handler/websocket/attach.go +++ b/api/http/handler/websocket/attach.go @@ -15,7 +15,7 @@ import ( ) // @summary Attach a websocket -// @description If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. +// @description If the nodeName query parameter is present, the request will be proxied to the underlying agent environment(endpoint). // @description If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and // @description an AttachStart operation HTTP request will be created and hijacked. // @description Authentication and access is controlled via the mandatory token query parameter. @@ -23,9 +23,9 @@ import ( // @tags websocket // @accept json // @produce json -// @param endpointId query int true "endpoint ID of the endpoint where the resource is located" +// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" // @param nodeName query string false "node name" -// @param token query string true "JWT token used for authentication against this endpoint" +// @param token query string true "JWT token used for authentication against this environment(endpoint)" // @success 200 // @failure 400 // @failure 403 @@ -48,14 +48,14 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request) endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the environment associated to the stack inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment associated to the stack inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } params := &webSocketRequestParams{ diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go index 7e451b252..023af618e 100644 --- a/api/http/handler/websocket/exec.go +++ b/api/http/handler/websocket/exec.go @@ -23,7 +23,7 @@ type execStartOperationPayload struct { } // @summary Execute a websocket -// @description If the nodeName query parameter is present, the request will be proxied to the underlying agent endpoint. +// @description If the nodeName query parameter is present, the request will be proxied to the underlying agent environment(endpoint). // @description If the nodeName query parameter is not specified, the request will be upgraded to the websocket protocol and // @description an ExecStart operation HTTP request will be created and hijacked. // @description Authentication and access is controlled via the mandatory token query parameter. @@ -31,9 +31,9 @@ type execStartOperationPayload struct { // @tags websocket // @accept json // @produce json -// @param endpointId query int true "endpoint ID of the endpoint where the resource is located" +// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" // @param nodeName query string false "node name" -// @param token query string true "JWT token used for authentication against this endpoint" +// @param token query string true "JWT token used for authentication against this environment(endpoint)" // @success 200 // @failure 400 // @failure 409 @@ -55,14 +55,14 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == errors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the environment associated to the stack inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment associated to the stack inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} } params := &webSocketRequestParams{ diff --git a/api/http/handler/websocket/handler.go b/api/http/handler/websocket/handler.go index 517df5756..477f72500 100644 --- a/api/http/handler/websocket/handler.go +++ b/api/http/handler/websocket/handler.go @@ -36,5 +36,7 @@ func NewHandler(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, bounc bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketAttach))) h.PathPrefix("/websocket/pod").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketPodExec))) + h.PathPrefix("/websocket/kubernetes-shell").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.websocketShellPodExec))) return h } diff --git a/api/http/handler/websocket/pod.go b/api/http/handler/websocket/pod.go index 07e14c39d..8b519bb43 100644 --- a/api/http/handler/websocket/pod.go +++ b/api/http/handler/websocket/pod.go @@ -2,12 +2,13 @@ package websocket import ( "fmt" - "github.com/portainer/portainer/api/http/security" "io" "log" "net/http" "strings" + "github.com/portainer/portainer/api/http/security" + "github.com/gorilla/websocket" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -23,12 +24,12 @@ import ( // @tags websocket // @accept json // @produce json -// @param endpointId query int true "endpoint ID of the endpoint where the resource is located" +// @param endpointId query int true "environment(endpoint) ID of the environment(endpoint) where the resource is located" // @param namespace query string true "namespace where the container is located" // @param podName query string true "name of the pod containing the container" // @param containerName query string true "name of the container" // @param command query string true "command to execute in the container" -// @param token query string true "JWT token used for authentication against this endpoint" +// @param token query string true "JWT token used for authentication against this environment(endpoint)" // @success 200 // @failure 400 // @failure 403 @@ -63,14 +64,19 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the environment associated to the stack inside the database", err} } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment associated to the stack inside the database", err} } err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint) if err != nil { - return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} + } + + serviceAccountToken, isAdminToken, err := handler.getToken(r, endpoint, false) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to get user service account token", err} } token, useAdminToken, err := handler.getToken(r, endpoint, false) @@ -80,7 +86,7 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) params := &webSocketRequestParams{ endpoint: endpoint, - token: token, + token: serviceAccountToken, } r.Header.Del("Origin") @@ -99,6 +105,28 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) return nil } + cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + handlerErr := handler.hijackPodExecStartOperation(w, r, cli, serviceAccountToken, isAdminToken, endpoint, namespace, podName, containerName, command) + if handlerErr != nil { + return handlerErr + } + + return nil +} + +func (handler *Handler) hijackPodExecStartOperation( + w http.ResponseWriter, + r *http.Request, + cli portainer.KubeClient, + serviceAccountToken string, + isAdminToken bool, + endpoint *portainer.Endpoint, + namespace, podName, containerName, command string, +) *httperror.HandlerError { commandArray := strings.Split(command, " ") websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) @@ -112,26 +140,59 @@ func (handler *Handler) websocketPodExec(w http.ResponseWriter, r *http.Request) stdoutReader, stdoutWriter := io.Pipe() defer stdoutWriter.Close() + // errorChan is used to propagate errors from the go routines to the caller. errorChan := make(chan error, 1) go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan) go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan) - cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} - } - - 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} - } + // StartExecProcess is a blocking operation which streams IO to/from pod; + // this must execute in asynchronously, since the websocketConn could return errors (e.g. client disconnects) before + // the blocking operation is completed. + go cli.StartExecProcess(serviceAccountToken, isAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter, errorChan) err = <-errorChan + + // websocket client successfully disconnected if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { log.Printf("websocket error: %s \n", err.Error()) + return nil } - return nil + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err} +} + +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 } func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) { diff --git a/api/http/handler/websocket/proxy.go b/api/http/handler/websocket/proxy.go index 14072d315..c9a3b07de 100644 --- a/api/http/handler/websocket/proxy.go +++ b/api/http/handler/websocket/proxy.go @@ -22,12 +22,22 @@ func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r endpointURL.Scheme = "ws" proxy := websocketproxy.NewProxy(endpointURL) + signature, err := handler.SignatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) + if err != nil { + return err + } + proxy.Director = func(incoming *http.Request, out http.Header) { + out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey()) + out.Set(portainer.PortainerAgentSignatureHeader, signature) out.Set(portainer.PortainerAgentTargetHeader, params.nodeName) out.Set(portainer.PortainerAgentKubernetesSATokenHeader, params.token) } handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID) + + handler.ReverseTunnelService.KeepTunnelAlive(params.endpoint.ID, r.Context(), portainer.WebSocketKeepAlive) + proxy.ServeHTTP(w, r) return nil diff --git a/api/http/handler/websocket/shell_pod.go b/api/http/handler/websocket/shell_pod.go new file mode 100644 index 000000000..f736a7dc7 --- /dev/null +++ b/api/http/handler/websocket/shell_pod.go @@ -0,0 +1,106 @@ +package websocket + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "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/security" +) + +// websocketShellPodExec handles GET requests on /websocket/pod?token=&endpointId= +// The request will be upgraded to the websocket protocol. +// Authentication and access is controlled via the mandatory token query parameter. +// The request will proxy input from the client to the pod via long-lived websocket connection. +// The following query parameters are mandatory: +// * token: JWT token used for authentication against this environment(endpoint) +// * endpointId: environment(endpoint) ID of the environment(endpoint) where the resource is located +func (handler *Handler) websocketShellPodExec(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == bolterrors.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find the environment associated to the stack inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the environment associated to the stack inside the database", err} + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access environment", err} + } + + cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Kubernetes client", err} + } + + serviceAccount, err := cli.GetServiceAccount(tokenData) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find serviceaccount associated with user", err} + } + + shellPod, err := cli.CreateUserShellPod(r.Context(), serviceAccount.Name) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create user shell", err} + } + + // Modifying request params mid-flight before forewarding to K8s API server (websocket) + q := r.URL.Query() + + q.Add("namespace", shellPod.Namespace) + q.Add("podName", shellPod.PodName) + q.Add("containerName", shellPod.ContainerName) + q.Add("command", shellPod.ShellExecCommand) + + r.URL.RawQuery = q.Encode() + + // Modify url path mid-flight before forewarding to k8s API server (websocket) + r.URL.Path = "/websocket/pod" + + /* + Note: The following websocket proxying logic is duplicated from `api/http/handler/websocket/pod.go` + */ + params := &webSocketRequestParams{ + endpoint: endpoint, + } + + r.Header.Del("Origin") + + if endpoint.Type == portainer.AgentOnKubernetesEnvironment { + err := handler.proxyAgentWebsocketRequest(w, r, params) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to proxy websocket request to agent", err} + } + return nil + } else if endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment { + err := handler.proxyEdgeAgentWebsocketRequest(w, r, params) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to proxy websocket request to Edge agent", err} + } + return nil + } + + handlerErr := handler.hijackPodExecStartOperation( + w, + r, + cli, + "", + true, + endpoint, + shellPod.Namespace, + shellPod.PodName, + shellPod.ContainerName, + shellPod.ShellExecCommand, + ) + if handlerErr != nil { + return handlerErr + } + + return nil +} diff --git a/api/http/middlewares/endpoint.go b/api/http/middlewares/endpoint.go new file mode 100644 index 000000000..2f3967e0c --- /dev/null +++ b/api/http/middlewares/endpoint.go @@ -0,0 +1,58 @@ +package middlewares + +import ( + "context" + "errors" + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + requesthelpers "github.com/portainer/libhttp/request" + portainer "github.com/portainer/portainer/api" + bolterrors "github.com/portainer/portainer/api/bolt/errors" +) + +const ( + contextEndpoint = "endpoint" +) + +func WithEndpoint(endpointService portainer.EndpointService, endpointIDParam string) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, request *http.Request) { + if endpointIDParam == "" { + endpointIDParam = "id" + } + + endpointID, err := requesthelpers.RetrieveNumericRouteVariableValue(request, endpointIDParam) + if err != nil { + httperror.WriteError(rw, http.StatusBadRequest, "Invalid environment identifier route variable", err) + return + } + + endpoint, err := endpointService.Endpoint(portainer.EndpointID(endpointID)) + if err != nil { + statusCode := http.StatusInternalServerError + + if err == bolterrors.ErrObjectNotFound { + statusCode = http.StatusNotFound + } + httperror.WriteError(rw, statusCode, "Unable to find an environment with the specified identifier inside the database", err) + return + } + + ctx := context.WithValue(request.Context(), contextEndpoint, endpoint) + + next.ServeHTTP(rw, request.WithContext(ctx)) + + }) + } +} + +func FetchEndpoint(request *http.Request) (*portainer.Endpoint, error) { + contextData := request.Context().Value(contextEndpoint) + if contextData == nil { + return nil, errors.New("Unable to find environment data in request context") + } + + return contextData.(*portainer.Endpoint), nil +} diff --git a/api/http/proxy/factory/azure/containergroup.go b/api/http/proxy/factory/azure/containergroup.go index 242968de5..99c7b2a88 100644 --- a/api/http/proxy/factory/azure/containergroup.go +++ b/api/http/proxy/factory/azure/containergroup.go @@ -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 } diff --git a/api/http/proxy/factory/azure/containergroups.go b/api/http/proxy/factory/azure/containergroups.go index ccb441b3b..e567ec5b7 100644 --- a/api/http/proxy/factory/azure/containergroups.go +++ b/api/http/proxy/factory/azure/containergroups.go @@ -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 -} \ No newline at end of file +} diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index 7f1cb4157..8db016ed9 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -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) { diff --git a/api/http/proxy/factory/docker/configs.go b/api/http/proxy/factory/docker/configs.go index 74d10759d..4820b74c6 100644 --- a/api/http/proxy/factory/docker/configs.go +++ b/api/http/proxy/factory/docker/configs.go @@ -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 diff --git a/api/http/proxy/factory/docker/containers.go b/api/http/proxy/factory/docker/containers.go index 97108355e..dc92ae379 100644 --- a/api/http/proxy/factory/docker/containers.go +++ b/api/http/proxy/factory/docker/containers.go @@ -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 } diff --git a/api/http/proxy/factory/docker/networks.go b/api/http/proxy/factory/docker/networks.go index b38ce68ec..05df57589 100644 --- a/api/http/proxy/factory/docker/networks.go +++ b/api/http/proxy/factory/docker/networks.go @@ -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") } diff --git a/api/http/proxy/factory/docker/registry.go b/api/http/proxy/factory/docker/registry.go index c07ebae3d..38f0bd903 100644 --- a/api/http/proxy/factory/docker/registry.go +++ b/api/http/proxy/factory/docker/registry.go @@ -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 } diff --git a/api/http/proxy/factory/docker/secrets.go b/api/http/proxy/factory/docker/secrets.go index 148073c02..6f7c203f8 100644 --- a/api/http/proxy/factory/docker/secrets.go +++ b/api/http/proxy/factory/docker/secrets.go @@ -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 diff --git a/api/http/proxy/factory/docker/services.go b/api/http/proxy/factory/docker/services.go index 683859f73..205c48c60 100644 --- a/api/http/proxy/factory/docker/services.go +++ b/api/http/proxy/factory/docker/services.go @@ -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 } diff --git a/api/http/proxy/factory/docker/swarm.go b/api/http/proxy/factory/docker/swarm.go index bc3ff9c4d..be39a4b0f 100644 --- a/api/http/proxy/factory/docker/swarm.go +++ b/api/http/proxy/factory/docker/swarm.go @@ -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) } diff --git a/api/http/proxy/factory/docker/tasks.go b/api/http/proxy/factory/docker/tasks.go index ad13398fd..f91c1a81c 100644 --- a/api/http/proxy/factory/docker/tasks.go +++ b/api/http/proxy/factory/docker/tasks.go @@ -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 diff --git a/api/http/proxy/factory/docker/transport.go b/api/http/proxy/factory/docker/transport.go index 803913ac5..6c043d246 100644 --- a/api/http/proxy/factory/docker/transport.go +++ b/api/http/proxy/factory/docker/transport.go @@ -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" ) @@ -90,7 +92,7 @@ func (transport *Transport) ProxyDockerRequest(request *http.Request) (*http.Res requestPath := apiVersionRe.ReplaceAllString(request.URL.Path, "") request.URL.Path = requestPath - if transport.endpoint.Type == portainer.AgentOnDockerEnvironment { + if transport.endpoint.Type == portainer.AgentOnDockerEnvironment || transport.endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) if err != nil { return nil, err @@ -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) diff --git a/api/http/proxy/factory/docker/volumes.go b/api/http/proxy/factory/docker/volumes.go index c1bd3d993..a09788c9f 100644 --- a/api/http/proxy/factory/docker/volumes.go +++ b/api/http/proxy/factory/docker/volumes.go @@ -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 } diff --git a/api/http/proxy/factory/docker_compose.go b/api/http/proxy/factory/docker_compose.go index 7da8d898f..2d1ac4966 100644 --- a/api/http/proxy/factory/docker_compose.go +++ b/api/http/proxy/factory/docker_compose.go @@ -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, nil } 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) } }() diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go index 1e6f83d40..dc0e9e0c4 100644 --- a/api/http/proxy/factory/factory.go +++ b/api/http/proxy/factory/factory.go @@ -51,7 +51,7 @@ func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (ht return proxy, nil } -// NewEndpointProxy returns a new reverse proxy (filesystem based or HTTP) to an endpoint API server +// NewEndpointProxy returns a new reverse proxy (filesystem based or HTTP) to an environment(endpoint) API server func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { switch endpoint.Type { case portainer.AzureEnvironment: diff --git a/api/http/proxy/factory/kubernetes.go b/api/http/proxy/factory/kubernetes.go index ae1d65edd..1d96d3fd5 100644 --- a/api/http/proxy/factory/kubernetes.go +++ b/api/http/proxy/factory/kubernetes.go @@ -39,7 +39,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin return nil, err } - transport, err := kubernetes.NewLocalTransport(tokenManager, endpoint, factory.dataStore) + 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, tokenManager) + proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory) 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, endpoint) + proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore) return proxy, nil } diff --git a/api/http/proxy/factory/kubernetes/agent_transport.go b/api/http/proxy/factory/kubernetes/agent_transport.go index fb87f1123..2973d46c9 100644 --- a/api/http/proxy/factory/kubernetes/agent_transport.go +++ b/api/http/proxy/factory/kubernetes/agent_transport.go @@ -6,6 +6,7 @@ import ( "strings" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes/cli" ) type agentTransport struct { @@ -14,17 +15,18 @@ type agentTransport struct { } // 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, endpoint *portainer.Endpoint) *agentTransport { +func NewAgentTransport(signatureService portainer.DigitalSignatureService, tlsConfig *tls.Config, tokenManager *tokenManager, endpoint *portainer.Endpoint, k8sClientFactory *cli.ClientFactory, dataStore portainer.DataStore) *agentTransport { transport := &agentTransport{ - signatureService: signatureService, baseTransport: newBaseTransport( &http.Transport{ TLSClientConfig: tlsConfig, }, tokenManager, endpoint, + k8sClientFactory, dataStore, ), + signatureService: signatureService, } return transport @@ -32,7 +34,7 @@ func NewAgentTransport(dataStore portainer.DataStore, signatureService portainer // 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.endpoint.ID) + token, err := transport.getRoundTripToken(request, transport.tokenManager) if err != nil { return nil, err } @@ -40,7 +42,10 @@ func (transport *agentTransport) RoundTrip(request *http.Request) (*http.Respons request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) if strings.HasPrefix(request.URL.Path, "/v2") { - decorateAgentRequest(request, transport.dataStore) + err := decorateAgentRequest(request, transport.dataStore) + if err != nil { + return nil, err + } } signature, err := transport.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) diff --git a/api/http/proxy/factory/kubernetes/edge_transport.go b/api/http/proxy/factory/kubernetes/edge_transport.go index 70ff737ff..5d7cc62e6 100644 --- a/api/http/proxy/factory/kubernetes/edge_transport.go +++ b/api/http/proxy/factory/kubernetes/edge_transport.go @@ -5,23 +5,27 @@ import ( "strings" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/kubernetes/cli" ) type edgeTransport struct { *baseTransport + signatureService portainer.DigitalSignatureService reverseTunnelService portainer.ReverseTunnelService } // NewAgentTransport 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, endpoint *portainer.Endpoint, tokenManager *tokenManager) *edgeTransport { +func NewEdgeTransport(dataStore portainer.DataStore, signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, endpoint *portainer.Endpoint, tokenManager *tokenManager, k8sClientFactory *cli.ClientFactory) *edgeTransport { transport := &edgeTransport{ - reverseTunnelService: reverseTunnelService, baseTransport: newBaseTransport( &http.Transport{}, tokenManager, endpoint, + k8sClientFactory, dataStore, ), + reverseTunnelService: reverseTunnelService, + signatureService: signatureService, } return transport @@ -29,7 +33,7 @@ func NewEdgeTransport(dataStore portainer.DataStore, reverseTunnelService portai // 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.endpoint.ID) + token, err := transport.getRoundTripToken(request, transport.tokenManager) if err != nil { return nil, err } @@ -37,9 +41,20 @@ func (transport *edgeTransport) RoundTrip(request *http.Request) (*http.Response request.Header.Set(portainer.PortainerAgentKubernetesSATokenHeader, token) if strings.HasPrefix(request.URL.Path, "/v2") { - decorateAgentRequest(request, transport.dataStore) + 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) + response, err := transport.baseTransport.RoundTrip(request) if err == nil { diff --git a/api/http/proxy/factory/kubernetes/local_transport.go b/api/http/proxy/factory/kubernetes/local_transport.go index 0378d71f2..916d1f6c1 100644 --- a/api/http/proxy/factory/kubernetes/local_transport.go +++ b/api/http/proxy/factory/kubernetes/local_transport.go @@ -1,11 +1,11 @@ package kubernetes import ( - "fmt" "net/http" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" + "github.com/portainer/portainer/api/kubernetes/cli" ) type localTransport struct { @@ -13,7 +13,7 @@ type localTransport struct { } // NewLocalTransport returns a new transport that can be used to send requests to the local Kubernetes API -func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, dataStore portainer.DataStore) (*localTransport, error) { +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 @@ -26,6 +26,7 @@ func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, }, tokenManager, endpoint, + k8sClientFactory, dataStore, ), } @@ -35,12 +36,10 @@ func NewLocalTransport(tokenManager *tokenManager, endpoint *portainer.Endpoint, // 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.endpoint.ID) + _, err := transport.prepareRoundTrip(request) if err != nil { return nil, err } - request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - return transport.baseTransport.RoundTrip(request) } diff --git a/api/http/proxy/factory/kubernetes/namespaces.go b/api/http/proxy/factory/kubernetes/namespaces.go index d5039455a..daa71749f 100644 --- a/api/http/proxy/factory/kubernetes/namespaces.go +++ b/api/http/proxy/factory/kubernetes/namespaces.go @@ -4,12 +4,47 @@ import ( "net/http" "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" ) -func (transport *baseTransport) deleteNamespaceRequest(request *http.Request, namespace string) (*http.Response, error) { +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) } - return transport.executeKubernetesRequest(request, true) + 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) } diff --git a/api/http/proxy/factory/kubernetes/secrets.go b/api/http/proxy/factory/kubernetes/secrets.go new file mode 100644 index 000000000..dc03007d9 --- /dev/null +++ b/api/http/proxy/factory/kubernetes/secrets.go @@ -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"] == nil || secret["type"].(string) != string(v1.SecretTypeDockerConfigJson) { + return false + } + + metadata := utils.GetJSONObject(secret, "metadata") + annotations := utils.GetJSONObject(metadata, "annotations") + _, ok := annotations[privateregistries.RegistryIDLabel] + + return ok +} diff --git a/api/http/proxy/factory/kubernetes/token.go b/api/http/proxy/factory/kubernetes/token.go index 1e13bd9df..cc1e0e4f7 100644 --- a/api/http/proxy/factory/kubernetes/token.go +++ b/api/http/proxy/factory/kubernetes/token.go @@ -41,7 +41,7 @@ func (manager *tokenManager) GetAdminServiceAccountToken() string { return manager.adminToken } -func (manager *tokenManager) GetUserServiceAccountToken(userID int) (string, error) { +func (manager *tokenManager) GetUserServiceAccountToken(userID int, endpointID portainer.EndpointID) (string, error) { manager.tokenCache.mutex.Lock() defer manager.tokenCache.mutex.Unlock() @@ -57,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 } diff --git a/api/http/proxy/factory/kubernetes/transport.go b/api/http/proxy/factory/kubernetes/transport.go index 3fa4b9f03..ae7036698 100644 --- a/api/http/proxy/factory/kubernetes/transport.go +++ b/api/http/proxy/factory/kubernetes/transport.go @@ -3,44 +3,55 @@ package kubernetes import ( "bytes" "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" ) type baseTransport struct { - httpTransport *http.Transport - tokenManager *tokenManager - endpoint *portainer.Endpoint - dataStore portainer.DataStore + httpTransport *http.Transport + tokenManager *tokenManager + endpoint *portainer.Endpoint + k8sClientFactory *cli.ClientFactory + dataStore portainer.DataStore } -func newBaseTransport(httpTransport *http.Transport, tokenManager *tokenManager, endpoint *portainer.Endpoint, dataStore portainer.DataStore) *baseTransport { +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, - dataStore: dataStore, + httpTransport: httpTransport, + tokenManager: tokenManager, + endpoint: endpoint, + k8sClientFactory: k8sClientFactory, + dataStore: dataStore, } } -func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) { +// #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, true) + return transport.executeKubernetesRequest(request) case strings.HasPrefix(requestPath, "/namespaces"): return transport.proxyNamespacedRequest(request, requestPath) default: - return transport.executeKubernetesRequest(request, true) + return transport.executeKubernetesRequest(request) } } @@ -55,26 +66,43 @@ func (transport *baseTransport) proxyNamespacedRequest(request *http.Request, fu } switch { + case strings.HasPrefix(requestPath, "secrets"): + return transport.proxySecretRequest(request, namespace, requestPath) case requestPath == "" && request.Method == "DELETE": - return transport.deleteNamespaceRequest(request, namespace) + return transport.proxyNamespaceDeleteOperation(request, namespace) default: - return transport.executeKubernetesRequest(request, true) + return transport.executeKubernetesRequest(request) } } -func (transport *baseTransport) executeKubernetesRequest(request *http.Request, shouldLog bool) (*http.Response, error) { - return transport.httpTransport.RoundTrip(request) +func (transport *baseTransport) executeKubernetesRequest(request *http.Request) (*http.Response, error) { + + resp, err := transport.httpTransport.RoundTrip(request) + + return resp, err } -var ( - namespaceRegex = regexp.MustCompile(`^/namespaces/([^/]*)$`) -) +// #endregion -func getRoundTripToken( - request *http.Request, - tokenManager *tokenManager, - endpointIdentifier portainer.EndpointID, -) (string, error) { +// #region ROUND TRIP + +func (transport *baseTransport) prepareRoundTrip(request *http.Request) (string, error) { + token, err := transport.getRoundTripToken(request, transport.tokenManager) + if err != nil { + return "", err + } + + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + return token, nil +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *baseTransport) RoundTrip(request *http.Request) (*http.Response, error) { + return transport.proxyKubernetesRequest(request) +} + +func (transport *baseTransport) getRoundTripToken(request *http.Request, tokenManager *tokenManager) (string, error) { tokenData, err := security.RetrieveTokenData(request) if err != nil { return "", err @@ -84,7 +112,7 @@ func getRoundTripToken( if tokenData.Role == portainer.AdministratorRole { 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 @@ -94,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 diff --git a/api/http/proxy/factory/responseutils/json.go b/api/http/proxy/factory/responseutils/json.go deleted file mode 100644 index 15af94c60..000000000 --- a/api/http/proxy/factory/responseutils/json.go +++ /dev/null @@ -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 -} diff --git a/api/http/proxy/factory/utils/json.go b/api/http/proxy/factory/utils/json.go new file mode 100644 index 000000000..23e7bc52f --- /dev/null +++ b/api/http/proxy/factory/utils/json.go @@ -0,0 +1,104 @@ +package utils + +import ( + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "mime" + + "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) { + // Note: contentType can look like: "application/json" or "application/json; charset=utf-8" + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return nil, err + } + + switch mediaType { + 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 { + // Note: contentType can look look like: "application/json" or "application/json; charset=utf-8" + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return err + } + + switch mediaType { + 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) +} diff --git a/api/http/proxy/factory/utils/request.go b/api/http/proxy/factory/utils/request.go new file mode 100644 index 000000000..92724b7fa --- /dev/null +++ b/api/http/proxy/factory/utils/request.go @@ -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) +} diff --git a/api/http/proxy/factory/responseutils/response.go b/api/http/proxy/factory/utils/response.go similarity index 62% rename from api/http/proxy/factory/responseutils/response.go rename to api/http/proxy/factory/utils/response.go index a32cd3252..dc73e5618 100644 --- a/api/http/proxy/factory/responseutils/response.go +++ b/api/http/proxy/factory/utils/response.go @@ -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") +} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 2013647d8..36951ccd7 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -17,7 +17,7 @@ import ( // TODO: contain code related to legacy extension management type ( - // Manager represents a service used to manage proxies to endpoints and extensions. + // Manager represents a service used to manage proxies to environments(endpoints) and extensions. Manager struct { proxyFactory *factory.ProxyFactory endpointProxies cmap.ConcurrentMap @@ -36,7 +36,7 @@ func NewManager(dataStore portainer.DataStore, signatureService portainer.Digita } } -// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. +// CreateAndRegisterEndpointProxy creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies. // It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { proxy, err := manager.proxyFactory.NewEndpointProxy(endpoint) @@ -48,7 +48,7 @@ func (manager *Manager) CreateAndRegisterEndpointProxy(endpoint *portainer.Endpo return proxy, nil } -// CreateComposeProxyServer creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. +// CreateComposeProxyServer creates a new HTTP reverse proxy based on environment(endpoint) properties and and adds it to the registered proxies. // It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. func (manager *Manager) CreateComposeProxyServer(endpoint *portainer.Endpoint) (*factory.ProxyServer, error) { return manager.proxyFactory.NewDockerComposeAgentProxy(endpoint) @@ -65,7 +65,7 @@ func (manager *Manager) GetEndpointProxy(endpoint *portainer.Endpoint) http.Hand } // DeleteEndpointProxy deletes the proxy associated to a key -// and cleans the k8s endpoint client cache. DeleteEndpointProxy +// and cleans the k8s environment(endpoint) client cache. DeleteEndpointProxy // is currently only called for edge connection clean up. func (manager *Manager) DeleteEndpointProxy(endpoint *portainer.Endpoint) { manager.endpointProxies.Remove(fmt.Sprint(endpoint.ID)) diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go index 2ba3e8743..5be89aad3 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -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 { @@ -91,32 +103,39 @@ func AuthorizedTeamManagement(teamID portainer.TeamID, context *RestrictedReques return false } -// authorizedEndpointAccess ensure that the user can access the specified endpoint. +// authorizedEndpointAccess ensure that the user can access the specified environment(endpoint). // 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. +// listed in the authorized teams of the environment(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 } -// authorizedEndpointGroupAccess ensure that the user can access the specified endpoint group. +// authorizedEndpointGroupAccess ensure that the user can access the specified environment(endpoint) group. // 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 environment(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 diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 5c57314af..d4c772284 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -36,16 +36,16 @@ func NewRequestBouncer(dataStore portainer.DataStore, jwtService portainer.JWTSe } } -// PublicAccess defines a security check for public API endpoints. -// No authentication is required to access these endpoints. +// PublicAccess defines a security check for public API environments(endpoints). +// No authentication is required to access these environments(endpoints). func (bouncer *RequestBouncer) PublicAccess(h http.Handler) http.Handler { h = mwSecureHeaders(h) return h } -// AdminAccess defines a security check for API endpoints that require an authorization check. -// Authentication is required to access these endpoints. -// The administrator role is required to use these endpoints. +// AdminAccess defines a security check for API environments(endpoints) that require an authorization check. +// Authentication is required to access these environments(endpoints). +// The administrator role is required to use these environments(endpoints). // The request context will be enhanced with a RestrictedRequestContext object // that might be used later to inside the API operation for extra authorization validation // and resource filtering. @@ -56,8 +56,8 @@ func (bouncer *RequestBouncer) AdminAccess(h http.Handler) http.Handler { return h } -// RestrictedAccess defines a security check for restricted API endpoints. -// Authentication is required to access these endpoints. +// RestrictedAccess defines a security check for restricted API environments(endpoints). +// Authentication is required to access these environments(endpoints). // The request context will be enhanced with a RestrictedRequestContext object // that might be used later to inside the API operation for extra authorization validation // and resource filtering. @@ -68,8 +68,8 @@ func (bouncer *RequestBouncer) RestrictedAccess(h http.Handler) http.Handler { return h } -// AuthenticatedAccess defines a security check for restricted API endpoints. -// Authentication is required to access these endpoints. +// AuthenticatedAccess defines a security check for restricted API environments(endpoints). +// Authentication is required to access these environments(endpoints). // The request context will be enhanced with a RestrictedRequestContext object // that might be used later to inside the API operation for extra authorization validation // and resource filtering. @@ -80,8 +80,8 @@ func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler } // AuthorizedEndpointOperation retrieves the JWT token from the request context and verifies -// that the user can access the specified endpoint. -// An error is returned when access to the endpoint is denied or if the user do not have the required +// that the user can access the specified environment(endpoint). +// An error is returned when access to the environments(endpoints) is denied or if the user do not have the required // authorization to execute the operation. func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { tokenData, err := RetrieveTokenData(r) @@ -110,10 +110,10 @@ func (bouncer *RequestBouncer) AuthorizedEndpointOperation(r *http.Request, endp return nil } -// AuthorizedEdgeEndpointOperation verifies that the request was received from a valid Edge endpoint +// AuthorizedEdgeEndpointOperation verifies that the request was received from a valid Edge environment(endpoint) func (bouncer *RequestBouncer) AuthorizedEdgeEndpointOperation(r *http.Request, endpoint *portainer.Endpoint) error { if endpoint.Type != portainer.EdgeAgentOnKubernetesEnvironment && endpoint.Type != portainer.EdgeAgentOnDockerEnvironment { - return errors.New("Invalid endpoint type") + return errors.New("Invalid environment type") } edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader) @@ -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. @@ -163,9 +138,9 @@ func (bouncer *RequestBouncer) mwAuthenticatedUser(h http.Handler) http.Handler } // mwCheckPortainerAuthorizations will verify that the user has the required authorization to access -// a specific API endpoint. +// a specific API environment(endpoint). // If the administratorOnly flag is specified, this will prevent non-admin -// users from accessing the endpoint. +// users from accessing the environment(endpoint). func (bouncer *RequestBouncer) mwCheckPortainerAuthorizations(next http.Handler, administratorOnly bool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenData, err := RetrieveTokenData(r) @@ -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)) }) } @@ -224,25 +199,14 @@ func (bouncer *RequestBouncer) mwUpgradeToRestrictedRequest(next http.Handler) h func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var tokenData *portainer.TokenData - var token string - // Optionally, token might be set via the "token" query parameter. - // For example, in websocket requests - token = r.URL.Query().Get("token") - - // Get token from the Authorization header - tokens, ok := r.Header["Authorization"] - if ok && len(tokens) >= 1 { - token = tokens[0] - token = strings.TrimPrefix(token, "Bearer ") - } - - if token == "" { - httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", httperrors.ErrUnauthorized) + // get token from the Authorization header or query parameter + token, err := ExtractBearerToken(r) + if err != nil { + httperror.WriteError(w, http.StatusUnauthorized, "Unauthorized", err) return } - var err error tokenData, err = bouncer.jwtService.ParseAndVerifyToken(token) if err != nil { httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", err) @@ -258,12 +222,28 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han return } - ctx := storeTokenData(r, tokenData) + ctx := StoreTokenData(r, tokenData) next.ServeHTTP(w, r.WithContext(ctx)) - return }) } +// ExtractBearerToken extracts the Bearer token from the request header or query parameter and returns the token. +func ExtractBearerToken(r *http.Request) (string, error) { + // Optionally, token might be set via the "token" query parameter. + // For example, in websocket requests + token := r.URL.Query().Get("token") + + tokens, ok := r.Header["Authorization"] + if ok && len(tokens) >= 1 { + token = tokens[0] + token = strings.TrimPrefix(token, "Bearer ") + } + if token == "" { + return "", httperrors.ErrUnauthorized + } + return token, nil +} + // mwSecureHeaders provides secure headers middleware for handlers. func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/api/http/security/context.go b/api/http/security/context.go index 8350fa56f..5e96b5b88 100644 --- a/api/http/security/context.go +++ b/api/http/security/context.go @@ -5,7 +5,7 @@ import ( "errors" "net/http" - "github.com/portainer/portainer/api" + portainer "github.com/portainer/portainer/api" ) type ( @@ -17,8 +17,8 @@ const ( contextRestrictedRequest ) -// storeTokenData stores a TokenData object inside the request context and returns the enhanced context. -func storeTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context { +// StoreTokenData stores a TokenData object inside the request context and returns the enhanced context. +func StoreTokenData(request *http.Request, tokenData *portainer.TokenData) context.Context { return context.WithValue(request.Context(), contextAuthenticationKey, tokenData) } @@ -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) } diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 1716b043e..f784827dd 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -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,23 +64,24 @@ 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) } } return filteredRegistries } -// FilterEndpoints filters endpoints based on user role and team memberships. -// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups). +// FilterEndpoints filters environments(endpoints) based on user role and team memberships. +// Non administrator users only have access to authorized environments(endpoints) (can be inherited via endoint groups). func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint { filteredEndpoints := endpoints @@ -99,8 +100,8 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint return filteredEndpoints } -// FilterEndpointGroups filters endpoint groups based on user role and team memberships. -// Non administrator users only have access to authorized endpoint groups. +// FilterEndpointGroups filters environment(endpoint) groups based on user role and team memberships. +// Non administrator users only have access to authorized environment(endpoint) groups. func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.EndpointGroup { filteredEndpointGroups := endpointGroups diff --git a/api/http/server.go b/api/http/server.go index decbe10aa..37df3cc78 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -2,12 +2,14 @@ package http import ( "context" + "crypto/tls" "fmt" "log" "net/http" "path/filepath" "time" + "github.com/portainer/libhelm" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/adminmonitor" "github.com/portainer/portainer/api/crypto" @@ -16,7 +18,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" @@ -26,11 +27,14 @@ import ( "github.com/portainer/portainer/api/http/handler/endpointproxy" "github.com/portainer/portainer/api/http/handler/endpoints" "github.com/portainer/portainer/api/http/handler/file" + "github.com/portainer/portainer/api/http/handler/helm" + kubehandler "github.com/portainer/portainer/api/http/handler/kubernetes" "github.com/portainer/portainer/api/http/handler/motd" "github.com/portainer/portainer/api/http/handler/registries" "github.com/portainer/portainer/api/http/handler/resourcecontrols" "github.com/portainer/portainer/api/http/handler/roles" "github.com/portainer/portainer/api/http/handler/settings" + sslhandler "github.com/portainer/portainer/api/http/handler/ssl" "github.com/portainer/portainer/api/http/handler/stacks" "github.com/portainer/portainer/api/http/handler/status" "github.com/portainer/portainer/api/http/handler/tags" @@ -46,13 +50,19 @@ import ( "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/ssl" + k8s "github.com/portainer/portainer/api/kubernetes" "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/scheduler" + stackdeployer "github.com/portainer/portainer/api/stacks" ) // Server implements the portainer.Server interface type Server struct { - AuthorizationService *authorization.Service + AuthorizationService *authorization.Service BindAddress string + BindAddressHTTPS string + HTTPEnabled bool AssetsPath string Status *portainer.Status ReverseTunnelService portainer.ReverseTunnelService @@ -69,15 +79,17 @@ type Server struct { SwarmStackManager portainer.SwarmStackManager ProxyManager *proxy.Manager KubernetesTokenCacheManager *kubernetes.TokenCacheManager + KubeConfigService k8s.KubeConfigService Handler *handler.Handler - SSL bool - SSLCert string - SSLKey string + SSLService *ssl.Service DockerClientFactory *docker.ClientFactory KubernetesClientFactory *cli.ClientFactory KubernetesDeployer portainer.KubernetesDeployer + HelmPackageManager libhelm.HelmPackageManager + Scheduler *scheduler.Scheduler ShutdownCtx context.Context ShutdownTrigger context.CancelFunc + StackDeployer stackdeployer.StackDeployer } // Start starts the HTTP server @@ -111,9 +123,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 @@ -126,6 +135,7 @@ func (server *Server) Start() error { edgeStacksHandler.DataStore = server.DataStore edgeStacksHandler.FileService = server.FileService edgeStacksHandler.GitService = server.GitService + edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer) edgeTemplatesHandler.DataStore = server.DataStore @@ -135,9 +145,12 @@ 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 + endpointHandler.BindAddress = server.BindAddress + endpointHandler.BindAddressHTTPS = server.BindAddressHTTPS var endpointEdgeHandler = endpointedge.NewHandler(requestBouncer) endpointEdgeHandler.DataStore = server.DataStore @@ -153,14 +166,22 @@ func (server *Server) Start() error { endpointProxyHandler.ProxyManager = server.ProxyManager endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService + var kubernetesHandler = kubehandler.NewHandler(requestBouncer, server.AuthorizationService, server.DataStore, server.KubernetesClientFactory) + kubernetesHandler.JwtService = server.JWTService + var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) + var endpointHelmHandler = helm.NewHandler(requestBouncer, server.DataStore, server.HelmPackageManager, server.KubeConfigService) + + var helmTemplatesHandler = helm.NewTemplateHandler(requestBouncer, server.HelmPackageManager) + var motdHandler = motd.NewHandler(requestBouncer) var registryHandler = registries.NewHandler(requestBouncer) 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 @@ -172,14 +193,19 @@ func (server *Server) Start() error { settingsHandler.LDAPService = server.LDAPService settingsHandler.SnapshotService = server.SnapshotService + var sslHandler = sslhandler.NewHandler(requestBouncer) + sslHandler.SSLService = server.SSLService + var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.DataStore = server.DataStore stackHandler.DockerClientFactory = server.DockerClientFactory stackHandler.FileService = server.FileService - stackHandler.SwarmStackManager = server.SwarmStackManager - stackHandler.ComposeStackManager = server.ComposeStackManager stackHandler.KubernetesDeployer = server.KubernetesDeployer stackHandler.GitService = server.GitService + stackHandler.Scheduler = server.Scheduler + stackHandler.SwarmStackManager = server.SwarmStackManager + stackHandler.ComposeStackManager = server.ComposeStackManager + stackHandler.StackDeployer = server.StackDeployer var tagHandler = tags.NewHandler(requestBouncer) tagHandler.DataStore = server.DataStore @@ -219,20 +245,23 @@ func (server *Server) Start() error { AuthHandler: authHandler, BackupHandler: backupHandler, CustomTemplatesHandler: customTemplatesHandler, - DockerHubHandler: dockerHubHandler, EdgeGroupsHandler: edgeGroupsHandler, EdgeJobsHandler: edgeJobsHandler, EdgeStacksHandler: edgeStacksHandler, EdgeTemplatesHandler: edgeTemplatesHandler, EndpointGroupHandler: endpointGroupHandler, EndpointHandler: endpointHandler, + EndpointHelmHandler: endpointHelmHandler, EndpointEdgeHandler: endpointEdgeHandler, EndpointProxyHandler: endpointProxyHandler, FileHandler: fileHandler, + HelmTemplatesHandler: helmTemplatesHandler, + KubernetesHandler: kubernetesHandler, MOTDHandler: motdHandler, RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, SettingsHandler: settingsHandler, + SSLHandler: sslHandler, StatusHandler: statusHandler, StackHandler: stackHandler, TagHandler: tagHandler, @@ -245,31 +274,48 @@ func (server *Server) Start() error { WebhookHandler: webhookHandler, } - httpServer := &http.Server{ - Addr: server.BindAddress, - Handler: server.Handler, - } - httpServer.Handler = offlineGate.WaitingMiddleware(time.Minute, httpServer.Handler) + handler := offlineGate.WaitingMiddleware(time.Minute, server.Handler) - if server.SSL { - httpServer.TLSConfig = crypto.CreateServerTLSConfiguration() - return httpServer.ListenAndServeTLS(server.SSLCert, server.SSLKey) + if server.HTTPEnabled { + go func() { + log.Printf("[INFO] [http,server] [message: starting HTTP server on port %s]", server.BindAddress) + httpServer := &http.Server{ + Addr: server.BindAddress, + Handler: handler, + } + + go shutdown(server.ShutdownCtx, httpServer) + err := httpServer.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Printf("[ERROR] [message: http server failed] [error: %s]", err) + } + }() } - go server.shutdown(httpServer) + log.Printf("[INFO] [http,server] [message: starting HTTPS server on port %s]", server.BindAddressHTTPS) + httpsServer := &http.Server{ + Addr: server.BindAddressHTTPS, + Handler: handler, + } - return httpServer.ListenAndServe() + httpsServer.TLSConfig = crypto.CreateServerTLSConfiguration() + httpsServer.TLSConfig.GetCertificate = func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + return server.SSLService.GetRawCertificate(), nil + } + + go shutdown(server.ShutdownCtx, httpsServer) + return httpsServer.ListenAndServeTLS("", "") } -func (server *Server) shutdown(httpServer *http.Server) { - <-server.ShutdownCtx.Done() +func shutdown(shutdownCtx context.Context, httpServer *http.Server) { + <-shutdownCtx.Done() - log.Println("[DEBUG] Shutting down http server") + log.Println("[DEBUG] [http,server] [message: shutting down http server]") shutdownTimeout, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() err := httpServer.Shutdown(shutdownTimeout) if err != nil { - fmt.Printf("Failed shutdown http server: %s \n", err) + fmt.Printf("[ERROR] [http,server] [message: failed shutdown http server] [error: %s]", err) } } diff --git a/api/internal/authorization/authorizations.go b/api/internal/authorization/authorizations.go index 805c2e38f..c2bfbe21a 100644 --- a/api/internal/authorization/authorizations.go +++ b/api/internal/authorization/authorizations.go @@ -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. @@ -19,8 +19,8 @@ func NewService(dataStore portainer.DataStore) *Service { } } -// DefaultEndpointAuthorizationsForEndpointAdministratorRole returns the default endpoint authorizations -// associated to the endpoint administrator role. +// DefaultEndpointAuthorizationsForEndpointAdministratorRole returns the default environment(endpoint) authorizations +// associated to the environment(endpoint) administrator role. func DefaultEndpointAuthorizationsForEndpointAdministratorRole() portainer.Authorizations { return map[portainer.Authorization]bool{ portainer.OperationDockerContainerArchiveInfo: true, @@ -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, @@ -156,7 +157,7 @@ func DefaultEndpointAuthorizationsForEndpointAdministratorRole() portainer.Autho } } -// DefaultEndpointAuthorizationsForHelpDeskRole returns the default endpoint authorizations +// DefaultEndpointAuthorizationsForHelpDeskRole returns the default environment(endpoint) authorizations // associated to the helpdesk role. func DefaultEndpointAuthorizationsForHelpDeskRole(volumeBrowsingAuthorizations bool) portainer.Authorizations { authorizations := map[portainer.Authorization]bool{ @@ -215,7 +216,7 @@ func DefaultEndpointAuthorizationsForHelpDeskRole(volumeBrowsingAuthorizations b return authorizations } -// DefaultEndpointAuthorizationsForStandardUserRole returns the default endpoint authorizations +// DefaultEndpointAuthorizationsForStandardUserRole returns the default environment(endpoint) authorizations // associated to the standard user role. func DefaultEndpointAuthorizationsForStandardUserRole(volumeBrowsingAuthorizations bool) portainer.Authorizations { authorizations := map[portainer.Authorization]bool{ @@ -349,7 +350,7 @@ func DefaultEndpointAuthorizationsForStandardUserRole(volumeBrowsingAuthorizatio return authorizations } -// DefaultEndpointAuthorizationsForReadOnlyUserRole returns the default endpoint authorizations +// DefaultEndpointAuthorizationsForReadOnlyUserRole returns the default environment(endpoint) authorizations // associated to the readonly user role. func DefaultEndpointAuthorizationsForReadOnlyUserRole(volumeBrowsingAuthorizations bool) portainer.Authorizations { authorizations := map[portainer.Authorization]bool{ diff --git a/api/internal/edge/edgegroup.go b/api/internal/edge/edgegroup.go index 0b0140acb..745f480ce 100644 --- a/api/internal/edge/edgegroup.go +++ b/api/internal/edge/edgegroup.go @@ -5,7 +5,7 @@ import ( "github.com/portainer/portainer/api/internal/tag" ) -// EdgeGroupRelatedEndpoints returns a list of endpoints related to this Edge group +// EdgeGroupRelatedEndpoints returns a list of environments(endpoints) related to this Edge group func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup) []portainer.EndpointID { if !edgeGroup.Dynamic { return edgeGroup.Endpoints @@ -33,7 +33,7 @@ func EdgeGroupRelatedEndpoints(edgeGroup *portainer.EdgeGroup, endpoints []porta return endpointIDs } -// edgeGroupRelatedToEndpoint returns true is edgeGroup is associated with endpoint +// edgeGroupRelatedToEndpoint returns true is edgeGroup is associated with environment(endpoint) func edgeGroupRelatedToEndpoint(edgeGroup *portainer.EdgeGroup, endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup) bool { if !edgeGroup.Dynamic { for _, endpointID := range edgeGroup.Endpoints { diff --git a/api/internal/edge/edgejob.go b/api/internal/edge/edgejob.go index bbd55b8e1..5d2633158 100644 --- a/api/internal/edge/edgejob.go +++ b/api/internal/edge/edgejob.go @@ -2,7 +2,7 @@ package edge import portainer "github.com/portainer/portainer/api" -// LoadEdgeJobs registers all edge jobs inside corresponding endpoint tunnel +// LoadEdgeJobs registers all edge jobs inside corresponding environment(endpoint) tunnel func LoadEdgeJobs(dataStore portainer.DataStore, reverseTunnelService portainer.ReverseTunnelService) error { edgeJobs, err := dataStore.EdgeJob().EdgeJobs() if err != nil { diff --git a/api/internal/edge/edgestack.go b/api/internal/edge/edgestack.go index 6f4094e9d..10633598a 100644 --- a/api/internal/edge/edgestack.go +++ b/api/internal/edge/edgestack.go @@ -5,7 +5,7 @@ import ( "github.com/portainer/portainer/api" ) -// EdgeStackRelatedEndpoints returns a list of endpoints related to this Edge stack +// EdgeStackRelatedEndpoints returns a list of environments(endpoints) related to this Edge stack func EdgeStackRelatedEndpoints(edgeGroupIDs []portainer.EdgeGroupID, endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup) ([]portainer.EndpointID, error) { edgeStackEndpoints := []portainer.EndpointID{} diff --git a/api/internal/edge/endpoint.go b/api/internal/edge/endpoint.go index 99d12bf60..b09fa5cca 100644 --- a/api/internal/edge/endpoint.go +++ b/api/internal/edge/endpoint.go @@ -2,7 +2,7 @@ package edge import "github.com/portainer/portainer/api" -// EndpointRelatedEdgeStacks returns a list of Edge stacks related to this Endpoint +// EndpointRelatedEdgeStacks returns a list of Edge stacks related to this Environment(Endpoint) func EndpointRelatedEdgeStacks(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, edgeGroups []portainer.EdgeGroup, edgeStacks []portainer.EdgeStack) []portainer.EdgeStackID { relatedEdgeGroupsSet := map[portainer.EdgeGroupID]bool{} diff --git a/api/internal/endpoint/endpoint.go b/api/internal/endpoint/endpoint.go deleted file mode 100644 index 378ca70e5..000000000 --- a/api/internal/endpoint/endpoint.go +++ /dev/null @@ -1,17 +0,0 @@ -package endpoint - -import portainer "github.com/portainer/portainer/api" - -// IsKubernetesEndpoint returns true if this is a kubernetes endpoint -func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool { - return endpoint.Type == portainer.KubernetesLocalEnvironment || - endpoint.Type == portainer.AgentOnKubernetesEnvironment || - endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment -} - -// IsDocketEndpoint returns true if this is a docker endpoint -func IsDocketEndpoint(endpoint *portainer.Endpoint) bool { - return endpoint.Type == portainer.DockerEnvironment || - endpoint.Type == portainer.AgentOnDockerEnvironment || - endpoint.Type == portainer.EdgeAgentOnDockerEnvironment -} diff --git a/api/internal/endpointutils/endpoint_test.go b/api/internal/endpointutils/endpoint_test.go new file mode 100644 index 000000000..35793b6e5 --- /dev/null +++ b/api/internal/endpointutils/endpoint_test.go @@ -0,0 +1,47 @@ +package endpointutils + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +type isEndpointTypeTest struct { + endpointType portainer.EndpointType + expected bool +} + +func Test_IsDockerEndpoint(t *testing.T) { + tests := []isEndpointTypeTest{ + {endpointType: portainer.DockerEnvironment, expected: true}, + {endpointType: portainer.AgentOnDockerEnvironment, expected: true}, + {endpointType: portainer.AzureEnvironment, expected: false}, + {endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: true}, + {endpointType: portainer.KubernetesLocalEnvironment, expected: false}, + {endpointType: portainer.AgentOnKubernetesEnvironment, expected: false}, + {endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: false}, + } + + for _, test := range tests { + ans := IsDockerEndpoint(&portainer.Endpoint{Type: test.endpointType}) + assert.Equal(t, test.expected, ans) + } +} + +func Test_IsKubernetesEndpoint(t *testing.T) { + tests := []isEndpointTypeTest{ + {endpointType: portainer.DockerEnvironment, expected: false}, + {endpointType: portainer.AgentOnDockerEnvironment, expected: false}, + {endpointType: portainer.AzureEnvironment, expected: false}, + {endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: false}, + {endpointType: portainer.KubernetesLocalEnvironment, expected: true}, + {endpointType: portainer.AgentOnKubernetesEnvironment, expected: true}, + {endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: true}, + } + + for _, test := range tests { + ans := IsKubernetesEndpoint(&portainer.Endpoint{Type: test.endpointType}) + assert.Equal(t, test.expected, ans) + } +} diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index 48c2c5fd1..2d6629f30 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -6,18 +6,19 @@ import ( portainer "github.com/portainer/portainer/api" ) +// IsLocalEndpoint returns true if this is a local environment(endpoint) func IsLocalEndpoint(endpoint *portainer.Endpoint) bool { return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5 } -// IsKubernetesEndpoint returns true if this is a kubernetes endpoint +// IsKubernetesEndpoint returns true if this is a kubernetes environment(endpoint) func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool { return endpoint.Type == portainer.KubernetesLocalEnvironment || endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment } -// IsDockerEndpoint returns true if this is a docker endpoint +// IsDockerEndpoint returns true if this is a docker environment(endpoint) func IsDockerEndpoint(endpoint *portainer.Endpoint) bool { return endpoint.Type == portainer.DockerEnvironment || endpoint.Type == portainer.AgentOnDockerEnvironment || diff --git a/api/internal/snapshot/snapshot.go b/api/internal/snapshot/snapshot.go index 31b17acda..dcfc197fd 100644 --- a/api/internal/snapshot/snapshot.go +++ b/api/internal/snapshot/snapshot.go @@ -2,15 +2,16 @@ package snapshot import ( "context" + "errors" "log" "time" portainer "github.com/portainer/portainer/api" ) -// Service repesents a service to manage endpoint snapshots. +// Service repesents a service to manage environment(endpoint) snapshots. // It provides an interface to start background snapshots as well as -// specific Docker/Kubernetes endpoint snapshot methods. +// specific Docker/Kubernetes environment(endpoint) snapshot methods. type Service struct { dataStore portainer.DataStore refreshSignal chan struct{} @@ -36,7 +37,7 @@ func NewService(snapshotInterval string, dataStore portainer.DataStore, dockerSn }, nil } -// Start will start a background routine to execute periodic snapshots of endpoints +// Start will start a background routine to execute periodic snapshots of environments(endpoints) func (service *Service) Start() { if service.refreshSignal != nil { return @@ -71,8 +72,8 @@ func (service *Service) SetSnapshotInterval(snapshotInterval string) error { return nil } -// SupportDirectSnapshot checks whether an endpoint can be used to trigger a direct a snapshot. -// It is mostly true for all endpoints except Edge and Azure endpoints. +// SupportDirectSnapshot checks whether an environment(endpoint) can be used to trigger a direct a snapshot. +// It is mostly true for all environments(endpoints) except Edge and Azure environments(endpoints). func SupportDirectSnapshot(endpoint *portainer.Endpoint) bool { switch endpoint.Type { case portainer.EdgeAgentOnDockerEnvironment, portainer.EdgeAgentOnKubernetesEnvironment, portainer.AzureEnvironment: @@ -81,8 +82,8 @@ func SupportDirectSnapshot(endpoint *portainer.Endpoint) bool { return true } -// SnapshotEndpoint will create a snapshot of the endpoint based on the endpoint type. -// If the snapshot is a success, it will be associated to the endpoint. +// SnapshotEndpoint will create a snapshot of the environment(endpoint) based on the environment(endpoint) type. +// If the snapshot is a success, it will be associated to the environment(endpoint). func (service *Service) SnapshotEndpoint(endpoint *portainer.Endpoint) error { switch endpoint.Type { case portainer.AzureEnvironment: @@ -125,7 +126,7 @@ func (service *Service) startSnapshotLoop() error { go func() { err := service.snapshotEndpoints() if err != nil { - log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (endpoint snapshot).] [error: %s]", err) + log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (environment snapshot).] [error: %s]", err) } for { @@ -133,7 +134,7 @@ func (service *Service) startSnapshotLoop() error { case <-ticker.C: err := service.snapshotEndpoints() if err != nil { - log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (endpoint snapshot).] [error: %s]", err) + log.Printf("[ERROR] [internal,snapshot] [message: background schedule error (environment snapshot).] [error: %s]", err) } case <-service.shutdownCtx.Done(): log.Println("[DEBUG] [internal,snapshot] [message: shutting down snapshotting]") @@ -165,13 +166,13 @@ func (service *Service) snapshotEndpoints() error { latestEndpointReference, err := service.dataStore.Endpoint().Endpoint(endpoint.ID) if latestEndpointReference == nil { - log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("background schedule error (environment snapshot). Environment not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) continue } latestEndpointReference.Status = portainer.EndpointStatusUp if snapshotError != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) + log.Printf("background schedule error (environment snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError) latestEndpointReference.Status = portainer.EndpointStatusDown } @@ -180,10 +181,34 @@ func (service *Service) snapshotEndpoints() error { err = service.dataStore.Endpoint().UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) if err != nil { - log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + log.Printf("background schedule error (environment snapshot). Unable to update environment (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) continue } } return nil } + +// FetchDockerID fetches info.Swarm.Cluster.ID if environment(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 environment is missing swarm info snapshot") + } + + swarmInfo := info["Swarm"].(map[string]interface{}) + if swarmInfo["Cluster"] == nil { + return "", errors.New("swarm environment is missing cluster info snapshot") + } + + clusterInfo := swarmInfo["Cluster"].(map[string]interface{}) + return clusterInfo["ID"].(string), nil +} diff --git a/api/internal/ssl/ssl.go b/api/internal/ssl/ssl.go new file mode 100644 index 000000000..7062858f2 --- /dev/null +++ b/api/internal/ssl/ssl.go @@ -0,0 +1,170 @@ +package ssl + +import ( + "context" + "crypto/tls" + "log" + "os" + "time" + + "github.com/pkg/errors" + "github.com/portainer/libcrypto" + portainer "github.com/portainer/portainer/api" +) + +// Service represents a service to manage SSL certificates +type Service struct { + fileService portainer.FileService + dataStore portainer.DataStore + rawCert *tls.Certificate + shutdownTrigger context.CancelFunc +} + +// NewService returns a pointer to a new Service +func NewService(fileService portainer.FileService, dataStore portainer.DataStore, shutdownTrigger context.CancelFunc) *Service { + return &Service{ + fileService: fileService, + dataStore: dataStore, + shutdownTrigger: shutdownTrigger, + } +} + +// Init initializes the service +func (service *Service) Init(host, certPath, keyPath string) error { + settings, err := service.GetSSLSettings() + if err != nil { + return errors.Wrap(err, "failed fetching ssl settings") + } + + // certificates already exist + if settings.CertPath != "" && settings.KeyPath != "" { + err := service.cacheCertificate(settings.CertPath, settings.KeyPath) + if err != nil && !os.IsNotExist(err) { + return err + } + + // continue if certs don't exist + if err == nil { + return nil + } + } + + pathSupplied := certPath != "" && keyPath != "" + if pathSupplied { + newCertPath, newKeyPath, err := service.fileService.CopySSLCertPair(certPath, keyPath) + if err != nil { + return errors.Wrap(err, "failed copying supplied certs") + } + + return service.cacheInfo(newCertPath, newKeyPath, false) + } + + // path not supplied and certificates doesn't exist - generate self signed + certPath, keyPath = service.fileService.GetDefaultSSLCertsPath() + + err = service.generateSelfSignedCertificates(host, certPath, keyPath) + if err != nil { + return errors.Wrap(err, "failed generating self signed certs") + } + + return service.cacheInfo(certPath, keyPath, true) + +} + +// GetRawCertificate gets the raw certificate +func (service *Service) GetRawCertificate() *tls.Certificate { + return service.rawCert +} + +// GetSSLSettings gets the certificate info +func (service *Service) GetSSLSettings() (*portainer.SSLSettings, error) { + return service.dataStore.SSLSettings().Settings() +} + +// SetCertificates sets the certificates +func (service *Service) SetCertificates(certData, keyData []byte) error { + if len(certData) == 0 || len(keyData) == 0 { + return errors.New("missing certificate files") + } + + _, err := tls.X509KeyPair(certData, keyData) + if err != nil { + return err + } + + certPath, keyPath, err := service.fileService.StoreSSLCertPair(certData, keyData) + if err != nil { + return err + } + + service.cacheInfo(certPath, keyPath, false) + + service.shutdownTrigger() + + return nil +} + +func (service *Service) SetHTTPEnabled(httpEnabled bool) error { + settings, err := service.dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + if settings.HTTPEnabled == httpEnabled { + return nil + } + + settings.HTTPEnabled = httpEnabled + + err = service.dataStore.SSLSettings().UpdateSettings(settings) + if err != nil { + return err + } + + service.shutdownTrigger() + + return nil +} + +func (service *Service) cacheCertificate(certPath, keyPath string) error { + rawCert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return err + } + + service.rawCert = &rawCert + + return nil +} + +func (service *Service) cacheInfo(certPath, keyPath string, selfSigned bool) error { + err := service.cacheCertificate(certPath, keyPath) + if err != nil { + return err + } + + settings, err := service.dataStore.SSLSettings().Settings() + if err != nil { + return err + } + + settings.CertPath = certPath + settings.KeyPath = keyPath + settings.SelfSigned = selfSigned + + err = service.dataStore.SSLSettings().UpdateSettings(settings) + if err != nil { + return err + } + + return nil +} + +func (service *Service) generateSelfSignedCertificates(ip, certPath, keyPath string) error { + if ip == "" { + return errors.New("host can't be empty") + } + + log.Printf("[INFO] [internal,ssl] [message: no cert files found, generating self signed ssl certificates]") + return libcrypto.GenerateCertsForHost("localhost", ip, certPath, keyPath, time.Now().AddDate(5, 0, 0)) +} diff --git a/api/internal/stackutils/stackutils.go b/api/internal/stackutils/stackutils.go index 5b1e9bf43..7e94bff17 100644 --- a/api/internal/stackutils/stackutils.go +++ b/api/internal/stackutils/stackutils.go @@ -2,6 +2,7 @@ package stackutils import ( "fmt" + "path" portainer "github.com/portainer/portainer/api" ) @@ -10,3 +11,12 @@ import ( func ResourceControlID(endpointID portainer.EndpointID, name string) string { return fmt.Sprintf("%d_%s", endpointID, name) } + +// GetStackFilePaths returns a list of file paths based on stack project path +func GetStackFilePaths(stack *portainer.Stack) []string { + var filePaths []string + for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { + filePaths = append(filePaths, path.Join(stack.ProjectPath, file)) + } + return filePaths +} diff --git a/api/internal/stackutils/stackutils_test.go b/api/internal/stackutils/stackutils_test.go new file mode 100644 index 000000000..6af19d8af --- /dev/null +++ b/api/internal/stackutils/stackutils_test.go @@ -0,0 +1,26 @@ +package stackutils + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" +) + +func Test_GetStackFilePaths(t *testing.T) { + stack := &portainer.Stack{ + ProjectPath: "/tmp/stack/1", + EntryPoint: "file-one.yml", + } + + t.Run("stack doesn't have additional files", func(t *testing.T) { + expected := []string{"/tmp/stack/1/file-one.yml"} + assert.ElementsMatch(t, expected, GetStackFilePaths(stack)) + }) + + t.Run("stack has additional files", func(t *testing.T) { + stack.AdditionalFiles = []string{"file-two.yml", "file-three.yml"} + expected := []string{"/tmp/stack/1/file-one.yml", "/tmp/stack/1/file-two.yml", "/tmp/stack/1/file-three.yml"} + assert.ElementsMatch(t, expected, GetStackFilePaths(stack)) + }) +} diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index a58da5c7c..f6d5786b1 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -4,29 +4,31 @@ import ( "io" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) type datastore struct { - dockerHub portainer.DockerHubService - customTemplate portainer.CustomTemplateService - edgeGroup portainer.EdgeGroupService - edgeJob portainer.EdgeJobService - edgeStack portainer.EdgeStackService - endpoint portainer.EndpointService - endpointGroup portainer.EndpointGroupService - endpointRelation portainer.EndpointRelationService - registry portainer.RegistryService - resourceControl portainer.ResourceControlService - role portainer.RoleService - settings portainer.SettingsService - stack portainer.StackService - tag portainer.TagService - teamMembership portainer.TeamMembershipService - team portainer.TeamService - tunnelServer portainer.TunnelServerService - user portainer.UserService - version portainer.VersionService - webhook portainer.WebhookService + customTemplate portainer.CustomTemplateService + edgeGroup portainer.EdgeGroupService + edgeJob portainer.EdgeJobService + edgeStack portainer.EdgeStackService + endpoint portainer.EndpointService + endpointGroup portainer.EndpointGroupService + endpointRelation portainer.EndpointRelationService + helmUserRepository portainer.HelmUserRepositoryService + registry portainer.RegistryService + resourceControl portainer.ResourceControlService + role portainer.RoleService + sslSettings portainer.SSLSettingsService + settings portainer.SettingsService + stack portainer.StackService + tag portainer.TagService + teamMembership portainer.TeamMembershipService + team portainer.TeamService + tunnelServer portainer.TunnelServerService + user portainer.UserService + version portainer.VersionService + webhook portainer.WebhookService } func (d *datastore) BackupTo(io.Writer) error { return nil } @@ -37,7 +39,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 } @@ -45,18 +46,22 @@ func (d *datastore) EdgeStack() portainer.EdgeStackService { retur func (d *datastore) Endpoint() portainer.EndpointService { return d.endpoint } func (d *datastore) EndpointGroup() portainer.EndpointGroupService { return d.endpointGroup } func (d *datastore) EndpointRelation() portainer.EndpointRelationService { return d.endpointRelation } -func (d *datastore) Registry() portainer.RegistryService { return d.registry } -func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } -func (d *datastore) Role() portainer.RoleService { return d.role } -func (d *datastore) Settings() portainer.SettingsService { return d.settings } -func (d *datastore) Stack() portainer.StackService { return d.stack } -func (d *datastore) Tag() portainer.TagService { return d.tag } -func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } -func (d *datastore) Team() portainer.TeamService { return d.team } -func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer } -func (d *datastore) User() portainer.UserService { return d.user } -func (d *datastore) Version() portainer.VersionService { return d.version } -func (d *datastore) Webhook() portainer.WebhookService { return d.webhook } +func (d *datastore) HelmUserRepository() portainer.HelmUserRepositoryService { + return d.helmUserRepository +} +func (d *datastore) Registry() portainer.RegistryService { return d.registry } +func (d *datastore) ResourceControl() portainer.ResourceControlService { return d.resourceControl } +func (d *datastore) Role() portainer.RoleService { return d.role } +func (d *datastore) Settings() portainer.SettingsService { return d.settings } +func (d *datastore) SSLSettings() portainer.SSLSettingsService { return d.sslSettings } +func (d *datastore) Stack() portainer.StackService { return d.stack } +func (d *datastore) Tag() portainer.TagService { return d.tag } +func (d *datastore) TeamMembership() portainer.TeamMembershipService { return d.teamMembership } +func (d *datastore) Team() portainer.TeamService { return d.team } +func (d *datastore) TunnelServer() portainer.TunnelServerService { return d.tunnelServer } +func (d *datastore) User() portainer.UserService { return d.user } +func (d *datastore) Version() portainer.VersionService { return d.version } +func (d *datastore) Webhook() portainer.WebhookService { return d.webhook } type datastoreOption = func(d *datastore) @@ -70,6 +75,25 @@ func NewDatastore(options ...datastoreOption) *datastore { return &d } +type stubSettingsService struct { + settings *portainer.Settings +} + +func (s *stubSettingsService) Settings() (*portainer.Settings, error) { + return s.settings, nil +} +func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error { + s.settings = settings + return nil +} +func WithSettingsService(settings *portainer.Settings) datastoreOption { + return func(d *datastore) { + d.settings = &stubSettingsService{ + settings: settings, + } + } +} + type stubUserService struct { users []portainer.User } @@ -112,3 +136,106 @@ func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption { d.edgeJob = &stubEdgeJobService{jobs: js} } } + +type stubEndpointRelationService struct { + relations []portainer.EndpointRelation +} + +func (s *stubEndpointRelationService) EndpointRelations() ([]portainer.EndpointRelation, error) { + return s.relations, nil +} +func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID) (*portainer.EndpointRelation, error) { + for _, relation := range s.relations { + if relation.EndpointID == ID { + return &relation, nil + } + } + + return nil, errors.ErrObjectNotFound +} +func (s *stubEndpointRelationService) CreateEndpointRelation(EndpointRelation *portainer.EndpointRelation) error { + return nil +} +func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error { + for i, r := range s.relations { + if r.EndpointID == ID { + s.relations[i] = *relation + } + } + + return nil +} +func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error { + return nil +} +func (s *stubEndpointRelationService) GetNextIdentifier() int { return 0 } + +// WithEndpointRelations option will instruct datastore to return provided jobs +func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOption { + return func(d *datastore) { + d.endpointRelation = &stubEndpointRelationService{relations: relations} + } +} + +type stubEndpointService struct { + endpoints []portainer.Endpoint +} + +func (s *stubEndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) { + for _, endpoint := range s.endpoints { + if endpoint.ID == ID { + return &endpoint, nil + } + } + + return nil, errors.ErrObjectNotFound +} + +func (s *stubEndpointService) Endpoints() ([]portainer.Endpoint, error) { + return s.endpoints, nil +} + +func (s *stubEndpointService) CreateEndpoint(endpoint *portainer.Endpoint) error { + s.endpoints = append(s.endpoints, *endpoint) + + return nil +} + +func (s *stubEndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error { + for i, e := range s.endpoints { + if e.ID == ID { + s.endpoints[i] = *endpoint + } + } + + return nil +} + +func (s *stubEndpointService) DeleteEndpoint(ID portainer.EndpointID) error { + endpoints := []portainer.Endpoint{} + + for _, endpoint := range s.endpoints { + if endpoint.ID != ID { + endpoints = append(endpoints, endpoint) + } + } + + s.endpoints = endpoints + + return nil +} + +func (s *stubEndpointService) Synchronize(toCreate []*portainer.Endpoint, toUpdate []*portainer.Endpoint, toDelete []*portainer.Endpoint) error { + panic("not implemented") +} + +func (s *stubEndpointService) GetNextIdentifier() int { + return len(s.endpoints) +} + +// WithEndpoints option will instruct datastore to return provided environments(endpoints) +func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption { + return func(d *datastore) { + d.endpoint = &stubEndpointService{endpoints: endpoints} + } +} diff --git a/api/internal/testhelpers/integration.go b/api/internal/testhelpers/integration.go new file mode 100644 index 000000000..f7ad19be4 --- /dev/null +++ b/api/internal/testhelpers/integration.go @@ -0,0 +1,22 @@ +package testhelpers + +import ( + "flag" + "os" + "testing" +) + +var integration bool + +func init() { + flag.BoolVar(&integration, "integration", false, "enable integration tests") +} + +// IntegrationTest marks the current test as an integration test +func IntegrationTest(t *testing.T) { + _, enabled := os.LookupEnv("INTEGRATION_TEST") + + if !(integration || enabled) { + t.Skip("Skipping integration test") + } +} diff --git a/api/internal/testhelpers/request_bouncer.go b/api/internal/testhelpers/request_bouncer.go new file mode 100644 index 000000000..39d7bab39 --- /dev/null +++ b/api/internal/testhelpers/request_bouncer.go @@ -0,0 +1,15 @@ +package testhelpers + +import "net/http" + +type testRequestBouncer struct { +} + +// NewTestRequestBouncer creates new mock for requestBouncer +func NewTestRequestBouncer() *testRequestBouncer { + return &testRequestBouncer{} +} + +func (testRequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler { + return h +} diff --git a/api/jwt/jwt.go b/api/jwt/jwt.go index 2caf0840a..5786caf85 100644 --- a/api/jwt/jwt.go +++ b/api/jwt/jwt.go @@ -16,6 +16,7 @@ import ( type Service struct { secret []byte userSessionTimeout time.Duration + dataStore portainer.DataStore } type claims struct { @@ -31,7 +32,7 @@ var ( ) // NewService initializes a new service. It will generate a random key that will be used to sign JWT tokens. -func NewService(userSessionDuration string) (*Service, error) { +func NewService(userSessionDuration string, dataStore portainer.DataStore) (*Service, error) { userSessionTimeout, err := time.ParseDuration(userSessionDuration) if err != nil { return nil, err @@ -45,19 +46,28 @@ func NewService(userSessionDuration string) (*Service, error) { service := &Service{ secret, userSessionTimeout, + dataStore, } return service, nil } +func (service *Service) defaultExpireAt() (int64) { + return time.Now().Add(service.userSessionTimeout).Unix() +} + // GenerateToken generates a new JWT token. func (service *Service) GenerateToken(data *portainer.TokenData) (string, error) { - return service.generateSignedToken(data, nil) + return service.generateSignedToken(data, service.defaultExpireAt()) } // GenerateTokenForOAuth generates a new JWT for OAuth login // token expiry time from the OAuth provider is considered func (service *Service) GenerateTokenForOAuth(data *portainer.TokenData, expiryTime *time.Time) (string, error) { - return service.generateSignedToken(data, expiryTime) + expireAt := service.defaultExpireAt() + if expiryTime != nil && !expiryTime.IsZero() { + expireAt = expiryTime.Unix() + } + return service.generateSignedToken(data, expireAt) } // ParseAndVerifyToken parses a JWT token and verify its validity. It returns an error if token is invalid. @@ -88,17 +98,13 @@ func (service *Service) SetUserSessionDuration(userSessionDuration time.Duration service.userSessionTimeout = userSessionDuration } -func (service *Service) generateSignedToken(data *portainer.TokenData, expiryTime *time.Time) (string, error) { - expireToken := time.Now().Add(service.userSessionTimeout).Unix() - if expiryTime != nil && !expiryTime.IsZero() { - expireToken = expiryTime.Unix() - } +func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt int64) (string, error) { cl := claims{ UserID: int(data.ID), Username: data.Username, Role: int(data.Role), StandardClaims: jwt.StandardClaims{ - ExpiresAt: expireToken, + ExpiresAt: expiresAt, }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, cl) diff --git a/api/jwt/jwt_kubeconfig.go b/api/jwt/jwt_kubeconfig.go new file mode 100644 index 000000000..544a481c1 --- /dev/null +++ b/api/jwt/jwt_kubeconfig.go @@ -0,0 +1,26 @@ +package jwt + +import ( + portainer "github.com/portainer/portainer/api" + "time" +) + +// GenerateTokenForKubeconfig generates a new JWT token for Kubeconfig +func (service *Service) GenerateTokenForKubeconfig(data *portainer.TokenData) (string, error) { + settings, err := service.dataStore.Settings().Settings() + if err != nil { + return "", err + } + + expiryDuration, err := time.ParseDuration(settings.KubeconfigExpiry) + if err != nil { + return "", err + } + + expiryAt := time.Now().Add(expiryDuration).Unix() + if expiryDuration == time.Duration(0) { + expiryAt = 0 + } + + return service.generateSignedToken(data, expiryAt) +} diff --git a/api/jwt/jwt_kubeconfig_test.go b/api/jwt/jwt_kubeconfig_test.go new file mode 100644 index 000000000..32289d0f3 --- /dev/null +++ b/api/jwt/jwt_kubeconfig_test.go @@ -0,0 +1,82 @@ +package jwt + +import ( + "testing" + + "github.com/dgrijalva/jwt-go" + portainer "github.com/portainer/portainer/api" + i "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/stretchr/testify/assert" +) + +func TestService_GenerateTokenForKubeconfig(t *testing.T) { + type fields struct { + userSessionTimeout string + dataStore portainer.DataStore + } + + type args struct { + data *portainer.TokenData + } + + mySettings := &portainer.Settings{ + KubeconfigExpiry: "0", + } + + myFields := fields{ + userSessionTimeout: "24h", + dataStore: i.NewDatastore(i.WithSettingsService(mySettings)), + } + + myTokenData := &portainer.TokenData{ + Username: "Joe", + ID: 1, + Role: 1, + } + + myArgs := args{ + data: myTokenData, + } + + tests := []struct { + name string + fields fields + args args + wantExpiresAt int64 + wantErr bool + }{ + { + name: "kubeconfig no expiry", + fields: myFields, + args: myArgs, + wantExpiresAt: 0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, err := NewService(tt.fields.userSessionTimeout, tt.fields.dataStore) + assert.NoError(t, err, "failed to create a copy of service") + + got, err := service.GenerateTokenForKubeconfig(tt.args.data) + if (err != nil) != tt.wantErr { + t.Errorf("GenerateTokenForKubeconfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + + parsedToken, err := jwt.ParseWithClaims(got, &claims{}, func(token *jwt.Token) (interface{}, error) { + return service.secret, nil + }) + assert.NoError(t, err, "failed to parse generated token") + + tokenClaims, ok := parsedToken.Claims.(*claims) + assert.Equal(t, true, ok, "failed to claims out of generated ticket") + + assert.Equal(t, myTokenData.Username, tokenClaims.Username) + assert.Equal(t, int(myTokenData.ID), tokenClaims.UserID) + assert.Equal(t, int(myTokenData.Role), tokenClaims.Role) + assert.Equal(t, tt.wantExpiresAt, tokenClaims.ExpiresAt) + }) + } +} diff --git a/api/jwt/jwt_test.go b/api/jwt/jwt_test.go index ce70f6308..2a18783e4 100644 --- a/api/jwt/jwt_test.go +++ b/api/jwt/jwt_test.go @@ -10,7 +10,7 @@ import ( ) func TestGenerateSignedToken(t *testing.T) { - svc, err := NewService("24h") + svc, err := NewService("24h", nil) assert.NoError(t, err, "failed to create a copy of service") token := &portainer.TokenData{ @@ -18,9 +18,9 @@ func TestGenerateSignedToken(t *testing.T) { ID: 1, Role: 1, } - expirtationTime := time.Now().Add(1 * time.Hour) + expiresAt := time.Now().Add(1 * time.Hour).Unix() - generatedToken, err := svc.generateSignedToken(token, &expirtationTime) + generatedToken, err := svc.generateSignedToken(token, expiresAt) assert.NoError(t, err, "failed to generate a signed token") parsedToken, err := jwt.ParseWithClaims(generatedToken, &claims{}, func(token *jwt.Token) (interface{}, error) { @@ -34,5 +34,5 @@ func TestGenerateSignedToken(t *testing.T) { assert.Equal(t, token.Username, tokenClaims.Username) assert.Equal(t, int(token.ID), tokenClaims.UserID) assert.Equal(t, int(token.Role), tokenClaims.Role) - assert.Equal(t, expirtationTime.Unix(), tokenClaims.ExpiresAt) + assert.Equal(t, expiresAt, tokenClaims.ExpiresAt) } diff --git a/api/kubernetes/cli/access.go b/api/kubernetes/cli/access.go index b6f5f47f7..1e0712e0e 100644 --- a/api/kubernetes/cli/access.go +++ b/api/kubernetes/cli/access.go @@ -9,22 +9,43 @@ import ( 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 } @@ -35,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 @@ -81,43 +98,6 @@ func hasUserAccessToNamespace(userID int, teamIDs []int, policies portainer.K8sN return false } -// NamespaceAccessPoliciesDeleteNamespace removes stored policies associated with a given namespace -func (kcl *KubeClient) NamespaceAccessPoliciesDeleteNamespace(ns string) error { - kcl.lock.Lock() - defer kcl.lock.Unlock() - - 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, 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) diff --git a/api/kubernetes/cli/client.go b/api/kubernetes/cli/client.go index 5b778837a..9dafe3e5a 100644 --- a/api/kubernetes/cli/client.go +++ b/api/kubernetes/cli/client.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "sync" + "time" cmap "github.com/orcaman/concurrent-map" @@ -18,6 +19,7 @@ import ( type ( // ClientFactory is used to create Kubernetes clients ClientFactory struct { + dataStore portainer.DataStore reverseTunnelService portainer.ReverseTunnelService signatureService portainer.DigitalSignatureService instanceID string @@ -33,8 +35,9 @@ type ( ) // NewClientFactory returns a new instance of a ClientFactory -func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string) *ClientFactory { +func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, instanceID string, dataStore portainer.DataStore) *ClientFactory { return &ClientFactory{ + dataStore: dataStore, signatureService: signatureService, reverseTunnelService: reverseTunnelService, instanceID: instanceID, @@ -47,7 +50,7 @@ func (factory *ClientFactory) RemoveKubeClient(endpoint *portainer.Endpoint) { factory.endpointClients.Remove(strconv.Itoa(int(endpoint.ID))) } -// GetKubeClient checks if an existing client is already registered for the endpoint and returns it if one is found. +// GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found. // If no client is registered, it will create a new client, register it, and returns it. func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (portainer.KubeClient, error) { key := strconv.Itoa(int(endpoint.ID)) @@ -91,7 +94,7 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kuber return factory.buildEdgeClient(endpoint) } - return nil, errors.New("unsupported endpoint type") + return nil, errors.New("unsupported environment type") } type agentHeaderRoundTripper struct { @@ -112,6 +115,40 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response, func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL) + + return factory.createRemoteClient(endpointURL); +} + +func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { + tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) + + if tunnel.Status == portainer.EdgeAgentIdle { + err := factory.reverseTunnelService.SetTunnelStatusToRequired(endpoint.ID) + if err != nil { + return nil, fmt.Errorf("failed opening tunnel to environment: %w", err) + } + + if endpoint.EdgeCheckinInterval == 0 { + settings, err := factory.dataStore.Settings().Settings() + if err != nil { + return nil, fmt.Errorf("failed fetching settings from db: %w", err) + } + + endpoint.EdgeCheckinInterval = settings.EdgeAgentCheckinInterval + } + + waitForAgentToConnect := time.Duration(endpoint.EdgeCheckinInterval) * time.Second + time.Sleep(waitForAgentToConnect * 2) + + tunnel = factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) + } + + endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port) + + return factory.createRemoteClient(endpointURL); +} + +func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) { signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) if err != nil { return nil, err @@ -134,19 +171,6 @@ func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*k return kubernetes.NewForConfig(config) } -func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) { - tunnel := factory.reverseTunnelService.GetTunnelDetails(endpoint.ID) - endpointURL := fmt.Sprintf("http://localhost:%d/kubernetes", tunnel.Port) - - config, err := clientcmd.BuildConfigFromFlags(endpointURL, "") - if err != nil { - return nil, err - } - config.Insecure = true - - return kubernetes.NewForConfig(config) -} - func buildLocalClient() (*kubernetes.Clientset, error) { config, err := rest.InClusterConfig() if err != nil { diff --git a/api/kubernetes/cli/exec.go b/api/kubernetes/cli/exec.go index 55cc38bc9..394acb09f 100644 --- a/api/kubernetes/cli/exec.go +++ b/api/kubernetes/cli/exec.go @@ -4,7 +4,7 @@ import ( "errors" "io" - "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/tools/remotecommand" @@ -14,11 +14,18 @@ import ( // StartExecProcess will start an exec process inside a container located inside a pod inside a specific namespace // using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write // to the stdout parameter. -// This function only works against a local endpoint using an in-cluster config with the user's SA token. -func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error { +// This function only works against a local environment(endpoint) using an in-cluster config with the user's SA token. +// This is a blocking operation. +func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) { config, err := rest.InClusterConfig() if err != nil { - return err + errChan <- err + return + } + + if !useAdminToken { + config.BearerToken = token + config.BearerTokenFile = "" } if !useAdminToken { @@ -44,7 +51,8 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL()) if err != nil { - return err + errChan <- err + return } err = exec.Stream(remotecommand.StreamOptions{ @@ -54,9 +62,7 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp }) if err != nil { if _, ok := err.(utilexec.ExitError); !ok { - return errors.New("unable to start exec process") + errChan <- errors.New("unable to start exec process") } } - - return nil } diff --git a/api/kubernetes/cli/kubeconfig.go b/api/kubernetes/cli/kubeconfig.go new file mode 100644 index 000000000..c4ca71e28 --- /dev/null +++ b/api/kubernetes/cli/kubeconfig.go @@ -0,0 +1,66 @@ +package cli + +import ( + "context" + "fmt" + + portainer "github.com/portainer/portainer/api" + clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" +) + +// GetKubeConfig returns kubeconfig for the current user based on: +// - portainer server url +// - portainer user bearer token +// - portainer token data - which maps to k8s service account +func (kcl *KubeClient) GetKubeConfig(ctx context.Context, apiServerURL string, bearerToken string, tokenData *portainer.TokenData) (*clientV1.Config, error) { + serviceAccount, err := kcl.GetServiceAccount(tokenData) + if err != nil { + errText := fmt.Sprintf("unable to find serviceaccount associated with user; username=%s", tokenData.Username) + return nil, fmt.Errorf("%s; err=%w", errText, err) + } + + kubeconfig := generateKubeconfig(apiServerURL, bearerToken, serviceAccount.Name) + + return kubeconfig, nil +} + +// generateKubeconfig will generate and return kubeconfig resource - usable by `kubectl` cli +// which will allow the client to connect directly to k8s server environment(endpoint) via portainer (proxy) +func generateKubeconfig(apiServerURL, bearerToken, serviceAccountName string) *clientV1.Config { + const ( + KubeConfigPortainerContext = "portainer-ctx" + KubeConfigPortainerCluster = "portainer-cluster" + ) + + return &clientV1.Config{ + APIVersion: "v1", + Kind: "Config", + CurrentContext: KubeConfigPortainerContext, + Contexts: []clientV1.NamedContext{ + { + Name: KubeConfigPortainerContext, + Context: clientV1.Context{ + AuthInfo: serviceAccountName, + Cluster: KubeConfigPortainerCluster, + }, + }, + }, + Clusters: []clientV1.NamedCluster{ + { + Name: KubeConfigPortainerCluster, + Cluster: clientV1.Cluster{ + Server: apiServerURL, + InsecureSkipTLSVerify: true, + }, + }, + }, + AuthInfos: []clientV1.NamedAuthInfo{ + { + Name: serviceAccountName, + AuthInfo: clientV1.AuthInfo{ + Token: bearerToken, + }, + }, + }, + } +} diff --git a/api/kubernetes/cli/kubeconfig_test.go b/api/kubernetes/cli/kubeconfig_test.go new file mode 100644 index 000000000..ed7649d8e --- /dev/null +++ b/api/kubernetes/cli/kubeconfig_test.go @@ -0,0 +1,150 @@ +package cli + +import ( + "context" + "errors" + "testing" + + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_GetKubeConfig(t *testing.T) { + + t.Run("returns error if SA non-existent", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.AdministratorRole, + Username: portainerClusterAdminServiceAccountName, + } + + _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData) + + if err == nil { + t.Error("GetKubeConfig should fail as service account does not exist") + } + if k8sErr := errors.Unwrap(err); !k8serrors.IsNotFound(k8sErr) { + t.Error("GetKubeConfig should fail with service account not found k8s error") + } + }) + + t.Run("successfully obtains kubeconfig for cluster admin", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + Role: portainer.AdministratorRole, + Username: portainerClusterAdminServiceAccountName, + } + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: tokenData.Username}, + } + + k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount) + defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil) + + _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData) + + if err != nil { + t.Errorf("GetKubeConfig should succeed; err=%s", err) + } + }) + + t.Run("successfully obtains kubeconfig for standard user", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.StandardUserRole, + } + nonAdminUserName := userServiceAccountName(int(tokenData.ID), k.instanceID) + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{Name: nonAdminUserName}, + } + + k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount) + defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil) + + _, err := k.GetKubeConfig(context.Background(), "localhost", "abc", tokenData) + + if err != nil { + t.Errorf("GetKubeConfig should succeed; err=%s", err) + } + }) +} + +func Test_generateKubeconfig(t *testing.T) { + apiServerURL, bearerToken, serviceAccountName := "localhost", "test-token", "test-user" + + t.Run("generates Config resource kind", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + want := "Config" + if config.Kind != want { + t.Errorf("generateKubeconfig resource kind should be %s", want) + } + }) + + t.Run("generates v1 version", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + want := "v1" + if config.APIVersion != want { + t.Errorf("generateKubeconfig api version should be %s", want) + } + }) + + t.Run("generates single entry context cluster and authinfo", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + if len(config.Contexts) != 1 { + t.Error("generateKubeconfig should generate single context configuration") + } + if len(config.Clusters) != 1 { + t.Error("generateKubeconfig should generate single cluster configuration") + } + if len(config.AuthInfos) != 1 { + t.Error("generateKubeconfig should generate single user configuration") + } + }) + + t.Run("sets default context appropriately", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + want := "portainer-ctx" + if config.CurrentContext != want { + t.Errorf("generateKubeconfig set cluster to be %s", want) + } + }) + + t.Run("generates cluster with InsecureSkipTLSVerify to be set to true", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + if config.Clusters[0].Cluster.InsecureSkipTLSVerify != true { + t.Error("generateKubeconfig default cluster InsecureSkipTLSVerify should be true") + } + }) + + t.Run("should contain passed in value", func(t *testing.T) { + config := generateKubeconfig(apiServerURL, bearerToken, serviceAccountName) + if config.Clusters[0].Cluster.Server != apiServerURL { + t.Errorf("generateKubeconfig default cluster server url should be %s", apiServerURL) + } + + if config.AuthInfos[0].Name != serviceAccountName { + t.Errorf("generateKubeconfig default authinfo name should be %s", serviceAccountName) + } + + if config.AuthInfos[0].AuthInfo.Token != bearerToken { + t.Errorf("generateKubeconfig default authinfo user token should be %s", bearerToken) + } + }) +} diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go new file mode 100644 index 000000000..7ba20683d --- /dev/null +++ b/api/kubernetes/cli/namespace.go @@ -0,0 +1,73 @@ +package cli + +import ( + "strconv" + + "github.com/pkg/errors" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + systemNamespaceLabel = "io.portainer.kubernetes.namespace.system" +) + +func defaultSystemNamespaces() map[string]struct{} { + return map[string]struct{}{ + "kube-system": {}, + "kube-public": {}, + "kube-node-lease": {}, + "portainer": {}, + } +} + +func isSystemNamespace(namespace v1.Namespace) bool { + systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel] + if hasSystemLabel { + return systemLabelValue == "true" + } + + systemNamespaces := defaultSystemNamespaces() + + _, isSystem := systemNamespaces[namespace.Name] + + return isSystem +} + +// ToggleSystemState will set a namespace as a system namespace, or remove this state +// if isSystem is true it will set `systemNamespaceLabel` to "true" and false otherwise +// this will skip if namespace is "default" or if the required state is already set +func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) error { + if namespaceName == "default" { + return nil + } + + nsService := kcl.cli.CoreV1().Namespaces() + + namespace, err := nsService.Get(namespaceName, metav1.GetOptions{}) + if err != nil { + return errors.Wrap(err, "failed fetching namespace object") + } + + if isSystemNamespace(*namespace) == isSystem { + return nil + } + + if namespace.Labels == nil { + namespace.Labels = map[string]string{} + } + + namespace.Labels[systemNamespaceLabel] = strconv.FormatBool(isSystem) + + _, err = nsService.Update(namespace) + if err != nil { + return errors.Wrap(err, "failed updating namespace object") + } + + if isSystem { + return kcl.NamespaceAccessPoliciesDeleteNamespace(namespaceName) + } + + return nil + +} diff --git a/api/kubernetes/cli/namespace_test.go b/api/kubernetes/cli/namespace_test.go new file mode 100644 index 000000000..207373141 --- /dev/null +++ b/api/kubernetes/cli/namespace_test.go @@ -0,0 +1,185 @@ +package cli + +import ( + "strconv" + "sync" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/stretchr/testify/assert" + + core "k8s.io/api/core/v1" + ktypes "k8s.io/api/core/v1" + meta "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_ToggleSystemState(t *testing.T) { + t.Run("should skip is default (exit without error)", func(t *testing.T) { + nsName := "default" + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}), + instanceID: "instance", + lock: &sync.Mutex{}, + } + + err := kcl.ToggleSystemState(nsName, true) + assert.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{}) + assert.NoError(t, err) + + _, exists := ns.Labels[systemNamespaceLabel] + assert.False(t, exists, "system label should not exists") + }) + + t.Run("should fail if namespace doesn't exist", func(t *testing.T) { + nsName := "not-exist" + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "instance", + lock: &sync.Mutex{}, + } + + err := kcl.ToggleSystemState(nsName, true) + assert.Error(t, err) + + }) + + t.Run("if called with the same state, should skip (exit without error)", func(t *testing.T) { + nsName := "namespace" + tests := []struct { + isSystem bool + }{ + {isSystem: true}, + {isSystem: false}, + } + + for _, test := range tests { + t.Run(strconv.FormatBool(test.isSystem), func(t *testing.T) { + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{ + systemNamespaceLabel: strconv.FormatBool(test.isSystem), + }}}), + instanceID: "instance", + lock: &sync.Mutex{}, + } + + err := kcl.ToggleSystemState(nsName, test.isSystem) + assert.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{}) + assert.NoError(t, err) + + assert.Equal(t, test.isSystem, isSystemNamespace(*ns)) + }) + } + }) + + t.Run("for regular namespace if isSystem is true and doesn't have a label, should set the label to true", func(t *testing.T) { + nsName := "namespace" + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}), + instanceID: "instance", + lock: &sync.Mutex{}, + } + + err := kcl.ToggleSystemState(nsName, true) + assert.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{}) + assert.NoError(t, err) + + labelValue, exists := ns.Labels[systemNamespaceLabel] + assert.True(t, exists, "system label should exists") + + assert.Equal(t, "true", labelValue) + }) + + t.Run("for default system namespace if isSystem is false and doesn't have a label, should set the label to false", func(t *testing.T) { + nsName := "portainer" + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName}}), + instanceID: "instance", + lock: &sync.Mutex{}, + } + + err := kcl.ToggleSystemState(nsName, false) + assert.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{}) + assert.NoError(t, err) + + labelValue, exists := ns.Labels[systemNamespaceLabel] + assert.True(t, exists, "system label should exists") + + assert.Equal(t, "false", labelValue) + }) + + t.Run("for system namespace (with label), if called with false, should set the label", func(t *testing.T) { + nsName := "namespace" + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(&core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{ + systemNamespaceLabel: "true", + }}}), + instanceID: "instance", + lock: &sync.Mutex{}, + } + + err := kcl.ToggleSystemState(nsName, false) + assert.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{}) + assert.NoError(t, err) + + labelValue, exists := ns.Labels[systemNamespaceLabel] + assert.True(t, exists, "system label should exists") + assert.Equal(t, "false", labelValue) + }) + + t.Run("for non system namespace (with label), if called with true, should set the label, and remove accesses", func(t *testing.T) { + nsName := "ns1" + + namespace := &core.Namespace{ObjectMeta: meta.ObjectMeta{Name: nsName, Labels: map[string]string{ + systemNamespaceLabel: "false", + }}} + + 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}}}}`, + }, + } + + kcl := &KubeClient{ + cli: kfake.NewSimpleClientset(namespace, config), + instanceID: "instance", + lock: &sync.Mutex{}, + } + + err := kcl.ToggleSystemState(nsName, true) + assert.NoError(t, err) + + ns, err := kcl.cli.CoreV1().Namespaces().Get(nsName, meta.GetOptions{}) + assert.NoError(t, err) + + labelValue, exists := ns.Labels[systemNamespaceLabel] + assert.True(t, exists, "system label should exists") + assert.Equal(t, "true", labelValue) + + expectedPolicies := map[string]portainer.K8sNamespaceAccessPolicy{ + "ns2": {UserAccessPolicies: portainer.UserAccessPolicies{2: {RoleID: 0}}}, + } + actualPolicies, err := kcl.GetNamespaceAccessPolicies() + assert.NoError(t, err, "failed to fetch policies") + assert.Equal(t, expectedPolicies, actualPolicies) + + }) +} diff --git a/api/kubernetes/cli/naming.go b/api/kubernetes/cli/naming.go index 9297ff6be..5bff9b076 100644 --- a/api/kubernetes/cli/naming.go +++ b/api/kubernetes/cli/naming.go @@ -1,16 +1,20 @@ package cli -import "fmt" +import ( + "fmt" +) const ( - defaultNamespace = "default" - portainerNamespace = "portainer" - portainerUserCRName = "portainer-cr-user" - portainerUserCRBName = "portainer-crb-user" - portainerUserServiceAccountPrefix = "portainer-sa-user" - portainerRBPrefix = "portainer-rb" - portainerConfigMapName = "portainer-config" - portainerConfigMapAccessPoliciesKey = "NamespaceAccessPolicies" + defaultNamespace = "default" + portainerNamespace = "portainer" + portainerUserCRName = "portainer-cr-user" + portainerUserCRBName = "portainer-crb-user" + portainerClusterAdminServiceAccountName = "portainer-sa-clusteradmin" + portainerUserServiceAccountPrefix = "portainer-sa-user" + portainerRBPrefix = "portainer-rb" + portainerConfigMapName = "portainer-config" + portainerConfigMapAccessPoliciesKey = "NamespaceAccessPolicies" + portainerShellPodPrefix = "portainer-pod-kubectl-shell" ) func userServiceAccountName(userID int, instanceID string) string { @@ -24,3 +28,7 @@ func userServiceAccountTokenSecretName(serviceAccountName string, instanceID str func namespaceClusterRoleBindingName(namespace string, instanceID string) string { return fmt.Sprintf("%s-%s-%s", portainerRBPrefix, instanceID, namespace) } + +func userShellPodPrefix(serviceAccountName string) string { + return fmt.Sprintf("%s-%s-", portainerShellPodPrefix, serviceAccountName) +} diff --git a/api/kubernetes/cli/nodes_limits.go b/api/kubernetes/cli/nodes_limits.go new file mode 100644 index 000000000..ab66f6a37 --- /dev/null +++ b/api/kubernetes/cli/nodes_limits.go @@ -0,0 +1,42 @@ +package cli + +import ( + portainer "github.com/portainer/portainer/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// GetNodesLimits gets the CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection +func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) { + nodesLimits := make(portainer.K8sNodesLimits) + + nodes, err := kcl.cli.CoreV1().Nodes().List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + pods, err := kcl.cli.CoreV1().Pods("").List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, item := range nodes.Items { + cpu := item.Status.Allocatable.Cpu().MilliValue() + memory := item.Status.Allocatable.Memory().Value() + + nodesLimits[item.ObjectMeta.Name] = &portainer.K8sNodeLimits{ + CPU: cpu, + Memory: memory, + } + } + + for _, item := range pods.Items { + if nodeLimits, ok := nodesLimits[item.Spec.NodeName]; ok { + for _, container := range item.Spec.Containers { + nodeLimits.CPU -= container.Resources.Requests.Cpu().MilliValue() + nodeLimits.Memory -= container.Resources.Requests.Memory().Value() + } + } + } + + return nodesLimits, nil +} diff --git a/api/kubernetes/cli/nodes_limits_test.go b/api/kubernetes/cli/nodes_limits_test.go new file mode 100644 index 000000000..bf880c2ff --- /dev/null +++ b/api/kubernetes/cli/nodes_limits_test.go @@ -0,0 +1,137 @@ +package cli + +import ( + portainer "github.com/portainer/portainer/api" + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + kfake "k8s.io/client-go/kubernetes/fake" + "reflect" + "testing" +) + +func newNodes() *v1.NodeList { + return &v1.NodeList{ + Items: []v1.Node{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-0", + }, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"), + v1.ResourceName(v1.ResourceMemory): resource.MustParse("4M"), + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-node-1", + }, + Status: v1.NodeStatus{ + Allocatable: v1.ResourceList{ + v1.ResourceName(v1.ResourceCPU): resource.MustParse("3"), + v1.ResourceName(v1.ResourceMemory): resource.MustParse("6M"), + }, + }, + }, + }, + } +} + +func newPods() *v1.PodList { + return &v1.PodList{ + Items: []v1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-container-0", + Namespace: "test-namespace-0", + }, + Spec: v1.PodSpec{ + NodeName: "test-node-0", + Containers: []v1.Container{ + { + Name: "test-container-0", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceName(v1.ResourceCPU): resource.MustParse("1"), + v1.ResourceName(v1.ResourceMemory): resource.MustParse("2M"), + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-container-1", + Namespace: "test-namespace-1", + }, + Spec: v1.PodSpec{ + NodeName: "test-node-1", + Containers: []v1.Container{ + { + Name: "test-container-1", + Resources: v1.ResourceRequirements{ + Requests: v1.ResourceList{ + v1.ResourceName(v1.ResourceCPU): resource.MustParse("2"), + v1.ResourceName(v1.ResourceMemory): resource.MustParse("3M"), + }, + }, + }, + }, + }, + }, + }, + } +} + +func TestKubeClient_GetNodesLimits(t *testing.T) { + type fields struct { + cli kubernetes.Interface + } + + fieldsInstance := fields{ + cli: kfake.NewSimpleClientset(newNodes(), newPods()), + } + + tests := []struct { + name string + fields fields + want portainer.K8sNodesLimits + wantErr bool + }{ + { + name: "2 nodes 2 pods", + fields: fieldsInstance, + want: portainer.K8sNodesLimits{ + "test-node-0": &portainer.K8sNodeLimits{ + CPU: 1000, + Memory: 2000000, + }, + "test-node-1": &portainer.K8sNodeLimits{ + CPU: 1000, + Memory: 3000000, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kcl := &KubeClient{ + cli: tt.fields.cli, + } + got, err := kcl.GetNodesLimits() + if (err != nil) != tt.wantErr { + t.Errorf("GetNodesLimits() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("GetNodesLimits() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/api/kubernetes/cli/pod.go b/api/kubernetes/cli/pod.go new file mode 100644 index 000000000..43d66434c --- /dev/null +++ b/api/kubernetes/cli/pod.go @@ -0,0 +1,118 @@ +package cli + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const shellPodImage = "portainer/kubectl-shell" + +// CreateUserShellPod will create a kubectl based shell for the specified user by mounting their respective service account. +// The lifecycle of the pod is managed in this function; this entails management of the following pod operations: +// - The shell pod will be scoped to specified service accounts access permissions +// - The shell pod will be automatically removed if it's not ready after specified period of time +// - The shell pod will be automatically removed after a specified max life (prevent zombie pods) +// - The shell pod will be automatically removed if request is cancelled (or client closes websocket connection) +func (kcl *KubeClient) CreateUserShellPod(ctx context.Context, serviceAccountName string) (*portainer.KubernetesShellPod, error) { + maxPodKeepAliveSecondsStr := fmt.Sprintf("%d", int(portainer.WebSocketKeepAlive.Seconds())) + + podPrefix := userShellPodPrefix(serviceAccountName) + + podSpec := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: podPrefix, + Namespace: portainerNamespace, + Annotations: map[string]string{ + "kubernetes.io/pod.type": "kubectl-shell", + }, + }, + Spec: v1.PodSpec{ + TerminationGracePeriodSeconds: new(int64), + ServiceAccountName: serviceAccountName, + Containers: []v1.Container{ + { + Name: "kubectl-shell-container", + Image: shellPodImage, + Command: []string{"sleep"}, + // Specify sleep time to prevent zombie pods in case portainer process is terminated + Args: []string{maxPodKeepAliveSecondsStr}, + ImagePullPolicy: v1.PullIfNotPresent, + }, + }, + RestartPolicy: v1.RestartPolicyNever, + }, + } + + shellPod, err := kcl.cli.CoreV1().Pods(portainerNamespace).Create(podSpec) + if err != nil { + return nil, errors.Wrap(err, "error creating shell pod") + } + + // Wait for pod to reach ready state + timeoutCtx, cancelFunc := context.WithTimeout(ctx, 20*time.Second) + defer cancelFunc() + err = kcl.waitForPodStatus(timeoutCtx, v1.PodRunning, shellPod) + if err != nil { + kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil) + return nil, errors.Wrap(err, "aborting pod creation; error waiting for shell pod ready status") + } + + if len(shellPod.Spec.Containers) != 1 { + kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil) + return nil, fmt.Errorf("incorrect shell pod state, expecting single container to be present") + } + + podData := &portainer.KubernetesShellPod{ + Namespace: shellPod.Namespace, + PodName: shellPod.Name, + ContainerName: shellPod.Spec.Containers[0].Name, + ShellExecCommand: "env COLUMNS=200 /bin/bash", // env COLUMNS dictates minimum width of the shell + } + + // Handle pod lifecycle/cleanup - terminate pod after maxPodKeepAlive or upon request (long-lived) cancellation + go func() { + select { + case <-time.After(portainer.WebSocketKeepAlive): + log.Println("[DEBUG] [internal,kubernetes/pod] [message: pod removal schedule duration exceeded]") + kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil) + case <-ctx.Done(): + err := ctx.Err() + log.Printf("[DEBUG] [internal,kubernetes/pod] [message: context error: err=%s ]\n", err) + kcl.cli.CoreV1().Pods(portainerNamespace).Delete(shellPod.Name, nil) + } + }() + + return podData, nil +} + +// waitForPodStatus will wait until duration d (from now) for a pod to reach defined phase/status. +// The pod status will be polled at specified delay until the pod reaches ready state. +func (kcl *KubeClient) waitForPodStatus(ctx context.Context, phase v1.PodPhase, pod *v1.Pod) error { + log.Printf("[DEBUG] [internal,kubernetes/pod] [message: waiting for pod ready: pod=%s... ]\n", pod.Name) + + pollDelay := 500 * time.Millisecond + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + pod, err := kcl.cli.CoreV1().Pods(pod.Namespace).Get(pod.Name, metav1.GetOptions{}) + if err != nil { + return err + } + + if pod.Status.Phase == phase { + return nil + } + + <-time.After(pollDelay) + } + } +} diff --git a/api/kubernetes/cli/pod_test.go b/api/kubernetes/cli/pod_test.go new file mode 100644 index 000000000..ad28d69d7 --- /dev/null +++ b/api/kubernetes/cli/pod_test.go @@ -0,0 +1,67 @@ +package cli + +import ( + "context" + "testing" + "time" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_waitForPodStatus(t *testing.T) { + + t.Run("successfully errors on cancelled context", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + podSpec := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: defaultNamespace}, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test-pod", Image: "containous/whoami"}, + }, + }, + } + + ctx, cancel := context.WithCancel(context.TODO()) + cancel() + err := k.waitForPodStatus(ctx, v1.PodRunning, podSpec) + if err != context.Canceled { + t.Errorf("waitForPodStatus should throw context cancellation error; err=%s", err) + } + }) + + t.Run("successfully errors on timeout", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + podSpec := &v1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: defaultNamespace}, + Spec: v1.PodSpec{ + Containers: []v1.Container{ + {Name: "test-pod", Image: "containous/whoami"}, + }, + }, + } + + pod, err := k.cli.CoreV1().Pods(defaultNamespace).Create(podSpec) + if err != nil { + t.Errorf("failed to create pod; err=%s", err) + } + defer k.cli.CoreV1().Pods(defaultNamespace).Delete(pod.Name, nil) + + ctx, cancelFunc := context.WithTimeout(context.TODO(), 0*time.Second) + defer cancelFunc() + err = k.waitForPodStatus(ctx, v1.PodRunning, podSpec) + if err != context.DeadlineExceeded { + t.Errorf("waitForPodStatus should throw deadline exceeded error; err=%s", err) + } + }) + +} diff --git a/api/kubernetes/cli/registries.go b/api/kubernetes/cli/registries.go new file mode 100644 index 000000000..122741a9b --- /dev/null +++ b/api/kubernetes/cli/registries.go @@ -0,0 +1,96 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + secretDockerConfigKey = ".dockerconfigjson" +) + +type ( + dockerConfig struct { + Auths map[string]registryDockerConfig `json:"auths"` + } + + registryDockerConfig struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + } +) + +func (kcl *KubeClient) DeleteRegistrySecret(registry *portainer.Registry, namespace string) error { + err := kcl.cli.CoreV1().Secrets(namespace).Delete(registrySecretName(registry), &metav1.DeleteOptions{}) + if err != nil && !k8serrors.IsNotFound(err) { + return errors.Wrap(err, "failed removing secret") + } + + return nil +} + +func (kcl *KubeClient) CreateRegistrySecret(registry *portainer.Registry, namespace string) error { + config := dockerConfig{ + Auths: map[string]registryDockerConfig{ + registry.URL: { + Username: registry.Username, + Password: registry.Password, + }, + }, + } + + configByte, err := json.Marshal(config) + if err != nil { + return errors.Wrap(err, "failed marshal config") + } + + secret := &v1.Secret{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: registrySecretName(registry), + Annotations: map[string]string{ + "portainer.io/registry.id": strconv.Itoa(int(registry.ID)), + }, + }, + Data: map[string][]byte{ + secretDockerConfigKey: configByte, + }, + Type: v1.SecretTypeDockerConfigJson, + } + + _, err = kcl.cli.CoreV1().Secrets(namespace).Create(secret) + if err != nil && !k8serrors.IsAlreadyExists(err) { + return errors.Wrap(err, "failed saving secret") + } + + return nil + +} + +func (cli *KubeClient) IsRegistrySecret(namespace, secretName string) (bool, error) { + secret, err := cli.cli.CoreV1().Secrets(namespace).Get(secretName, metav1.GetOptions{}) + if err != nil { + if k8serrors.IsNotFound(err) { + return false, nil + } + + return false, err + } + + isSecret := secret.Type == v1.SecretTypeDockerConfigJson + + return isSecret, nil + +} + +func registrySecretName(registry *portainer.Registry) string { + return fmt.Sprintf("registry-%d", registry.ID) +} diff --git a/api/kubernetes/cli/resource.go b/api/kubernetes/cli/resource.go new file mode 100644 index 000000000..f3d69f573 --- /dev/null +++ b/api/kubernetes/cli/resource.go @@ -0,0 +1,27 @@ +package cli + +import ( + "bytes" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" +) + +func GenerateYAML(obj runtime.Object) (string, error) { + serializer := json.NewSerializerWithOptions( + json.DefaultMetaFactory, nil, nil, + json.SerializerOptions{ + Yaml: true, + Pretty: true, + Strict: true, + }, + ) + + b := new(bytes.Buffer) + err := serializer.Encode(obj, b) + if err != nil { + return "", err + } + + return b.String(), nil +} diff --git a/api/kubernetes/cli/resource_test.go b/api/kubernetes/cli/resource_test.go new file mode 100644 index 000000000..902671103 --- /dev/null +++ b/api/kubernetes/cli/resource_test.go @@ -0,0 +1,93 @@ +package cli + +import ( + "strings" + "testing" + + "k8s.io/apimachinery/pkg/runtime" + clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" +) + +// compareYAMLStrings will compare 2 strings by stripping tabs, newlines and whitespaces from both strings +func compareYAMLStrings(in1, in2 string) int { + r := strings.NewReplacer("\t", "", "\n", "", " ", "") + in1 = r.Replace(in1) + in2 = r.Replace(in2) + return strings.Compare(in1, in2) +} + +func Test_GenerateYAML(t *testing.T) { + resourceYAMLTests := []struct { + title string + resource runtime.Object + wantYAML string + }{ + { + title: "Config", + resource: &clientV1.Config{ + APIVersion: "v1", + Kind: "Config", + CurrentContext: "portainer-ctx", + Contexts: []clientV1.NamedContext{ + { + Name: "portainer-ctx", + Context: clientV1.Context{ + AuthInfo: "test-user", + Cluster: "portainer-cluster", + }, + }, + }, + Clusters: []clientV1.NamedCluster{ + { + Name: "portainer-cluster", + Cluster: clientV1.Cluster{ + Server: "localhost", + InsecureSkipTLSVerify: true, + }, + }, + }, + AuthInfos: []clientV1.NamedAuthInfo{ + { + Name: "test-user", + AuthInfo: clientV1.AuthInfo{ + Token: "test-token", + }, + }, + }, + }, + wantYAML: ` + apiVersion: v1 + clusters: + - cluster: + insecure-skip-tls-verify: true + server: localhost + name: portainer-cluster + contexts: + - context: + cluster: portainer-cluster + user: test-user + name: portainer-ctx + current-context: portainer-ctx + kind: Config + preferences: {} + users: + - name: test-user + user: + token: test-token + `, + }, + } + + for _, ryt := range resourceYAMLTests { + t.Run(ryt.title, func(t *testing.T) { + yaml, err := GenerateYAML(ryt.resource) + if err != nil { + t.Errorf("generateYamlConfig failed; err=%s", err) + } + + if compareYAMLStrings(yaml, ryt.wantYAML) != 0 { + t.Errorf("generateYamlConfig failed;\ngot=\n%s\nwant=\n%s", yaml, ryt.wantYAML) + } + }) + } +} diff --git a/api/kubernetes/cli/role.go b/api/kubernetes/cli/role.go index d75afa3c1..7c22f1b32 100644 --- a/api/kubernetes/cli/role.go +++ b/api/kubernetes/cli/role.go @@ -18,15 +18,10 @@ func getPortainerUserDefaultPolicies() []rbacv1.PolicyRule { Resources: []string{"storageclasses"}, APIGroups: []string{"storage.k8s.io"}, }, - { - Verbs: []string{"list"}, - Resources: []string{"ingresses"}, - APIGroups: []string{"networking.k8s.io"}, - }, } } -func (kcl *KubeClient) createPortainerUserClusterRole() error { +func (kcl *KubeClient) upsertPortainerK8sClusterRoles() error { clusterRole := &rbacv1.ClusterRole{ ObjectMeta: metav1.ObjectMeta{ Name: portainerUserCRName, @@ -35,8 +30,13 @@ func (kcl *KubeClient) createPortainerUserClusterRole() error { } _, err := kcl.cli.RbacV1().ClusterRoles().Create(clusterRole) - if err != nil && !k8serrors.IsAlreadyExists(err) { - return err + if err != nil { + if k8serrors.IsAlreadyExists(err) { + _, err = kcl.cli.RbacV1().ClusterRoles().Update(clusterRole) + } + if err != nil { + return err + } } return nil diff --git a/api/kubernetes/cli/secret.go b/api/kubernetes/cli/secret.go index 3235cb304..eda7b05d6 100644 --- a/api/kubernetes/cli/secret.go +++ b/api/kubernetes/cli/secret.go @@ -41,7 +41,7 @@ func (kcl *KubeClient) getServiceAccountToken(serviceAccountName string) (string } // API token secret is populated asynchronously. - // Is it created by the controller and will depend on the environment/secret-store: + // Is it created by the controller and will depend on the environment(endpoint)/secret-store: // https://github.com/kubernetes/kubernetes/issues/67882#issuecomment-422026204 // as a work-around, we wait for up to 5 seconds for the secret to be populated. timeout := time.After(5 * time.Second) diff --git a/api/kubernetes/cli/service_account.go b/api/kubernetes/cli/service_account.go index d8abc6f0f..95dc4e897 100644 --- a/api/kubernetes/cli/service_account.go +++ b/api/kubernetes/cli/service_account.go @@ -1,12 +1,31 @@ package cli import ( - "k8s.io/api/core/v1" + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// GetServiceAccount returns the portainer ServiceAccountName associated to the specified user. +func (kcl *KubeClient) GetServiceAccount(tokenData *portainer.TokenData) (*v1.ServiceAccount, error) { + var portainerServiceAccountName string + if tokenData.Role == portainer.AdministratorRole { + portainerServiceAccountName = portainerClusterAdminServiceAccountName + } else { + portainerServiceAccountName = userServiceAccountName(int(tokenData.ID), kcl.instanceID) + } + + // verify name exists as service account resource within portainer namespace + serviceAccount, err := kcl.cli.CoreV1().ServiceAccounts(portainerNamespace).Get(portainerServiceAccountName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + + return serviceAccount, nil +} + // GetServiceAccountBearerToken returns the ServiceAccountToken associated to the specified user. func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error) { serviceAccountName := userServiceAccountName(userID, kcl.instanceID) @@ -17,7 +36,7 @@ func (kcl *KubeClient) GetServiceAccountBearerToken(userID int) (string, error) // SetupUserServiceAccount will make sure that all the required resources are created inside the Kubernetes // cluster before creating a ServiceAccount and a ServiceAccountToken for the specified Portainer user. //It will also create required default RoleBinding and ClusterRoleBinding rules. -func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int) error { +func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error { serviceAccountName := userServiceAccountName(userID, kcl.instanceID) err := kcl.ensureRequiredResourcesExist() @@ -25,20 +44,7 @@ func (kcl *KubeClient) SetupUserServiceAccount(userID int, teamIDs []int) error return err } - err = kcl.ensureServiceAccountForUserExists(serviceAccountName) - if err != nil { - return err - } - - return kcl.setupNamespaceAccesses(userID, teamIDs, serviceAccountName) -} - -func (kcl *KubeClient) ensureRequiredResourcesExist() error { - return kcl.createPortainerUserClusterRole() -} - -func (kcl *KubeClient) ensureServiceAccountForUserExists(serviceAccountName string) error { - err := kcl.createUserServiceAccount(portainerNamespace, serviceAccountName) + err = kcl.createUserServiceAccount(portainerNamespace, serviceAccountName) if err != nil { return err } @@ -53,7 +59,11 @@ func (kcl *KubeClient) ensureServiceAccountForUserExists(serviceAccountName stri return err } - return kcl.ensureNamespaceAccessForServiceAccount(serviceAccountName, defaultNamespace) + return kcl.setupNamespaceAccesses(userID, teamIDs, serviceAccountName, restrictDefaultNamespace) +} + +func (kcl *KubeClient) ensureRequiredResourcesExist() error { + return kcl.upsertPortainerK8sClusterRoles() } func (kcl *KubeClient) createUserServiceAccount(namespace, serviceAccountName string) error { diff --git a/api/kubernetes/cli/service_account_test.go b/api/kubernetes/cli/service_account_test.go new file mode 100644 index 000000000..defc4eb43 --- /dev/null +++ b/api/kubernetes/cli/service_account_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kfake "k8s.io/client-go/kubernetes/fake" +) + +func Test_GetServiceAccount(t *testing.T) { + + t.Run("returns error if non-existent", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + tokenData := &portainer.TokenData{ID: 1} + _, err := k.GetServiceAccount(tokenData) + if err == nil { + t.Error("GetServiceAccount should fail with service account not found") + } + }) + + t.Run("succeeds for cluster admin role", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.AdministratorRole, + Username: portainerClusterAdminServiceAccountName, + } + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: tokenData.Username, + }, + } + _, err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount) + if err != nil { + t.Errorf("failed to create service acount; err=%s", err) + } + defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil) + + sa, err := k.GetServiceAccount(tokenData) + if err != nil { + t.Errorf("GetServiceAccount should succeed; err=%s", err) + } + + want := "portainer-sa-clusteradmin" + if sa.Name != want { + t.Errorf("GetServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want) + } + }) + + t.Run("succeeds for standard user role", func(t *testing.T) { + k := &KubeClient{ + cli: kfake.NewSimpleClientset(), + instanceID: "test", + } + + tokenData := &portainer.TokenData{ + ID: 1, + Role: portainer.StandardUserRole, + } + serviceAccountName := userServiceAccountName(int(tokenData.ID), k.instanceID) + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccountName, + }, + } + _, err := k.cli.CoreV1().ServiceAccounts(portainerNamespace).Create(serviceAccount) + if err != nil { + t.Errorf("failed to create service acount; err=%s", err) + } + defer k.cli.CoreV1().ServiceAccounts(portainerNamespace).Delete(serviceAccount.Name, nil) + + sa, err := k.GetServiceAccount(tokenData) + if err != nil { + t.Errorf("GetServiceAccount should succeed; err=%s", err) + } + + want := "portainer-sa-user-test-1" + if sa.Name != want { + t.Errorf("GetServiceAccount should succeed and return correct sa name; got=%s want=%s", sa.Name, want) + } + }) + +} diff --git a/api/kubernetes/kubeconfig_service.go b/api/kubernetes/kubeconfig_service.go new file mode 100644 index 000000000..84ac6834a --- /dev/null +++ b/api/kubernetes/kubeconfig_service.go @@ -0,0 +1,104 @@ +package kubernetes + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "io/ioutil" + "log" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" +) + +// KubeConfigService represents a service that is responsible for handling kubeconfig operations +type KubeConfigService interface { + IsSecure() bool + GetKubeConfigInternal(endpointId portainer.EndpointID, authToken string) kubernetesClusterAccess +} + +// KubernetesClusterAccess represents core details which can be used to generate KubeConfig file/data +type kubernetesClusterAccess struct { + ClusterServerURL string `example:"https://mycompany.k8s.com"` + CertificateAuthorityFile string `example:"/data/tls/localhost.crt"` + CertificateAuthorityData string `example:"MIIC5TCCAc2gAwIBAgIJAJ+...+xuhOaFXwQ=="` + AuthToken string `example:"ey..."` +} + +type kubeConfigCAService struct { + httpsBindAddr string + certificateAuthorityFile string + certificateAuthorityData string +} + +var ( + errTLSCertNotProvided = errors.New("tls cert path not provided") + errTLSCertFileMissing = errors.New("missing tls cert file") + errTLSCertIncorrectType = errors.New("incorrect tls cert type") + errTLSCertValidation = errors.New("failed to parse tls certificate") +) + +// NewKubeConfigCAService encapsulates generation of core KubeConfig data +func NewKubeConfigCAService(httpsBindAddr string, tlsCertPath string) KubeConfigService { + certificateAuthorityData, err := getCertificateAuthorityData(tlsCertPath) + if err != nil { + log.Printf("[DEBUG] [internal,kubeconfig] [message: %s, generated KubeConfig will be insecure]", err.Error()) + } + + return &kubeConfigCAService{ + httpsBindAddr: httpsBindAddr, + certificateAuthorityFile: tlsCertPath, + certificateAuthorityData: certificateAuthorityData, + } +} + +// getCertificateAuthorityData reads tls certificate from supplied path and verifies the tls certificate +// then returns content (string) of the certificate within `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` +func getCertificateAuthorityData(tlsCertPath string) (string, error) { + if tlsCertPath == "" { + return "", errTLSCertNotProvided + } + + data, err := ioutil.ReadFile(tlsCertPath) + if err != nil { + return "", errors.Wrap(errTLSCertFileMissing, err.Error()) + } + + block, _ := pem.Decode(data) + if block == nil || block.Type != "CERTIFICATE" { + return "", errTLSCertIncorrectType + } + + certificate, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", errors.Wrap(errTLSCertValidation, err.Error()) + } + + return base64.StdEncoding.EncodeToString(certificate.Raw), nil +} + +// IsSecure specifies whether generated KubeConfig structs from the service will not have `insecure-skip-tls-verify: true` +// this is based on the fact that we can successfully extract `certificateAuthorityData` from +// certificate file at `tlsCertPath`. If we can successfully extract `certificateAuthorityData`, +// then this will be used as `certificate-authority-data` attribute in a generated KubeConfig. +func (kccas *kubeConfigCAService) IsSecure() bool { + return kccas.certificateAuthorityData != "" +} + +// GetKubeConfigInternal returns K8s cluster access details for the specified environment(endpoint). +// On startup, portainer generates a certificate against localhost at specified `httpsBindAddr` port, hence +// the kubeconfig generated should only be utilised by internal portainer binaries as the `ClusterServerURL` +// points to the internally accessible `https` based `localhost` address. +// The struct can be used to: +// - generate a kubeconfig file +// - pass down params to binaries +func (kccas *kubeConfigCAService) GetKubeConfigInternal(endpointId portainer.EndpointID, authToken string) kubernetesClusterAccess { + clusterServerUrl := fmt.Sprintf("https://localhost%s/api/endpoints/%s/kubernetes", kccas.httpsBindAddr, fmt.Sprint(endpointId)) + return kubernetesClusterAccess{ + ClusterServerURL: clusterServerUrl, + CertificateAuthorityFile: kccas.certificateAuthorityFile, + CertificateAuthorityData: kccas.certificateAuthorityData, + AuthToken: authToken, + } +} diff --git a/api/kubernetes/kubeconfig_service_test.go b/api/kubernetes/kubeconfig_service_test.go new file mode 100644 index 000000000..402b4f082 --- /dev/null +++ b/api/kubernetes/kubeconfig_service_test.go @@ -0,0 +1,149 @@ +package kubernetes + +import ( + "fmt" + "io/ioutil" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TLS certificate can be generated using: +// openssl req -x509 -out localhost.crt -keyout localhost.key -newkey rsa:2048 -nodes -sha25 -subj '/CN=localhost' -extensions EXT -config <( \ +// printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") +const certData = `-----BEGIN CERTIFICATE----- +MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0 +B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95Ocv +X+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3Xsew +B3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXi +oa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubT +oA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo +b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B +AQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/ +S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUS +Q5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/ +JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMo +iuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi +4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ== +-----END CERTIFICATE----- +` + +// string within the `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` without linebreaks +const certDataString = "MIIC5TCCAc2gAwIBAgIJAJ+poiEBdsplMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAeFw0yMTA4MDQwNDM0MTZaFw0yMTA5MDMwNDM0MTZaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKQ0HStP34FY/lSDIfMG9MV/lKNUkiLZcMXepbyhPit4ND/w9kOA4WTJ+oP0B2IYklRvLkneZOfQiPweGAPwZl3CjwII6gL6NCkhcXXAJ4JQ9duL5Q6pL//95OcvX+qMTssyS1DcH88F6v+gifACLpvG86G9V0DeSGS2fqqfOJngrOCgum1DsWi3XsewB3A7GkPRjYmckU3t4iHgcMb+6lGQAxtnllSM9DpqGnjXRs4mnQHKgufaeW5nvHXioa5l0aHIhN6MQS99QwKwfml7UtWAYhSJksMrrTovB6rThYpp2ID/iU9MGfkpxubToA6scv8alFa8Bo+NEKo255dxsScCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxob3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0BAQsFAAOCAQEALFBHW/r79KOj5bhoDtHs8h/ESAlD5DJI/kzc1RajA8AuWPsaagG/S0Bqiq2ApMA6Tr3t9An8peaLCaUapWw59kyQcwwPXm9vxhEEfoBRtk8po8XblsUSQ5Ku07ycSg5NBGEW2rCLsvjQFuQiAt8sW4jGCCN+ph/GQF9XC8ir+ssiqiMEkbm/JaK7sTi5kZ/GsSK8bJ+9N/ztoFr89YYEWjjOuIS3HNMdBcuQXIel7siEFdNjbzMoiuViiuhTPJkxKOzCmv52cxf15B0/+cgcImoX4zc9Z0NxKthBmIe00ojexE0ZBOFi4PxB7Ou6y/c9OvJb7gJv3z08+xuhOaFXwQ==" + +func createTempFile(filename, content string) (string, func()) { + tempPath, _ := ioutil.TempDir("", "temp") + filePath := fmt.Sprintf("%s/%s", tempPath, filename) + ioutil.WriteFile(filePath, []byte(content), 0644) + + teardown := func() { os.RemoveAll(tempPath) } + + return filePath, teardown +} + +func Test_getCertificateAuthorityData(t *testing.T) { + is := assert.New(t) + + t.Run("getCertificateAuthorityData fails on tls cert not provided", func(t *testing.T) { + _, err := getCertificateAuthorityData("") + is.ErrorIs(err, errTLSCertNotProvided, "getCertificateAuthorityData should fail with %w", errTLSCertNotProvided) + }) + + t.Run("getCertificateAuthorityData fails on tls cert provided but missing file", func(t *testing.T) { + _, err := getCertificateAuthorityData("/tmp/non-existent.crt") + is.ErrorIs(err, errTLSCertFileMissing, "getCertificateAuthorityData should fail with %w", errTLSCertFileMissing) + }) + + t.Run("getCertificateAuthorityData fails on tls cert provided but invalid file data", func(t *testing.T) { + filePath, teardown := createTempFile("invalid-cert.crt", "hello\ngo\n") + defer teardown() + + _, err := getCertificateAuthorityData(filePath) + is.ErrorIs(err, errTLSCertIncorrectType, "getCertificateAuthorityData should fail with %w", errTLSCertIncorrectType) + }) + + t.Run("getCertificateAuthorityData succeeds on valid tls cert provided", func(t *testing.T) { + filePath, teardown := createTempFile("valid-cert.crt", certData) + defer teardown() + + certificateAuthorityData, err := getCertificateAuthorityData(filePath) + is.NoError(err, "getCertificateAuthorityData succeed with valid cert; err=%w", errTLSCertIncorrectType) + + is.Equal(certificateAuthorityData, certDataString, "returned certificateAuthorityData should be %s", certDataString) + }) +} + +func TestKubeConfigService_IsSecure(t *testing.T) { + is := assert.New(t) + + t.Run("IsSecure should be false", func(t *testing.T) { + kcs := NewKubeConfigCAService("", "") + is.False(kcs.IsSecure(), "should be false if TLS cert not provided") + }) + + t.Run("IsSecure should be false", func(t *testing.T) { + filePath, teardown := createTempFile("valid-cert.crt", certData) + defer teardown() + + kcs := NewKubeConfigCAService("", filePath) + is.True(kcs.IsSecure(), "should be true if valid TLS cert (path and content) provided") + }) +} + +func TestKubeConfigService_GetKubeConfigInternal(t *testing.T) { + is := assert.New(t) + + t.Run("GetKubeConfigInternal returns localhost address", func(t *testing.T) { + kcs := NewKubeConfigCAService("", "") + clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token") + is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, "https://localhost"), "should contain localhost address") + }) + + t.Run("GetKubeConfigInternal contains https bind address port", func(t *testing.T) { + kcs := NewKubeConfigCAService(":1010", "") + clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token") + is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, ":1010"), "should contain bind address port") + }) + + t.Run("GetKubeConfigInternal contains environment proxy url", func(t *testing.T) { + kcs := NewKubeConfigCAService("", "") + clusterAccessDetails := kcs.GetKubeConfigInternal(100, "some-token") + is.True(strings.Contains(clusterAccessDetails.ClusterServerURL, "api/endpoints/100/kubernetes"), "should contain environment proxy url") + }) + + t.Run("GetKubeConfigInternal returns insecure cluster access config", func(t *testing.T) { + kcs := NewKubeConfigCAService("", "") + clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token") + + wantClusterAccessDetails := kubernetesClusterAccess{ + ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes", + AuthToken: "some-token", + CertificateAuthorityFile: "", + CertificateAuthorityData: "", + } + + is.Equal(clusterAccessDetails, wantClusterAccessDetails) + }) + + t.Run("GetKubeConfigInternal returns secure cluster access config", func(t *testing.T) { + filePath, teardown := createTempFile("valid-cert.crt", certData) + defer teardown() + + kcs := NewKubeConfigCAService("", filePath) + clusterAccessDetails := kcs.GetKubeConfigInternal(1, "some-token") + + wantClusterAccessDetails := kubernetesClusterAccess{ + ClusterServerURL: "https://localhost/api/endpoints/1/kubernetes", + AuthToken: "some-token", + CertificateAuthorityFile: filePath, + CertificateAuthorityData: certDataString, + } + + is.Equal(clusterAccessDetails, wantClusterAccessDetails) + }) +} diff --git a/api/kubernetes/privateregistries/labels.go b/api/kubernetes/privateregistries/labels.go new file mode 100644 index 000000000..dc780814c --- /dev/null +++ b/api/kubernetes/privateregistries/labels.go @@ -0,0 +1,5 @@ +package privateregistries + +const ( + RegistryIDLabel = "portainer.io/registry.id" +) diff --git a/api/kubernetes/snapshot.go b/api/kubernetes/snapshot.go index 8382d95ab..954766e8c 100644 --- a/api/kubernetes/snapshot.go +++ b/api/kubernetes/snapshot.go @@ -22,7 +22,7 @@ func NewSnapshotter(clientFactory *cli.ClientFactory) *Snapshotter { } } -// CreateSnapshot creates a snapshot of a specific Kubernetes endpoint +// CreateSnapshot creates a snapshot of a specific Kubernetes environment(endpoint) func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.KubernetesSnapshot, error) { client, err := snapshotter.clientFactory.CreateClient(endpoint) if err != nil { diff --git a/api/kubernetes/validation/validation.go b/api/kubernetes/validation/validation.go new file mode 100644 index 000000000..26b26eac5 --- /dev/null +++ b/api/kubernetes/validation/validation.go @@ -0,0 +1,48 @@ +package validation + +// borrowed from apimachinery@v0.17.2/pkg/util/validation/validation.go +// https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apimachinery/pkg/util/validation/validation.go + +import ( + "fmt" + "regexp" +) + +const dns1123LabelFmt string = "[a-z0-9]([-a-z0-9]*[a-z0-9])?" +const dns1123SubdomainFmt string = dns1123LabelFmt + "(\\." + dns1123LabelFmt + ")*" +const DNS1123SubdomainMaxLength int = 253 + +var dns1123SubdomainRegexp = regexp.MustCompile("^" + dns1123SubdomainFmt + "$") + +// IsDNS1123Subdomain tests for a string that conforms to the definition of a subdomain in DNS (RFC 1123). +func IsDNS1123Subdomain(value string) []string { + var errs []string + if len(value) > DNS1123SubdomainMaxLength { + errs = append(errs, MaxLenError(DNS1123SubdomainMaxLength)) + } + if !dns1123SubdomainRegexp.MatchString(value) { + errs = append(errs, RegexError(dns1123SubdomainFmt, "example.com")) + } + return errs +} + +// MaxLenError returns a string explanation of a "string too long" validation failure. +func MaxLenError(length int) string { + return fmt.Sprintf("must be no more than %d characters", length) +} + +// RegexError returns a string explanation of a regex validation failure. +func RegexError(fmt string, examples ...string) string { + s := "must match the regex " + fmt + if len(examples) == 0 { + return s + } + s += " (e.g. " + for i := range examples { + if i > 0 { + s += " or " + } + s += "'" + examples[i] + "'" + } + return s + ")" +} diff --git a/api/kubernetes/yaml.go b/api/kubernetes/yaml.go new file mode 100644 index 000000000..b8e7b1b68 --- /dev/null +++ b/api/kubernetes/yaml.go @@ -0,0 +1,112 @@ +package kubernetes + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + + "github.com/pkg/errors" + "gopkg.in/yaml.v3" +) + +type KubeAppLabels struct { + StackID int + Name string + Owner string + Kind string +} + +// AddAppLabels adds required labels to "Resource"->metadata->labels. +// It'll add those labels to all Resource (nodes with a kind property exluding a list) it can find in provided yaml. +// Items in the yaml file could either be organised as a list or broken into multi documents. +func AddAppLabels(manifestYaml []byte, appLabels KubeAppLabels) ([]byte, error) { + if bytes.Equal(manifestYaml, []byte("")) { + return manifestYaml, nil + } + + docs := make([][]byte, 0) + yamlDecoder := yaml.NewDecoder(bytes.NewReader(manifestYaml)) + + for { + m := make(map[string]interface{}) + err := yamlDecoder.Decode(&m) + + // if decoded document is empty + if m == nil { + continue + } + + // if there are no more documents in the file + if errors.Is(err, io.EOF) { + break + } + + addResourceLabels(m, appLabels) + + var out bytes.Buffer + yamlEncoder := yaml.NewEncoder(&out) + yamlEncoder.SetIndent(2) + if err := yamlEncoder.Encode(m); err != nil { + return nil, errors.Wrap(err, "failed to marshal yaml manifest") + } + + docs = append(docs, out.Bytes()) + } + + return bytes.Join(docs, []byte("---\n")), nil +} + +func addResourceLabels(yamlDoc interface{}, appLabels KubeAppLabels) { + m, ok := yamlDoc.(map[string]interface{}) + if !ok { + return + } + + kind, ok := m["kind"] + if ok && !strings.EqualFold(kind.(string), "list") { + addLabels(m, appLabels) + return + } + + for _, v := range m { + switch v.(type) { + case map[string]interface{}: + addResourceLabels(v, appLabels) + case []interface{}: + for _, item := range v.([]interface{}) { + addResourceLabels(item, appLabels) + } + } + } +} + +func addLabels(obj map[string]interface{}, appLabels KubeAppLabels) { + metadata := make(map[string]interface{}) + if m, ok := obj["metadata"]; ok { + metadata = m.(map[string]interface{}) + } + + labels := make(map[string]string) + if l, ok := metadata["labels"]; ok { + for k, v := range l.(map[string]interface{}) { + labels[k] = fmt.Sprintf("%v", v) + } + } + + name := appLabels.Name + if appLabels.Name == "" { + if n, ok := metadata["name"]; ok { + name = n.(string) + } + } + + labels["io.portainer.kubernetes.application.stackid"] = strconv.Itoa(appLabels.StackID) + labels["io.portainer.kubernetes.application.name"] = name + labels["io.portainer.kubernetes.application.owner"] = appLabels.Owner + labels["io.portainer.kubernetes.application.kind"] = appLabels.Kind + + metadata["labels"] = labels + obj["metadata"] = metadata +} diff --git a/api/kubernetes/yaml_test.go b/api/kubernetes/yaml_test.go new file mode 100644 index 000000000..5172357f4 --- /dev/null +++ b/api/kubernetes/yaml_test.go @@ -0,0 +1,493 @@ +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_AddAppLabels(t *testing.T) { + tests := []struct { + name string + input string + wantOutput string + }{ + { + name: "single deployment without labels", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "single deployment with existing labels", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "complex kompose output", + input: `apiVersion: v1 +items: + - apiVersion: v1 + kind: Service + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web + spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web + status: + loadBalancer: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + name: redis + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: redis + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + status: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + name: web + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: web + strategy: + type: Recreate + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + status: {} +kind: List +metadata: {} +`, + wantOutput: `apiVersion: v1 +items: + - apiVersion: v1 + kind: Service + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web + spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web + status: + loadBalancer: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: redis + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: redis + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + status: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: web + strategy: + type: Recreate + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + status: {} +kind: List +metadata: {} +`, + }, + { + name: "multiple items separated by ---", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +--- +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +--- +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "empty", + input: "", + wantOutput: "", + }, + { + name: "no only deployments", + input: `apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +`, + wantOutput: `apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +`, + }, + } + + labels := KubeAppLabels{ + StackID: 123, + Name: "best-name", + Owner: "best-owner", + Kind: "git", + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := AddAppLabels([]byte(tt.input), labels) + assert.NoError(t, err) + assert.Equal(t, tt.wantOutput, string(result)) + }) + } +} + +func Test_AddAppLabels_PickingName_WhenLabelNameIsEmpty(t *testing.T) { + labels := KubeAppLabels{ + StackID: 123, + Owner: "best-owner", + Kind: "git", + } + + input := `apiVersion: v1 +kind: Service +metadata: + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 +` + + expected := `apiVersion: v1 +kind: Service +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: web + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 +` + + result, err := AddAppLabels([]byte(input), labels) + assert.NoError(t, err) + assert.Equal(t, expected, string(result)) +} + +func Test_AddAppLabels_PickingName_WhenLabelAndMetadataNameAreEmpty(t *testing.T) { + labels := KubeAppLabels{ + StackID: 123, + Owner: "best-owner", + Kind: "git", + } + + input := `apiVersion: v1 +kind: Service +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 +` + + expected := `apiVersion: v1 +kind: Service +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: "" + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 +` + + result, err := AddAppLabels([]byte(input), labels) + assert.NoError(t, err) + assert.Equal(t, expected, string(result)) +} diff --git a/api/libcompose/compose_stack.go b/api/libcompose/compose_stack.go deleted file mode 100644 index 41da7239d..000000000 --- a/api/libcompose/compose_stack.go +++ /dev/null @@ -1,136 +0,0 @@ -package libcompose - -import ( - "context" - "fmt" - "path" - "path/filepath" - "regexp" - "strings" - - "github.com/portainer/libcompose/config" - "github.com/portainer/libcompose/docker" - "github.com/portainer/libcompose/docker/client" - "github.com/portainer/libcompose/docker/ctx" - "github.com/portainer/libcompose/lookup" - "github.com/portainer/libcompose/project" - "github.com/portainer/libcompose/project/options" - portainer "github.com/portainer/portainer/api" -) - -const ( - dockerClientVersion = "1.24" - composeSyntaxMaxVersion = "2" -) - -// ComposeStackManager represents a service for managing compose stacks. -type ComposeStackManager struct { - dataPath string - reverseTunnelService portainer.ReverseTunnelService -} - -// NewComposeStackManager initializes a new ComposeStackManager service. -func NewComposeStackManager(dataPath string, reverseTunnelService portainer.ReverseTunnelService) *ComposeStackManager { - return &ComposeStackManager{ - dataPath: dataPath, - reverseTunnelService: reverseTunnelService, - } -} - -func (manager *ComposeStackManager) createClient(endpoint *portainer.Endpoint) (client.Factory, error) { - - endpointURL := endpoint.URL - if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment { - tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID) - endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port) - } - - clientOpts := client.Options{ - Host: endpointURL, - APIVersion: dockerClientVersion, - } - - if endpoint.TLSConfig.TLS { - clientOpts.TLS = endpoint.TLSConfig.TLS - clientOpts.TLSVerify = !endpoint.TLSConfig.TLSSkipVerify - clientOpts.TLSCAFile = endpoint.TLSConfig.TLSCACertPath - clientOpts.TLSCertFile = endpoint.TLSConfig.TLSCertPath - clientOpts.TLSKeyFile = endpoint.TLSConfig.TLSKeyPath - } - - return client.NewDefaultFactory(clientOpts) -} - -// ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax -func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string { - return composeSyntaxMaxVersion -} - -// NormalizeStackName returns a new stack name with unsupported characters replaced -func (manager *ComposeStackManager) NormalizeStackName(name string) string { - // this is coming from libcompose - // https://github.com/portainer/libcompose/blob/master/project/context.go#L117-L120 - r := regexp.MustCompile("[^a-z0-9]+") - return r.ReplaceAllString(strings.ToLower(name), "") -} - -// Up will deploy a compose stack (equivalent of docker-compose up) -func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { - - clientFactory, err := manager.createClient(endpoint) - if err != nil { - return err - } - - env := make(map[string]string) - for _, envvar := range stack.Env { - env[envvar.Name] = envvar.Value - } - - composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) - proj, err := docker.NewProject(&ctx.Context{ - ConfigDir: manager.dataPath, - Context: project.Context{ - ComposeFiles: []string{composeFilePath}, - EnvironmentLookup: &lookup.ComposableEnvLookup{ - Lookups: []config.EnvironmentLookup{ - &lookup.EnvfileLookup{ - Path: filepath.Join(stack.ProjectPath, ".env"), - }, - &lookup.MapLookup{ - Vars: env, - }, - }, - }, - ProjectName: stack.Name, - }, - ClientFactory: clientFactory, - }, nil) - if err != nil { - return err - } - - return proj.Up(context.Background(), options.Up{}) -} - -// Down will shutdown a compose stack (equivalent of docker-compose down) -func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error { - clientFactory, err := manager.createClient(endpoint) - if err != nil { - return err - } - - composeFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) - proj, err := docker.NewProject(&ctx.Context{ - Context: project.Context{ - ComposeFiles: []string{composeFilePath}, - ProjectName: stack.Name, - }, - ClientFactory: clientFactory, - }, nil) - if err != nil { - return err - } - - return proj.Down(context.Background(), options.Down{RemoveVolume: false, RemoveOrphans: true}) -} diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index ef039d056..8ff5afeee 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -23,7 +23,7 @@ func NewService() *Service { return &Service{} } -// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint. +// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token environment(endpoint). // On success, it will then return the username and token expiry time associated to authenticated user by fetching this information // from the resource server and matching it with the user identifier setting. func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) { diff --git a/api/portainer.go b/api/portainer.go index dd866e435..f9f0652ce 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1,11 +1,14 @@ package portainer import ( + "context" "io" "net/http" "time" gittypes "github.com/portainer/portainer/api/git/types" + v1 "k8s.io/api/core/v1" + clientV1 "k8s.io/client-go/tools/clientcmd/api/v1" ) type ( @@ -28,7 +31,7 @@ type ( Authorizations map[Authorization]bool // AzureCredentials represents the credentials used to connect to an Azure - // environment. + // environment(endpoint). AzureCredentials struct { // Azure application ID ApplicationID string `json:"ApplicationID" example:"eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4"` @@ -41,6 +44,7 @@ type ( // CLIFlags represents the available flags on the CLI CLIFlags struct { Addr *string + AddrHTTPS *string TunnelAddr *string TunnelPort *string AdminPassword *string @@ -58,6 +62,7 @@ type ( TLSCacert *string TLSCert *string TLSKey *string + HTTPDisabled *bool SSL *bool SSLCert *string SSLKey *string @@ -107,7 +112,7 @@ type ( Password string `json:"Password,omitempty" example:"passwd"` } - // DockerSnapshot represents a snapshot of a specific Docker endpoint at a specific time + // DockerSnapshot represents a snapshot of a specific Docker environment(endpoint) at a specific time DockerSnapshot struct { Time int64 `json:"Time"` DockerVersion string `json:"DockerVersion"` @@ -150,7 +155,7 @@ type ( // EdgeGroupID represents an Edge group identifier EdgeGroupID int - // EdgeJob represents a job that can run on Edge environments. + // EdgeJob represents a job that can run on Edge environments(endpoints). EdgeJob struct { // EdgeJob Identifier ID EdgeJobID `json:"Id" example:"1"` @@ -163,7 +168,7 @@ type ( Version int `json:"Version"` } - // EdgeJobEndpointMeta represents a meta data object for an Edge job and Endpoint relation + // EdgeJobEndpointMeta represents a meta data object for an Edge job and Environment(Endpoint) relation EdgeJobEndpointMeta struct { LogsStatus EdgeJobLogsStatus CollectLogs bool @@ -175,7 +180,7 @@ type ( // EdgeJobLogsStatus represent status of logs collection job EdgeJobLogsStatus int - // EdgeSchedule represents a scheduled job that can run on Edge environments. + // EdgeSchedule represents a scheduled job that can run on Edge environments(endpoints). // Deprecated in favor of EdgeJob EdgeSchedule struct { // EdgeSchedule Identifier @@ -189,17 +194,23 @@ type ( //EdgeStack represents an edge stack EdgeStack struct { // EdgeStack Identifier - ID EdgeStackID `json:"Id" example:"1"` - Name string `json:"Name"` - Status map[EndpointID]EdgeStackStatus `json:"Status"` - CreationDate int64 `json:"CreationDate"` - EdgeGroups []EdgeGroupID `json:"EdgeGroups"` - ProjectPath string `json:"ProjectPath"` - EntryPoint string `json:"EntryPoint"` - Version int `json:"Version"` - Prune bool `json:"Prune"` + ID EdgeStackID `json:"Id" example:"1"` + Name string `json:"Name"` + Status map[EndpointID]EdgeStackStatus `json:"Status"` + CreationDate int64 `json:"CreationDate"` + EdgeGroups []EdgeGroupID `json:"EdgeGroups"` + ProjectPath string `json:"ProjectPath"` + EntryPoint string `json:"EntryPoint"` + Version int `json:"Version"` + ManifestPath string + DeploymentType EdgeStackDeploymentType + + // Deprecated + Prune bool `json:"Prune"` } + EdgeStackDeploymentType int + //EdgeStackID represents an edge stack id EdgeStackID int @@ -213,35 +224,35 @@ type ( //EdgeStackStatusType represents an edge stack status type EdgeStackStatusType int - // Endpoint represents a Docker endpoint with all the info required + // Environment(Endpoint) represents a Docker environment(endpoint) with all the info required // to connect to it Endpoint struct { - // Endpoint Identifier + // Environment(Endpoint) Identifier ID EndpointID `json:"Id" example:"1"` - // Endpoint name - Name string `json:"Name" example:"my-endpoint"` - // Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment or 3 for an Azure environment. + // Environment(Endpoint) name + Name string `json:"Name" example:"my-environment"` + // Environment(Endpoint) environment(endpoint) type. 1 for a Docker environment(endpoint), 2 for an agent on Docker environment(endpoint) or 3 for an Azure environment(endpoint). Type EndpointType `json:"Type" example:"1"` - // URL or IP address of the Docker host associated to this endpoint + // URL or IP address of the Docker host associated to this environment(endpoint) URL string `json:"URL" example:"docker.mydomain.tld:2375"` - // Endpoint group identifier + // Environment(Endpoint) group identifier GroupID EndpointGroupID `json:"GroupId" example:"1"` // URL or IP address where exposed containers will be reachable PublicURL string `json:"PublicURL" example:"docker.mydomain.tld:2375"` TLSConfig TLSConfiguration `json:"TLSConfig"` Extensions []EndpointExtension `json:"Extensions" example:""` AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty" example:""` - // List of tag identifiers to which this endpoint is associated + // List of tag identifiers to which this environment(endpoint) is associated TagIDs []TagID `json:"TagIds"` - // The status of the endpoint (1 - up, 2 - down) + // The status of the environment(endpoint) (1 - up, 2 - down) Status EndpointStatus `json:"Status" example:"1"` // List of snapshots Snapshots []DockerSnapshot `json:"Snapshots" example:""` - // List of user identifiers authorized to connect to this endpoint + // List of user identifiers authorized to connect to this environment(endpoint) UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` - // List of team identifiers authorized to connect to this endpoint + // List of team identifiers authorized to connect to this environment(endpoint) TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies" example:""` - // The identifier of the edge agent associated with this endpoint + // The identifier of the edge agent associated with this environment(endpoint) EdgeID string `json:"EdgeID,omitempty" example:""` // The key which is used to map the agent to Portainer EdgeKey string `json:"EdgeKey" example:""` @@ -251,7 +262,7 @@ type ( Kubernetes KubernetesData `json:"Kubernetes" example:""` // Maximum version of docker-compose ComposeSyntaxMaxVersion string `json:"ComposeSyntaxMaxVersion" example:"3.8"` - // Endpoint specific security settings + // Environment(Endpoint) specific security settings SecuritySettings EndpointSecuritySettings // LastCheckInDate mark last check-in date on checkin LastCheckInDate int64 @@ -271,7 +282,7 @@ type ( Tags []string `json:"Tags"` } - // EndpointAuthorizations represents the authorizations associated to a set of endpoints + // EndpointAuthorizations represents the authorizations associated to a set of environments(endpoints) EndpointAuthorizations map[EndpointID]Authorizations // EndpointExtension represents a deprecated form of Portainer extension @@ -281,21 +292,21 @@ type ( URL string `json:"URL"` } - // EndpointExtensionType represents the type of an endpoint extension. Only - // one extension of each type can be associated to an endpoint + // EndpointExtensionType represents the type of an environment(endpoint) extension. Only + // one extension of each type can be associated to an environment(endpoint) EndpointExtensionType int - // EndpointGroup represents a group of endpoints + // EndpointGroup represents a group of environments(endpoints) EndpointGroup struct { - // Endpoint group Identifier + // Environment(Endpoint) group Identifier ID EndpointGroupID `json:"Id" example:"1"` - // Endpoint group name - Name string `json:"Name" example:"my-endpoint-group"` - // Description associated to the endpoint group - Description string `json:"Description" example:"Endpoint group description"` + // Environment(Endpoint) group name + Name string `json:"Name" example:"my-environment-group"` + // Description associated to the environment(endpoint) group + Description string `json:"Description" example:"Environment(Endpoint) group description"` UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies" example:""` TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies" example:""` - // List of tags associated to this endpoint group + // List of tags associated to this environment(endpoint) group TagIDs []TagID `json:"TagIds"` // Deprecated fields @@ -309,20 +320,20 @@ type ( Tags []string `json:"Tags"` } - // EndpointGroupID represents an endpoint group identifier + // EndpointGroupID represents an environment(endpoint) group identifier EndpointGroupID int - // EndpointID represents an endpoint identifier + // EndpointID represents an environment(endpoint) identifier EndpointID int - // EndpointStatus represents the status of an endpoint + // EndpointStatus represents the status of an environment(endpoint) EndpointStatus int - // EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file + // EndpointSyncJob represents a scheduled job that synchronize environments(endpoints) based on an external file // Deprecated EndpointSyncJob struct{} - // EndpointSecuritySettings represents settings for an endpoint + // EndpointSecuritySettings represents settings for an environment(endpoint) EndpointSecuritySettings struct { // Whether non-administrator should be able to use bind mounts when creating containers AllowBindMountsForRegularUsers bool `json:"allowBindMountsForRegularUsers" example:"false"` @@ -344,10 +355,10 @@ type ( EnableHostManagementFeatures bool `json:"enableHostManagementFeatures" example:"true"` } - // EndpointType represents the type of an endpoint + // EndpointType represents the type of an environment(endpoint) EndpointType int - // EndpointRelation represents a endpoint relation object + // EndpointRelation represents a environment(endpoint) relation object EndpointRelation struct { EndpointID EndpointID EdgeStacks map[EdgeStackID]bool @@ -384,6 +395,18 @@ type ( ProjectPath string `json:"ProjectPath"` } + HelmUserRepositoryID int + + // HelmUserRepositories stores a Helm repository URL for the given user + HelmUserRepository struct { + // Membership Identifier + ID HelmUserRepositoryID `json:"Id" example:"1"` + // User identifier + UserID UserID `json:"UserId" example:"1"` + // Helm repository URL + URL string `json:"URL" example:"https://charts.bitnami.com/bitnami"` + } + // QuayRegistryData represents data required for Quay registry to work QuayRegistryData struct { UseOrganisation bool `json:"UseOrganisation"` @@ -393,18 +416,25 @@ type ( // JobType represents a job type JobType int + K8sNodeLimits struct { + CPU int64 `json:"CPU"` + Memory int64 `json:"Memory"` + } + + K8sNodesLimits map[string]*K8sNodeLimits + K8sNamespaceAccessPolicy struct { UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` } - // KubernetesData contains all the Kubernetes related endpoint information + // KubernetesData contains all the Kubernetes related environment(endpoint) information KubernetesData struct { Snapshots []KubernetesSnapshot `json:"Snapshots"` Configuration KubernetesConfiguration `json:"Configuration"` } - // KubernetesSnapshot represents a snapshot of a specific Kubernetes endpoint at a specific time + // KubernetesSnapshot represents a snapshot of a specific Kubernetes environment(endpoint) at a specific time KubernetesSnapshot struct { Time int64 `json:"Time"` KubernetesVersion string `json:"KubernetesVersion"` @@ -413,12 +443,13 @@ type ( TotalMemory int64 `json:"TotalMemory"` } - // KubernetesConfiguration represents the configuration of a Kubernetes endpoint + // KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint) KubernetesConfiguration struct { - UseLoadBalancer bool `json:"UseLoadBalancer"` - UseServerMetrics bool `json:"UseServerMetrics"` - StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"` - IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"` + UseLoadBalancer bool `json:"UseLoadBalancer"` + UseServerMetrics bool `json:"UseServerMetrics"` + StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"` + IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"` + RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"` } // KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration @@ -435,6 +466,14 @@ type ( Type string `json:"Type"` } + // KubernetesShellPod represents a Kubectl Shell details to facilitate pod exec functionality + KubernetesShellPod struct { + Namespace string + PodName string + ContainerName string + ShellExecCommand string + } + // LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server LDAPGroupSearchSettings struct { // The distinguished name of the element from which the LDAP server will search for groups @@ -512,12 +551,14 @@ type ( Registry struct { // Registry Identifier ID RegistryID `json:"Id" example:"1"` - // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab) - Type RegistryType `json:"Type" enums:"1,2,3,4"` + // Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet, 6 - DockerHub) + Type RegistryType `json:"Type" enums:"1,2,3,4,5,6"` // Registry Name Name string `json:"Name" example:"my-registry"` // URL or IP address of the Docker registry URL string `json:"URL" example:"registry.mydomain.tld:2375"` + // Base URL, introduced for ProGet registry + BaseURL string `json:"BaseURL" example:"registry.mydomain.tld:2375"` // Is authentication against this registry enabled Authentication bool `json:"Authentication" example:"true"` // Username used to authenticate against this registry @@ -527,15 +568,28 @@ type ( ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` Gitlab GitlabRegistryData `json:"Gitlab"` Quay QuayRegistryData `json:"Quay"` - UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` - TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + RegistryAccesses RegistryAccesses `json:"RegistryAccesses"` // Deprecated fields + // Deprecated in DBVersion == 31 + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + // Deprecated in DBVersion == 31 + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + // Deprecated in DBVersion == 18 AuthorizedUsers []UserID `json:"AuthorizedUsers"` + // Deprecated in DBVersion == 18 AuthorizedTeams []TeamID `json:"AuthorizedTeams"` } + RegistryAccesses map[EndpointID]RegistryAccessPolicies + + RegistryAccessPolicies struct { + UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` + TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` + Namespaces []string `json:"Namespaces"` + } + // RegistryID represents a registry identifier RegistryID int @@ -595,7 +649,7 @@ type ( // Role name Name string `json:"Name" example:"HelpDesk"` // Role description - Description string `json:"Description" example:"Read-only access of all resources in an endpoint"` + Description string `json:"Description" example:"Read-only access of all resources in an environment(endpoint)"` // Authorizations associated to a role Authorizations Authorizations `json:"Authorizations"` Priority int `json:"Priority"` @@ -643,7 +697,7 @@ type ( AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod" example:"1"` LDAPSettings LDAPSettings `json:"LDAPSettings" example:""` OAuthSettings OAuthSettings `json:"OAuthSettings" example:""` - // The interval in which endpoint snapshots are created + // The interval in which environment(endpoint) snapshots are created SnapshotInterval string `json:"SnapshotInterval" example:"5m"` // URL to the templates that will be displayed in the UI when navigating to App Templates TemplatesURL string `json:"TemplatesURL" example:"https://raw.githubusercontent.com/portainer/templates/master/templates.json"` @@ -653,8 +707,12 @@ type ( EnableEdgeComputeFeatures bool `json:"EnableEdgeComputeFeatures" example:""` // The duration of a user session UserSessionTimeout string `json:"UserSessionTimeout" example:"5m"` + // The expiry of a Kubeconfig + KubeconfigExpiry string `json:"KubeconfigExpiry" example:"24h"` // Whether telemetry is enabled EnableTelemetry bool `json:"EnableTelemetry" example:"false"` + // Helm repository URL, defaults to "https://charts.bitnami.com/bitnami" + HelmRepositoryURL string `json:"HelmRepositoryURL" example:"https://charts.bitnami.com/bitnami"` // Deprecated fields DisplayDonationHeader bool @@ -671,12 +729,20 @@ type ( AllowContainerCapabilitiesForRegularUsers bool `json:"AllowContainerCapabilitiesForRegularUsers"` } - // SnapshotJob represents a scheduled job that can create endpoint snapshots + // SnapshotJob represents a scheduled job that can create environment(endpoint) snapshots SnapshotJob struct{} // SoftwareEdition represents an edition of Portainer SoftwareEdition int + // SSLSettings represents a pair of SSL certificate and key + SSLSettings struct { + CertPath string `json:"certPath"` + KeyPath string `json:"keyPath"` + SelfSigned bool `json:"selfSigned"` + HTTPEnabled bool `json:"httpEnabled"` + } + // Stack represents a Docker stack created via docker stack deploy Stack struct { // Stack Identifier @@ -685,13 +751,13 @@ type ( Name string `json:"Name" example:"myStack"` // Stack type. 1 for a Swarm stack, 2 for a Compose stack Type StackType `json:"Type" example:"2"` - // Endpoint identifier. Reference the endpoint that will be used for deployment + // Environment(Endpoint) identifier. Reference the environment(endpoint) that will be used for deployment EndpointID EndpointID `json:"EndpointId" example:"1"` // Cluster identifier of the Swarm cluster where the stack is deployed SwarmID string `json:"SwarmId" example:"jpofkc0i9uo9wtx1zesuk649w"` // Path to the Stack file EntryPoint string `json:"EntryPoint" example:"docker-compose.yml"` - // A list of environment variables used during stack deployment + // A list of environment(endpoint) variables used during stack deployment Env []Pair `json:"Env" example:""` // ResourceControl *ResourceControl `json:"ResourceControl" example:""` @@ -707,8 +773,26 @@ type ( UpdateDate int64 `example:"1587399600"` // The username which last updated this stack UpdatedBy string `example:"bob"` + // Only applies when deploying stack with multiple files + AdditionalFiles []string `json:"AdditionalFiles"` + // The auto update settings of a git stack + AutoUpdate *StackAutoUpdate `json:"AutoUpdate"` // The git config of this stack GitConfig *gittypes.RepoConfig + // Kubernetes namespace if stack is a kube application + Namespace string `example:"default"` + // IsComposeFormat indicates if the Kubernetes stack is created from a Docker Compose file + IsComposeFormat bool `example:"false"` + } + + //StackAutoUpdate represents the git auto sync config for stack deployment + StackAutoUpdate struct { + // Auto update interval + Interval string `example:"1m30s"` + // A UUID generated from client + Webhook string `example:"05de31a2-79fa-4644-9c12-faa67e5c49f0"` + // Autoupdate job id + JobID string `example:"15"` } // StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier) @@ -724,6 +808,8 @@ type ( Status struct { // Portainer API version Version string `json:"Version" example:"2.0.0"` + // Server Instance ID + InstanceID string `example:"299ab403-70a8-4c05-92f7-bf7a994d50df"` } // Tag represents a tag that can be associated to a resource @@ -732,9 +818,9 @@ type ( ID TagID `example:"1"` // Tag name Name string `json:"Name" example:"org/acme"` - // A set of endpoint ids that have this tag + // A set of environment(endpoint) ids that have this tag Endpoints map[EndpointID]bool `json:"Endpoints"` - // A set of endpoint group ids that have this tag + // A set of environment(endpoint) group ids that have this tag EndpointGroups map[EndpointGroupID]bool `json:"EndpointGroups"` } @@ -807,7 +893,7 @@ type ( Name string `json:"name,omitempty" example:"mystackname"` // URL of the template's logo Logo string `json:"logo,omitempty" example:"https://cloudinovasi.id/assets/img/logos/nginx.png"` - // A list of environment variables used during the template deployment + // A list of environment(endpoint) variables used during the template deployment Env []TemplateEnv `json:"env,omitempty"` // A note that will be displayed in the UI. Supports HTML content Note string `json:"note,omitempty" example:"This is my custom template"` @@ -822,7 +908,7 @@ type ( Registry string `json:"registry,omitempty" example:"quay.io"` // The command that will be executed in a container template Command string `json:"command,omitempty" example:"ls -lah"` - // Name of a network that will be used on container deployment if it exists inside the environment + // Name of a network that will be used on container deployment if it exists inside the environment(endpoint) Network string `json:"network,omitempty" example:"mynet"` // A list of volumes used during the container template deployment Volumes []TemplateVolume `json:"volumes,omitempty"` @@ -841,9 +927,9 @@ type ( Hostname string `json:"hostname,omitempty" example:"mycontainer"` } - // TemplateEnv represents a template environment variable configuration + // TemplateEnv represents a template environment(endpoint) variable configuration TemplateEnv struct { - // name of the environment variable + // name of the environment(endpoint) variable Name string `json:"name" example:"MYSQL_ROOT_PASSWORD"` // Text for the label that will be generated in the UI Label string `json:"label,omitempty" example:"Root password"` @@ -906,7 +992,7 @@ type ( TLSKeyPath string `json:"TLSKey,omitempty" example:"/data/tls/key.pem"` } - // TLSFileType represents a type of TLS file required to connect to a Docker endpoint. + // TLSFileType represents a type of TLS file required to connect to a Docker environment(endpoint). // It can be either a TLS CA file, a TLS certificate file or a TLS key file TLSFileType int @@ -937,6 +1023,8 @@ type ( ID UserID `json:"Id" example:"1"` Username string `json:"Username" example:"bob"` Password string `json:"Password,omitempty" example:"passwd"` + // User Theme + UserTheme string `example:"dark"` // User role (1 for administrator account and 2 for regular account) Role UserRole `json:"Role" example:"1"` @@ -988,8 +1076,8 @@ type ( ComposeStackManager interface { ComposeSyntaxMaxVersion() string NormalizeStackName(name string) string - Up(stack *Stack, endpoint *Endpoint) error - Down(stack *Stack, endpoint *Endpoint) error + Up(ctx context.Context, stack *Stack, endpoint *Endpoint) error + Down(ctx context.Context, stack *Stack, endpoint *Endpoint) error } // CryptoService represents a service for encrypting/hashing data @@ -1018,7 +1106,6 @@ type ( CheckCurrentEdition() error BackupTo(w io.Writer) error - DockerHub() DockerHubService CustomTemplate() CustomTemplateService EdgeGroup() EdgeGroupService EdgeJob() EdgeJobService @@ -1026,10 +1113,12 @@ type ( Endpoint() EndpointService EndpointGroup() EndpointGroupService EndpointRelation() EndpointRelationService + HelmUserRepository() HelmUserRepositoryService Registry() RegistryService ResourceControl() ResourceControlService Role() RoleService Settings() SettingsService + SSLSettings() SSLSettingsService Stack() StackService Tag() TagService TeamMembership() TeamMembershipService @@ -1049,13 +1138,7 @@ type ( CreateSignature(message string) (string, error) } - // DockerHubService represents a service for managing the DockerHub object - DockerHubService interface { - DockerHub() (*DockerHub, error) - UpdateDockerHub(registry *DockerHub) error - } - - // DockerSnapshotter represents a service used to create Docker endpoint snapshots + // DockerSnapshotter represents a service used to create Docker environment(endpoint) snapshots DockerSnapshotter interface { CreateSnapshot(endpoint *Endpoint) (*DockerSnapshot, error) } @@ -1089,7 +1172,7 @@ type ( GetNextIdentifier() int } - // EndpointService represents a service for managing endpoint data + // EndpointService represents a service for managing environment(endpoint) data EndpointService interface { Endpoint(ID EndpointID) (*Endpoint, error) Endpoints() ([]Endpoint, error) @@ -1100,7 +1183,7 @@ type ( GetNextIdentifier() int } - // EndpointGroupService represents a service for managing endpoint group data + // EndpointGroupService represents a service for managing environment(endpoint) group data EndpointGroupService interface { EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error) EndpointGroups() ([]EndpointGroup, error) @@ -1109,7 +1192,7 @@ type ( DeleteEndpointGroup(ID EndpointGroupID) error } - // EndpointRelationService represents a service for managing endpoint relations data + // EndpointRelationService represents a service for managing environment(endpoint) relations data EndpointRelationService interface { EndpointRelation(EndpointID EndpointID) (*EndpointRelation, error) CreateEndpointRelation(endpointRelation *EndpointRelation) error @@ -1119,6 +1202,7 @@ type ( // FileService represents a service for managing files FileService interface { + GetDockerConfigPath() string GetFileContent(filePath string) ([]byte, error) Rename(oldPath, newPath string) error RemoveDirectory(directoryPath string) error @@ -1146,38 +1230,57 @@ type ( GetCustomTemplateProjectPath(identifier string) string GetTemporaryPath() (string, error) GetDatastorePath() string + GetDefaultSSLCertsPath() (string, string) + StoreSSLCertPair(cert, key []byte) (string, string, error) + CopySSLCertPair(certPath, keyPath string) (string, string, error) } // GitService represents a service for managing Git GitService interface { CloneRepository(destination string, repositoryURL, referenceName, username, password string) error + LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) + } + + // HelmUserRepositoryService represents a service to manage HelmUserRepositories + HelmUserRepositoryService interface { + HelmUserRepositoryByUserID(userID UserID) ([]HelmUserRepository, error) + CreateHelmUserRepository(record *HelmUserRepository) error } // JWTService represents a service for managing JWT tokens JWTService interface { GenerateToken(data *TokenData) (string, error) GenerateTokenForOAuth(data *TokenData, expiryTime *time.Time) (string, error) + GenerateTokenForKubeconfig(data *TokenData) (string, error) ParseAndVerifyToken(token string) (*TokenData, error) SetUserSessionDuration(userSessionDuration time.Duration) } - // KubeClient represents a service used to query a Kubernetes environment + // KubeClient represents a service used to query a Kubernetes environment(endpoint) KubeClient interface { - SetupUserServiceAccount(userID int, teamIDs []int) error + SetupUserServiceAccount(userID int, teamIDs []int, restrictDefaultNamespace bool) error + GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error) GetServiceAccountBearerToken(userID int) (string, error) - StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error + CreateUserShellPod(ctx context.Context, serviceAccountName string) (*KubernetesShellPod, error) + StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) NamespaceAccessPoliciesDeleteNamespace(namespace string) error + GetNodesLimits() (K8sNodesLimits, error) GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error + DeleteRegistrySecret(registry *Registry, namespace string) error + CreateRegistrySecret(registry *Registry, namespace string) error + IsRegistrySecret(namespace, secretName string) (bool, error) + GetKubeConfig(ctx context.Context, apiServerURL string, bearerToken string, tokenData *TokenData) (*clientV1.Config, error) + ToggleSystemState(namespace string, isSystem bool) error } - // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint + // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes environment(endpoint) KubernetesDeployer interface { Deploy(request *http.Request, endpoint *Endpoint, data string, namespace string) (string, error) - ConvertCompose(data string) ([]byte, error) + ConvertCompose(data []byte) ([]byte, error) } - // KubernetesSnapshotter represents a service used to create Kubernetes endpoint snapshots + // KubernetesSnapshotter represents a service used to create Kubernetes environment(endpoint) snapshots KubernetesSnapshotter interface { CreateSnapshot(endpoint *Endpoint) (*KubernetesSnapshot, error) } @@ -1221,6 +1324,7 @@ type ( SetTunnelStatusToActive(endpointID EndpointID) SetTunnelStatusToRequired(endpointID EndpointID) error SetTunnelStatusToIdle(endpointID EndpointID) + KeepTunnelAlive(endpointID EndpointID, ctx context.Context, maxKeepAlive time.Duration) GetTunnelDetails(endpointID EndpointID) *TunnelDetails AddEdgeJob(endpointID EndpointID, edgeJob *EdgeJob) RemoveEdgeJob(edgeJobID EdgeJobID) @@ -1245,6 +1349,12 @@ type ( Start() error } + // SSLSettingsService represents a service for managing application settings + SSLSettingsService interface { + Settings() (*SSLSettings, error) + UpdateSettings(settings *SSLSettings) error + } + // StackService represents a service for managing stack data StackService interface { Stack(ID StackID) (*Stack, error) @@ -1254,9 +1364,11 @@ type ( UpdateStack(ID StackID, stack *Stack) error DeleteStack(ID StackID) error GetNextIdentifier() int + StackByWebhookID(ID string) (*Stack, error) + RefreshableStacks() ([]Stack, error) } - // SnapshotService represents a service for managing endpoint snapshots + // SnapshotService represents a service for managing environment(endpoint) snapshots SnapshotService interface { Start() Stop() @@ -1266,10 +1378,11 @@ type ( // SwarmStackManager represents a service to manage Swarm stacks SwarmStackManager interface { - Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) + Login(registries []Registry, endpoint *Endpoint) Logout(endpoint *Endpoint) error Deploy(stack *Stack, prune bool, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error + NormalizeStackName(name string) string } // TagService represents a service for managing tag data @@ -1343,9 +1456,9 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "2.6.3" + APIVersion = "2.9.0" // DBVersion is the version number of the Portainer database - DBVersion = 30 + DBVersion = 32 // ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax ComposeSyntaxMaxVersion = "3.9" // AssetsServerURL represents the URL of the Portainer asset server @@ -1375,8 +1488,14 @@ const ( DefaultEdgeAgentCheckinIntervalInSeconds = 5 // DefaultTemplatesURL represents the URL to the official templates supported by Portainer DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates-2.0.json" + // DefaultHelmrepositoryURL represents the URL to the official templates supported by Bitnami + DefaultHelmRepositoryURL = "https://charts.bitnami.com/bitnami" // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared DefaultUserSessionTimeout = "8h" + // DefaultUserSessionTimeout represents the default timeout after which the user session is cleared + DefaultKubeconfigExpiry = "0" + // WebSocketKeepAlive web socket keep alive for edge environments + WebSocketKeepAlive = 1 * time.Hour ) const ( @@ -1415,11 +1534,18 @@ const ( CustomTemplatePlatformWindows ) +const ( + // EdgeStackDeploymentCompose represent an edge stack deployed using a compose file + EdgeStackDeploymentCompose EdgeStackDeploymentType = iota + // EdgeStackDeploymentKubernetes represent an edge stack deployed using a kubernetes manifest file + EdgeStackDeploymentKubernetes +) + const ( _ EdgeStackStatusType = iota //StatusOk represents a successfully deployed edge stack StatusOk - //StatusError represents an edge endpoint which failed to deploy its edge stack + //StatusError represents an edge environment(endpoint) which failed to deploy its edge stack StatusError //StatusAcknowledged represents an acknowledged edge stack StatusAcknowledged @@ -1433,33 +1559,33 @@ const ( const ( _ EndpointStatus = iota - // EndpointStatusUp is used to represent an available endpoint + // EndpointStatusUp is used to represent an available environment(endpoint) EndpointStatusUp - // EndpointStatusDown is used to represent an unavailable endpoint + // EndpointStatusDown is used to represent an unavailable environment(endpoint) EndpointStatusDown ) const ( _ EndpointType = iota - // DockerEnvironment represents an endpoint connected to a Docker environment + // DockerEnvironment represents an environment(endpoint) connected to a Docker environment(endpoint) DockerEnvironment - // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment + // AgentOnDockerEnvironment represents an environment(endpoint) connected to a Portainer agent deployed on a Docker environment(endpoint) AgentOnDockerEnvironment - // AzureEnvironment represents an endpoint connected to an Azure environment + // AzureEnvironment represents an environment(endpoint) connected to an Azure environment(endpoint) AzureEnvironment - // EdgeAgentOnDockerEnvironment represents an endpoint connected to an Edge agent deployed on a Docker environment + // EdgeAgentOnDockerEnvironment represents an environment(endpoint) connected to an Edge agent deployed on a Docker environment(endpoint) EdgeAgentOnDockerEnvironment - // KubernetesLocalEnvironment represents an endpoint connected to a local Kubernetes environment + // KubernetesLocalEnvironment represents an environment(endpoint) connected to a local Kubernetes environment(endpoint) KubernetesLocalEnvironment - // AgentOnKubernetesEnvironment represents an endpoint connected to a Portainer agent deployed on a Kubernetes environment + // AgentOnKubernetesEnvironment represents an environment(endpoint) connected to a Portainer agent deployed on a Kubernetes environment(endpoint) AgentOnKubernetesEnvironment - // EdgeAgentOnKubernetesEnvironment represents an endpoint connected to an Edge agent deployed on a Kubernetes environment + // EdgeAgentOnKubernetesEnvironment represents an environment(endpoint) connected to an Edge agent deployed on a Kubernetes environment(endpoint) EdgeAgentOnKubernetesEnvironment ) const ( _ JobType = iota - // SnapshotJobType is a system job used to create endpoint snapshots + // SnapshotJobType is a system job used to create environment(endpoint) snapshots SnapshotJobType = 2 ) @@ -1491,6 +1617,10 @@ const ( CustomRegistry // GitlabRegistry represents a gitlab registry GitlabRegistry + // ProGetRegistry represents a proget registry + ProGetRegistry + // DockerHubRegistry represents a dockerhub registry + DockerHubRegistry ) const ( @@ -1574,11 +1704,11 @@ const ( ) const ( - // EdgeAgentIdle represents an idle state for a tunnel connected to an Edge endpoint. + // EdgeAgentIdle represents an idle state for a tunnel connected to an Edge environment(endpoint). EdgeAgentIdle string = "IDLE" - // EdgeAgentManagementRequired represents a required state for a tunnel connected to an Edge endpoint + // EdgeAgentManagementRequired represents a required state for a tunnel connected to an Edge environment(endpoint) EdgeAgentManagementRequired string = "REQUIRED" - // EdgeAgentActive represents an active state for a tunnel connected to an Edge endpoint + // EdgeAgentActive represents an active state for a tunnel connected to an Edge environment(endpoint) EdgeAgentActive string = "ACTIVE" ) diff --git a/api/scheduler/scheduler.go b/api/scheduler/scheduler.go new file mode 100644 index 000000000..6568f753f --- /dev/null +++ b/api/scheduler/scheduler.go @@ -0,0 +1,73 @@ +package scheduler + +import ( + "context" + "log" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/robfig/cron/v3" +) + +type Scheduler struct { + crontab *cron.Cron + shutdownCtx context.Context +} + +func NewScheduler(ctx context.Context) *Scheduler { + crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger))) + crontab.Start() + + s := &Scheduler{ + crontab: crontab, + } + + if ctx != nil { + go func() { + <-ctx.Done() + s.Shutdown() + }() + } + + return s +} + +// Shutdown stops the scheduler and waits for it to stop if it is running; otherwise does nothing. +func (s *Scheduler) Shutdown() error { + if s.crontab == nil { + return nil + } + + log.Println("[DEBUG] Stopping scheduler") + ctx := s.crontab.Stop() + <-ctx.Done() + + for _, j := range s.crontab.Entries() { + s.crontab.Remove(j.ID) + } + + err := ctx.Err() + if err == context.Canceled { + return nil + } + return err +} + +// StopJob stops the job from being run in the future +func (s *Scheduler) StopJob(jobID string) error { + id, err := strconv.Atoi(jobID) + if err != nil { + return errors.Wrapf(err, "failed convert jobID %q to int", jobID) + } + s.crontab.Remove(cron.EntryID(id)) + + return nil +} + +// StartJobEvery schedules a new periodic job with a given duration. +// Returns job id that could be used to stop the given job +func (s *Scheduler) StartJobEvery(duration time.Duration, job func()) string { + entryId := s.crontab.Schedule(cron.Every(duration), cron.FuncJob(job)) + return strconv.Itoa(int(entryId)) +} diff --git a/api/scheduler/scheduler_test.go b/api/scheduler/scheduler_test.go new file mode 100644 index 000000000..6d21e49ec --- /dev/null +++ b/api/scheduler/scheduler_test.go @@ -0,0 +1,57 @@ +package scheduler + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func Test_CanStartAndTerminate(t *testing.T) { + s := NewScheduler(context.Background()) + s.StartJobEvery(1*time.Minute, func() { fmt.Println("boop") }) + + err := s.Shutdown() + assert.NoError(t, err, "Shutdown should return no errors") + assert.Empty(t, s.crontab.Entries(), "all jobs should have been removed") +} + +func Test_CanTerminateByCancellingContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + s := NewScheduler(ctx) + s.StartJobEvery(1*time.Minute, func() { fmt.Println("boop") }) + + cancel() + + for i := 0; i < 100; i++ { + if len(s.crontab.Entries()) == 0 { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("all jobs are expected to be cleaned by now; it might be a timing issue, otherwise implementation defect") +} + +func Test_StartAndStopJob(t *testing.T) { + s := NewScheduler(context.Background()) + defer s.Shutdown() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + + var jobOne string + var workDone bool + jobOne = s.StartJobEvery(time.Second, func() { + assert.Equal(t, 1, len(s.crontab.Entries()), "scheduler should have one active job") + workDone = true + + s.StopJob(jobOne) + cancel() + }) + + <-ctx.Done() + assert.True(t, workDone, "value should been set in the job") + assert.Equal(t, 0, len(s.crontab.Entries()), "scheduler should have no active jobs") + +} diff --git a/api/stacks/deploy.go b/api/stacks/deploy.go new file mode 100644 index 000000000..797a415e3 --- /dev/null +++ b/api/stacks/deploy.go @@ -0,0 +1,138 @@ +package stacks + +import ( + "strings" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/security" +) + +func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) error { + stack, err := datastore.Stack().Stack(stackID) + if err != nil { + return errors.WithMessagef(err, "failed to get the stack %v", stackID) + } + + if stack.GitConfig == nil { + return nil // do nothing if it isn't a git-based stack + } + + username, password := "", "" + if stack.GitConfig.Authentication != nil { + username, password = stack.GitConfig.Authentication.Username, stack.GitConfig.Authentication.Password + } + + newHash, err := gitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, username, password) + if err != nil { + return errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID) + } + + if strings.EqualFold(newHash, string(stack.GitConfig.ConfigHash)) { + return nil + } + + cloneParams := &cloneRepositoryParameters{ + url: stack.GitConfig.URL, + ref: stack.GitConfig.ReferenceName, + toDir: stack.ProjectPath, + } + if stack.GitConfig.Authentication != nil { + cloneParams.auth = &gitAuth{ + username: username, + password: password, + } + } + + if err := cloneGitRepository(gitService, cloneParams); err != nil { + return errors.WithMessagef(err, "failed to do a fresh clone of the stack %v", stack.ID) + } + + endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID) + if err != nil { + return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID) + } + + author := stack.UpdatedBy + if author == "" { + author = stack.CreatedBy + } + + registries, err := getUserRegistries(datastore, author, endpoint.ID) + if err != nil { + return err + } + + switch stack.Type { + case portainer.DockerComposeStack: + err := deployer.DeployComposeStack(stack, endpoint, registries) + if err != nil { + return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID) + } + case portainer.DockerSwarmStack: + err := deployer.DeploySwarmStack(stack, endpoint, registries, true) + if err != nil { + return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID) + } + default: + return errors.Errorf("cannot update stack, type %v is unsupported", stack.Type) + } + + stack.UpdateDate = time.Now().Unix() + stack.GitConfig.ConfigHash = newHash + if err := datastore.Stack().UpdateStack(stack.ID, stack); err != nil { + return errors.WithMessagef(err, "failed to update the stack %v", stack.ID) + } + + return nil +} + +func getUserRegistries(datastore portainer.DataStore, authorUsername string, endpointID portainer.EndpointID) ([]portainer.Registry, error) { + registries, err := datastore.Registry().Registries() + if err != nil { + return nil, errors.WithMessage(err, "unable to retrieve registries from the database") + } + + user, err := datastore.User().UserByUsername(authorUsername) + if err != nil { + return nil, errors.WithMessagef(err, "failed to fetch a stack's author [%s]", authorUsername) + } + + if user.Role == portainer.AdministratorRole { + return registries, nil + } + + userMemberships, err := datastore.TeamMembership().TeamMembershipsByUserID(user.ID) + if err != nil { + return nil, errors.WithMessagef(err, "failed to fetch memberships of the stack author [%s]", authorUsername) + } + + filteredRegistries := make([]portainer.Registry, 0, len(registries)) + for _, registry := range registries { + if security.AuthorizedRegistryAccess(®istry, user, userMemberships, endpointID) { + filteredRegistries = append(filteredRegistries, registry) + } + } + + return filteredRegistries, nil +} + +type cloneRepositoryParameters struct { + url string + ref string + toDir string + auth *gitAuth +} + +type gitAuth struct { + username string + password string +} + +func cloneGitRepository(gitService portainer.GitService, cloneParams *cloneRepositoryParameters) error { + if cloneParams.auth != nil { + return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, cloneParams.auth.username, cloneParams.auth.password) + } + return gitService.CloneRepository(cloneParams.toDir, cloneParams.url, cloneParams.ref, "", "") +} diff --git a/api/stacks/deploy_test.go b/api/stacks/deploy_test.go new file mode 100644 index 000000000..6ccb9beea --- /dev/null +++ b/api/stacks/deploy_test.go @@ -0,0 +1,221 @@ +package stacks + +import ( + "errors" + "io/ioutil" + "strings" + "testing" + + portainer "github.com/portainer/portainer/api" + bolt "github.com/portainer/portainer/api/bolt/bolttest" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/stretchr/testify/assert" +) + +type gitService struct { + cloneErr error + id string +} + +func (g *gitService) CloneRepository(destination, repositoryURL, referenceName, username, password string) error { + return g.cloneErr +} + +func (g *gitService) LatestCommitID(repositoryURL, referenceName, username, password string) (string, error) { + return g.id, nil +} + +type noopDeployer struct{} + +func (s *noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error { + return nil +} + +func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { + return nil +} + +func Test_redeployWhenChanged_FailsWhenCannotFindStack(t *testing.T) { + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := RedeployWhenChanged(1, nil, store, nil) + assert.Error(t, err) + assert.Truef(t, strings.HasPrefix(err.Error(), "failed to get the stack"), "it isn't an error we expected: %v", err.Error()) +} + +func Test_redeployWhenChanged_DoesNothingWhenNotAGitBasedStack(t *testing.T) { + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := store.Stack().CreateStack(&portainer.Stack{ID: 1}) + assert.NoError(t, err, "failed to create a test stack") + + err = RedeployWhenChanged(1, nil, store, &gitService{nil, ""}) + assert.NoError(t, err) +} + +func Test_redeployWhenChanged_DoesNothingWhenNoGitChanges(t *testing.T) { + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + tmpDir, _ := ioutil.TempDir("", "stack") + + err := store.Stack().CreateStack(&portainer.Stack{ + ID: 1, + ProjectPath: tmpDir, + GitConfig: &gittypes.RepoConfig{ + URL: "url", + ReferenceName: "ref", + ConfigHash: "oldHash", + }}) + assert.NoError(t, err, "failed to create a test stack") + + err = RedeployWhenChanged(1, nil, store, &gitService{nil, "oldHash"}) + assert.NoError(t, err) +} + +func Test_redeployWhenChanged_FailsWhenCannotClone(t *testing.T) { + cloneErr := errors.New("failed to clone") + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + err := store.Stack().CreateStack(&portainer.Stack{ + ID: 1, + GitConfig: &gittypes.RepoConfig{ + URL: "url", + ReferenceName: "ref", + ConfigHash: "oldHash", + }}) + assert.NoError(t, err, "failed to create a test stack") + + err = RedeployWhenChanged(1, nil, store, &gitService{cloneErr, "newHash"}) + assert.Error(t, err) + assert.ErrorIs(t, err, cloneErr, "should failed to clone but didn't, check test setup") +} + +func Test_redeployWhenChanged(t *testing.T) { + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + tmpDir, _ := ioutil.TempDir("", "stack") + + err := store.Endpoint().CreateEndpoint(&portainer.Endpoint{ID: 1}) + assert.NoError(t, err, "error creating environment") + + username := "user" + err = store.User().CreateUser(&portainer.User{Username: username, Role: portainer.AdministratorRole}) + assert.NoError(t, err, "error creating a user") + + stack := portainer.Stack{ + ID: 1, + EndpointID: 1, + ProjectPath: tmpDir, + UpdatedBy: username, + GitConfig: &gittypes.RepoConfig{ + URL: "url", + ReferenceName: "ref", + ConfigHash: "oldHash", + }} + err = store.Stack().CreateStack(&stack) + assert.NoError(t, err, "failed to create a test stack") + + t.Run("can deploy docker compose stack", func(t *testing.T) { + stack.Type = portainer.DockerComposeStack + store.Stack().UpdateStack(stack.ID, &stack) + + err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"}) + assert.NoError(t, err) + }) + + t.Run("can deploy docker swarm stack", func(t *testing.T) { + stack.Type = portainer.DockerSwarmStack + store.Stack().UpdateStack(stack.ID, &stack) + + err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"}) + assert.NoError(t, err) + }) + + t.Run("can NOT deploy kube stack", func(t *testing.T) { + stack.Type = portainer.KubernetesStack + store.Stack().UpdateStack(stack.ID, &stack) + + err = RedeployWhenChanged(1, &noopDeployer{}, store, &gitService{nil, "newHash"}) + assert.EqualError(t, err, "cannot update stack, type 3 is unsupported") + }) +} + +func Test_getUserRegistries(t *testing.T) { + store, teardown := bolt.MustNewTestStore(true) + defer teardown() + + endpointID := 123 + + admin := portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole} + err := store.User().CreateUser(&admin) + assert.NoError(t, err, "error creating an admin") + + user := portainer.User{ID: 2, Username: "user", Role: portainer.StandardUserRole} + err = store.User().CreateUser(&user) + assert.NoError(t, err, "error creating a user") + + team := portainer.Team{ID: 1, Name: "team"} + + store.TeamMembership().CreateTeamMembership(&portainer.TeamMembership{ + ID: 1, + UserID: user.ID, + TeamID: team.ID, + Role: portainer.TeamMember, + }) + + registryReachableByUser := portainer.Registry{ + ID: 1, + RegistryAccesses: portainer.RegistryAccesses{ + portainer.EndpointID(endpointID): { + UserAccessPolicies: map[portainer.UserID]portainer.AccessPolicy{ + user.ID: {RoleID: portainer.RoleID(portainer.StandardUserRole)}, + }, + }, + }, + } + err = store.Registry().CreateRegistry(®istryReachableByUser) + assert.NoError(t, err, "couldn't create a registry") + + registryReachableByTeam := portainer.Registry{ + ID: 2, + RegistryAccesses: portainer.RegistryAccesses{ + portainer.EndpointID(endpointID): { + TeamAccessPolicies: map[portainer.TeamID]portainer.AccessPolicy{ + team.ID: {RoleID: portainer.RoleID(portainer.StandardUserRole)}, + }, + }, + }, + } + err = store.Registry().CreateRegistry(®istryReachableByTeam) + assert.NoError(t, err, "couldn't create a registry") + + registryRestricted := portainer.Registry{ + ID: 3, + RegistryAccesses: portainer.RegistryAccesses{ + portainer.EndpointID(endpointID): { + UserAccessPolicies: map[portainer.UserID]portainer.AccessPolicy{ + user.ID + 100: {RoleID: portainer.RoleID(portainer.StandardUserRole)}, + }, + }, + }, + } + err = store.Registry().CreateRegistry(®istryRestricted) + assert.NoError(t, err, "couldn't create a registry") + + t.Run("admin should has access to all registries", func(t *testing.T) { + registries, err := getUserRegistries(store, admin.Username, portainer.EndpointID(endpointID)) + assert.NoError(t, err) + assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam, registryRestricted}, registries) + }) + + t.Run("regular user has access to registries allowed to him and/or his team", func(t *testing.T) { + registries, err := getUserRegistries(store, user.Username, portainer.EndpointID(endpointID)) + assert.NoError(t, err) + assert.ElementsMatch(t, []portainer.Registry{registryReachableByUser, registryReachableByTeam}, registries) + }) +} diff --git a/api/stacks/deployer.go b/api/stacks/deployer.go new file mode 100644 index 000000000..c594e48ec --- /dev/null +++ b/api/stacks/deployer.go @@ -0,0 +1,47 @@ +package stacks + +import ( + "context" + "sync" + + portainer "github.com/portainer/portainer/api" +) + +type StackDeployer interface { + DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error + DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error +} + +type stackDeployer struct { + lock *sync.Mutex + swarmStackManager portainer.SwarmStackManager + composeStackManager portainer.ComposeStackManager +} + +func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager) *stackDeployer { + return &stackDeployer{ + lock: &sync.Mutex{}, + swarmStackManager: swarmStackManager, + composeStackManager: composeStackManager, + } +} + +func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error { + d.lock.Lock() + defer d.lock.Unlock() + + d.swarmStackManager.Login(registries, endpoint) + defer d.swarmStackManager.Logout(endpoint) + + return d.swarmStackManager.Deploy(stack, prune, endpoint) +} + +func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error { + d.lock.Lock() + defer d.lock.Unlock() + + d.swarmStackManager.Login(registries, endpoint) + defer d.swarmStackManager.Logout(endpoint) + + return d.composeStackManager.Up(context.TODO(), stack, endpoint) +} diff --git a/api/stacks/scheduled.go b/api/stacks/scheduled.go new file mode 100644 index 000000000..fb90ca22c --- /dev/null +++ b/api/stacks/scheduled.go @@ -0,0 +1,34 @@ +package stacks + +import ( + "log" + "time" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/scheduler" +) + +func StartStackSchedules(scheduler *scheduler.Scheduler, stackdeployer StackDeployer, datastore portainer.DataStore, gitService portainer.GitService) error { + stacks, err := datastore.Stack().RefreshableStacks() + if err != nil { + return errors.Wrap(err, "failed to fetch refreshable stacks") + } + for _, stack := range stacks { + d, err := time.ParseDuration(stack.AutoUpdate.Interval) + if err != nil { + return errors.Wrap(err, "Unable to parse auto update interval") + } + jobID := scheduler.StartJobEvery(d, func() { + if err := RedeployWhenChanged(stack.ID, stackdeployer, datastore, gitService); err != nil { + log.Printf("[ERROR] %s\n", err) + } + }) + + stack.AutoUpdate.JobID = jobID + if err := datastore.Stack().UpdateStack(stack.ID, &stack); err != nil { + return errors.Wrap(err, "failed to update stack job id") + } + } + return nil +} diff --git a/api/swagger.yaml b/api/swagger.yaml index 6a9f489e6..ae48df4ef 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -1003,6 +1003,8 @@ definitions: type: boolean UseServerMetrics: type: boolean + RestrictDefaultNamespace: + type: boolean type: object portainer.KubernetesData: properties: @@ -1187,17 +1189,22 @@ definitions: TeamAccessPolicies: $ref: '#/definitions/portainer.TeamAccessPolicies' Type: - description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab) + description: Registry Type (1 - Quay, 2 - Azure, 3 - Custom, 4 - Gitlab, 5 - ProGet) enum: - 1 - 2 - 3 - 4 + - 5 type: integer URL: description: URL or IP address of the Docker registry example: registry.mydomain.tld:2375 type: string + BaseURL: + description: Base URL or IP address of the ProGet registry + example: registry.mydomain.tld:2375 + type: string UserAccessPolicies: $ref: '#/definitions/portainer.UserAccessPolicies' Username: @@ -1827,18 +1834,23 @@ definitions: type: string type: description: 'Registry Type. Valid values are: 1 (Quay.io), 2 (Azure container - registry), 3 (custom registry) or 4 (Gitlab registry)' + registry), 3 (custom registry), 4 (Gitlab registry) or 5 (ProGet registry)' enum: - 1 - 2 - 3 - 4 + - 5 example: 1 type: integer url: description: URL or IP address of the Docker registry example: registry.mydomain.tld:2375 type: string + baseUrl: + description: Base URL or IP address of the ProGet registry + example: registry.mydomain.tld:2375 + type: string username: description: Username used to authenticate against this registry. Required when Authentication is true @@ -1871,6 +1883,10 @@ definitions: description: URL or IP address of the Docker registry example: registry.mydomain.tld:2375 type: string + baseUrl: + description: Base URL or IP address of the ProGet registry + example: registry.mydomain.tld:2375 + type: string userAccessPolicies: $ref: '#/definitions/portainer.UserAccessPolicies' username: @@ -2486,7 +2502,7 @@ info: email: info@portainer.io description: | Portainer API is an HTTP API served by Portainer. It is used by the Portainer UI and everything you can do with the UI can be done using the HTTP API. - Examples are available at https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8 + Examples are available at https://documentation.portainer.io/api/api-examples/ You can find out more about Portainer at [http://portainer.io](http://portainer.io) and get some support on [Slack](http://portainer.io/slack/). # Authentication @@ -2537,7 +2553,7 @@ info: To do so, you can use the `/endpoints/{id}/docker` Portainer API endpoint (which is not documented below due to Swagger limitations). This endpoint has a restricted access policy so you still need to be authenticated to be able to query this endpoint. Any query on this endpoint will be proxied to the Docker API of the associated endpoint (requests and responses objects are the same as documented in the Docker API). - **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). + **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://documentation.portainer.io/api/api-examples/). license: {} title: PortainerCE API version: 2.0.0 diff --git a/app/__module.js b/app/__module.js index bc835f9d7..074121e19 100644 --- a/app/__module.js +++ b/app/__module.js @@ -4,7 +4,7 @@ import '@babel/polyfill'; import angular from 'angular'; import './matomo-setup'; -import './assets/js/angulartics-matomo'; +import analyticsModule from './angulartics.matomo'; import './agent'; import './azure/_module'; @@ -39,7 +39,7 @@ angular.module('portainer', [ 'rzModule', 'moment-picker', 'angulartics', - 'angulartics.matomo', + analyticsModule, ]); if (require) { diff --git a/app/agent/rest/dockerhub.js b/app/agent/rest/dockerhub.js index b48481e8a..369feda3e 100644 --- a/app/agent/rest/dockerhub.js +++ b/app/agent/rest/dockerhub.js @@ -4,10 +4,10 @@ angular.module('portainer.agent').factory('AgentDockerhub', AgentDockerhub); function AgentDockerhub($resource, API_ENDPOINT_ENDPOINTS) { return $resource( - `${API_ENDPOINT_ENDPOINTS}/:endpointId/:endpointType/v2/dockerhub`, + `${API_ENDPOINT_ENDPOINTS}/:endpointId/agent/:endpointType/v2/dockerhub/:registryId`, {}, { - limits: { method: 'GET' }, + limits: { method: 'GET', params: { registryId: '@registryId' } }, } ); } diff --git a/app/angulartics.matomo/index.js b/app/angulartics.matomo/index.js new file mode 100644 index 000000000..f6a4e528e --- /dev/null +++ b/app/angulartics.matomo/index.js @@ -0,0 +1,194 @@ +import angular from 'angular'; +import _ from 'lodash-es'; + +const basePath = 'http://portainer-ce.app'; + +const dimensions = { + PortainerVersion: 1, + PortainerInstanceID: 2, + PortainerUserRole: 3, + PortainerEndpointUserRole: 4, +}; + +const categories = ['docker', 'kubernetes', 'aci', 'portainer', 'edge']; + +// forked from https://github.com/angulartics/angulartics-piwik/blob/master/src/angulartics-piwik.js + +/** + * @ngdoc overview + * @name angulartics.piwik + * Enables analytics support for Piwik/Matomo (http://piwik.org/docs/tracking-api/) + */ +export default angular.module('angulartics.matomo', ['angulartics']).config(config).name; + +/* @ngInject */ +function config($analyticsProvider, $windowProvider) { + const $window = $windowProvider.$get(); + + $analyticsProvider.settings.pageTracking.trackRelativePath = true; + + $analyticsProvider.api.setPortainerStatus = setPortainerStatus; + + $analyticsProvider.api.setUserRole = setUserRole; + $analyticsProvider.api.clearUserRole = clearUserRole; + + $analyticsProvider.api.setUserEndpointRole = setUserEndpointRole; + $analyticsProvider.api.clearUserEndpointRole = clearUserEndpointRole; + + // scope: visit or page. Defaults to 'page' + $analyticsProvider.api.setCustomVariable = function (varIndex, varName, value, scope = 'page') { + push(['setCustomVariable', varIndex, varName, value, scope]); + }; + + // scope: visit or page. Defaults to 'page' + $analyticsProvider.api.deleteCustomVariable = function (varIndex, scope = 'page') { + $window._paq.push(['deleteCustomVariable', varIndex, scope]); + }; + + // trackSiteSearch(keyword, category, [searchCount]) + $analyticsProvider.api.trackSiteSearch = function (keyword, category, searchCount) { + // keyword is required + if (keyword) { + const params = ['trackSiteSearch', keyword, category || false]; + + // searchCount is optional + if (angular.isDefined(searchCount)) { + params.push(searchCount); + } + + push(params); + } + }; + + // logs a conversion for goal 1. revenue is optional + // trackGoal(goalID, [revenue]); + $analyticsProvider.api.trackGoal = function (goalID, revenue) { + push(['trackGoal', goalID, revenue || 0]); + }; + + // track outlink or download + // linkType is 'link' or 'download', 'link' by default + // trackLink(url, [linkType]); + $analyticsProvider.api.trackLink = function (url, linkType) { + const type = linkType || 'link'; + push(['trackLink', url, type]); + }; + + // Set default angulartics page and event tracking + + $analyticsProvider.registerSetUsername(function (username) { + push(['setUserId', username]); + }); + + // locationObj is the angular $location object + $analyticsProvider.registerPageTrack(function (path) { + push(['setDocumentTitle', $window.document.title]); + push(['setReferrerUrl', '']); + push(['setCustomUrl', basePath + path]); + push(['trackPageView']); + }); + + /** + * @name eventTrack + * Track a basic event in Piwik, or send an ecommerce event. + * + * @param {string} action A string corresponding to the type of event that needs to be tracked. + * @param {object} properties The properties that need to be logged with the event. + */ + $analyticsProvider.registerEventTrack(function trackEvent(action, properties = {}) { + /** + * @description Logs an event with an event category (Videos, Music, Games...), an event + * action (Play, Pause, Duration, Add Playlist, Downloaded, Clicked...), and an optional + * event name and optional numeric value. + * + * @link https://piwik.org/docs/event-tracking/ + * @link https://developer.piwik.org/api-reference/tracking-javascript#using-the-tracker-object + * + * @property {string} category + * @property {string} action + * @property {object} metadata + * @property value (optional) + * @property dimensions (optional) + */ + + let { category, metadata, value, dimensions } = properties; + + // PAQ requires that eventValue be an integer, see: http://piwik.org/docs/event-tracking + if (value) { + const parsed = parseInt(properties.value, 10); + properties.value = isNaN(parsed) ? 0 : parsed; + } + + if (!category) { + throw new Error('missing category'); + } + category = category.toLowerCase(); + + if (!categories.includes(category)) { + throw new Error('unsupported category'); + } + + action = action.toLowerCase(); + + let metadataString = ''; + if (metadata) { + const kebabCasedMetadata = Object.fromEntries(Object.entries(metadata).map(([key, value]) => [_.kebabCase(key), value])); + metadataString = JSON.stringify(kebabCasedMetadata).toLowerCase(); + } + + push([ + 'trackEvent', + category, + action, + metadataString, // Changed in favour of Piwik documentation. Added fallback so it's backwards compatible. + value, + dimensions || {}, + ]); + }); + + /** + * @name exceptionTrack + * Sugar on top of the eventTrack method for easily handling errors + * + * @param {object} error An Error object to track: error.toString() used for event 'action', error.stack used for event 'label'. + * @param {object} cause The cause of the error given from $exceptionHandler, not used. + */ + $analyticsProvider.registerExceptionTrack(function (error) { + push(['trackEvent', 'Exceptions', error.toString(), error.stack, 0]); + }); + + function push(args) { + if ($window._paq) { + $window._paq.push(args); + } + } + + function setPortainerStatus(instanceID, version) { + setCustomDimension(dimensions.PortainerInstanceID, instanceID); + setCustomDimension(dimensions.PortainerVersion, version); + } + + function setUserRole(role) { + setCustomDimension(dimensions.PortainerUserRole, role); + } + + function clearUserRole() { + deleteCustomDimension(dimensions.PortainerUserRole); + } + + function setUserEndpointRole(role) { + setCustomDimension(dimensions.PortainerEndpointUserRole, role); + } + + function clearUserEndpointRole() { + deleteCustomDimension(dimensions.PortainerEndpointUserRole); + } + + function setCustomDimension(dimensionId, value) { + push(['setCustomDimension', dimensionId, value]); + } + + function deleteCustomDimension(dimensionId) { + push(['deleteCustomDimension', dimensionId]); + } +} diff --git a/app/app.js b/app/app.js index bf795ccc6..5e94c74f0 100644 --- a/app/app.js +++ b/app/app.js @@ -31,10 +31,6 @@ angular.module('portainer').run([ HttpRequestHelper.resetAgentHeaders(); }); - $state.defaultErrorHandler(function () { - // Do not log transitionTo errors - }); - // Keep-alive Edge endpoints by sending a ping request every minute $interval(function () { ping(EndpointProvider, SystemService); diff --git a/app/assets/css/app.css b/app/assets/css/app.css index 1989727d0..c75fcd32d 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -6,6 +6,7 @@ body, #view { height: 100%; width: 100%; + overflow-y: initial; } #view { @@ -58,10 +59,10 @@ body, } .form-section-title { - border-bottom: 1px solid #777; + border-bottom: 1px solid var(--border-form-section-title-color); margin-top: 5px; margin-bottom: 15px; - color: #777; + color: var(--text-form-section-title-color); padding-left: 0; } @@ -107,12 +108,33 @@ a[ng-click] { pointer-events: none; } +.datatable-highlighted { + background-color: var(--bg-item-highlighted-color); +} + +.datatable-unhighlighted { + background-color: var(--bg-item-highlighted-null-color); +} + +.service-datatable { + background-color: var(--bg-item-highlighted-color); + padding: 2px; +} + +.service-datatable thead { + background-color: var(--bg-service-datatable-thead) !important; +} + +.service-datatable tbody { + background-color: var(--bg-service-datatable-tbody); +} + .tooltip.portainer-tooltip .tooltip-inner { font-family: Montserrat; - background-color: #ffffff; + background-color: var(--bg-tooltip-color); padding: 0.833em 1em; - color: #333333; - border: 1px solid #d4d4d5; + color: var(--text-tooltip-color); + border: 1px solid var(--border-tooltip-color); border-radius: 0.14285714rem; box-shadow: 0 2px 4px 0 rgba(34, 36, 38, 0.12), 0 2px 10px 0 rgba(34, 36, 38, 0.15); } @@ -144,7 +166,7 @@ a[ng-click] { .fa.blue-icon, .fab.blue-icon { - color: #337ab7; + color: var(--blue-2); } .text-warning { @@ -206,25 +228,25 @@ a[ng-click] { padding: 0.7rem; margin-bottom: 0.7rem; cursor: pointer; - border: 1px solid #cccccc; + border: 1px solid var(--border-blocklist-color); border-radius: 2px; - box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); + box-shadow: var(--shadow-box-color); } .blocklist-item--disabled { cursor: auto; - background-color: #ececec; + background-color: var(--grey-12); } .blocklist-item--selected { - border: 2px solid #bbbbbb; - background-color: #ececec; - color: #2d3e63; + background-color: var(--bg-blocklist-item-selected-color); + border: 2px solid var(--border-blocklist-item-selected-color); + color: var(--text-blocklist-item-selected-color); } .blocklist-item:hover { - background-color: #ececec; - color: #2d3e63; + background-color: var(--bg-blocklist-hover-color); + color: var(--text-blocklist-hover-color); } .blocklist-item-box { @@ -288,6 +310,10 @@ a[ng-click] { padding-top: 15px !important; } +.nomargin { + margin: 0 !important; +} + .terminal-container { width: 100%; padding: 10px 0; @@ -359,7 +385,7 @@ a[ng-click] { .panel-body { padding-top: 30px; - background-color: #ffffff; + background-color: var(--white-color) fff; } .pagination-controls { @@ -375,98 +401,6 @@ a[ng-click] { margin: 0 auto; } -ul.sidebar { - position: relative; - overflow: hidden; - flex-shrink: 0; -} - -ul.sidebar .sidebar-title { - height: auto; -} - -ul.sidebar .sidebar-title.endpoint-name { - color: #fff; - text-align: center; - text-indent: 0; -} - -ul.sidebar .sidebar-list a { - font-size: 14px; -} - -ul.sidebar .sidebar-list a.active { - color: #fff; - text-indent: 22px; - border-left: 3px solid #fff; - background: #2d3e63; -} - -.sidebar-header { - height: 60px; - list-style: none; - text-indent: 20px; - font-size: 18px; - background: #2d3e63; -} - -.sidebar-header a { - color: #fff; -} -.sidebar-header a:hover { - text-decoration: none; -} - -.sidebar-header .menu-icon { - float: right; - padding-right: 28px; - line-height: 60px; -} - -#page-wrapper:not(.open) .sidebar-footer-content { - display: none; -} - -.sidebar-footer-content { - text-align: center; -} - -.sidebar-footer-content .logo { - width: 100%; - max-width: 100px; - height: 100%; - max-height: 35px; - margin: 2px 0 2px 20px; -} - -.sidebar-footer-content .update-notification { - font-size: 14px; - padding: 12px; - border-radius: 2px; - background-color: #ff851b; - margin-bottom: 5px; -} - -.sidebar-footer-content .version { - font-size: 11px; - margin: 11px 20px 0 7px; - color: #fff; -} - -#sidebar-wrapper { - display: flex; - flex-flow: column; -} - -.sidebar-content { - display: flex; - flex-direction: column; - justify-content: space-between; - overflow-y: auto; - overflow-x: hidden; - height: 100%; -} - #image-layers .btn { padding: 0; } @@ -480,86 +414,6 @@ ul.sidebar .sidebar-list a.active { font-size: 90%; } -ul.sidebar .sidebar-list a.active .menu-icon { - text-indent: 25px; -} - -ul.sidebar .sidebar-list .sidebar-sublist a { - text-indent: 35px; - font-size: 12px; - color: #b2bfdc; - line-height: 36px; -} - -ul.sidebar .sidebar-title { - line-height: 36px; -} -ul.sidebar .sidebar-title .form-control { - height: 36px; - padding: 6px 12px; -} - -ul.sidebar .sidebar-list { - height: 36px; -} - -ul.sidebar .sidebar-list a, -ul.sidebar .sidebar-list .sidebar-sublist a { - line-height: 36px; -} - -ul.sidebar .sidebar-list .menu-icon { - line-height: 36px; -} - -ul.sidebar .sidebar-list .sidebar-sublist a.active { - color: #fff; - border-left: 3px solid #fff; - background: #2d3e63; -} - -@media (max-height: 785px) { - ul.sidebar .sidebar-title { - line-height: 26px; - } - ul.sidebar .sidebar-title .form-control { - height: 26px; - padding: 3px 6px; - } - ul.sidebar .sidebar-list { - height: 26px; - } - ul.sidebar .sidebar-list a, - ul.sidebar .sidebar-list .sidebar-sublist a { - font-size: 12px; - line-height: 26px; - } - ul.sidebar .sidebar-list .menu-icon { - line-height: 26px; - } -} - -@media (min-height: 786px) and (max-height: 924px) { - ul.sidebar .sidebar-title { - line-height: 30px; - } - ul.sidebar .sidebar-title .form-control { - height: 30px; - padding: 5px 10px; - } - ul.sidebar .sidebar-list { - height: 30px; - } - ul.sidebar .sidebar-list a, - ul.sidebar .sidebar-list .sidebar-sublist a { - font-size: 12px; - line-height: 30px; - } - ul.sidebar .sidebar-list .menu-icon { - line-height: 30px; - } -} - @media (min-width: 768px) { .margin-sm-top { margin-top: 5px; @@ -614,8 +468,8 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { display: inline-block; padding: 0px 6px; margin-left: 10px; - color: #555555; - background-color: #fff; + color: var(--text-small-select-color); + background-color: var(--bg-small-select-color); background-image: none; border-radius: 4px; font-size: 14px; @@ -625,88 +479,6 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { margin-left: 21px; } -.boxselector_wrapper { - display: flex; - flex-flow: row wrap; - margin: 0.5rem; -} - -.boxselector_wrapper > div { - flex: 1; - padding: 0.5rem; -} - -.boxselector_wrapper .boxselector_header { - font-size: 14px; - margin-bottom: 5px; - font-weight: bold; -} - -.boxselector_header .fa, -.fab { - font-weight: normal; -} - -.boxselector_wrapper input[type='radio'] { - display: none; -} - -.boxselector_wrapper input[type='radio']:not(:disabled) ~ label { - cursor: pointer; -} - -.boxselector_wrapper label { - font-weight: normal; - font-size: 12px; - display: block; - background: white; - border: 1px solid #333333; - border-radius: 2px; - padding: 10px 10px 0 10px; - text-align: center; - box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); - position: relative; -} -.boxselector_wrapper label.boxselector_disabled { - background: #cacaca; - border-color: #787878; - color: #787878; - cursor: not-allowed; -} - -.boxselector_wrapper input[type='radio']:checked + label { - background: #337ab7; - color: white; - padding-top: 2rem; - border-color: #337ab7; -} - -.boxselector_wrapper input[type='radio']:checked + label::after { - color: #337ab7; - font-family: 'Font Awesome 5 Free'; - border: 2px solid #337ab7; - content: '\f00c'; - font-size: 16px; - font-weight: bold; - position: absolute; - top: -15px; - left: 50%; - transform: translateX(-50%); - height: 30px; - width: 30px; - line-height: 26px; - text-align: center; - border-radius: 50%; - background: white; - box-shadow: 0 2px 5px -2px rgba(0, 0, 0, 0.25); -} - -@media only screen and (max-width: 700px) { - .boxselector_wrapper { - flex-direction: column; - } -} - .visualizer_container { display: flex; flex-direction: row; @@ -715,11 +487,11 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { } .visualizer_container .node { - border: 1px dashed #337ab7; + border: 1px dashed var(--blue-2); background-color: rgb(51, 122, 183); background-color: rgba(51, 122, 183, 0.1); border-radius: 4px; - box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); + box-shadow: 0 3px 10px -2px var(--grey-50); padding: 15px; margin: 5px; } @@ -729,7 +501,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { flex-direction: column; justify-content: center; text-align: center; - border-bottom: 1px solid #777; + border-bottom: 1px solid var(--grey-26); padding-bottom: 10px; } @@ -739,7 +511,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { } .visualizer_container .node .node_info .node_labels { - border-top: 1px solid #777; + border-top: 1px solid var(--grey-26); padding-top: 10px; margin-top: 10px; } @@ -756,9 +528,9 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { } .visualizer_container .node .tasks .task { - border: 1px solid #333333; + border: 1px solid var(--grey-6); border-radius: 2px; - box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5); + box-shadow: 0 3px 10px -2px var(--grey-50); padding: 10px; margin: 5px; font-size: 10px; @@ -800,9 +572,9 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { .log_viewer { height: 100%; overflow-y: scroll; - color: black; + color: var(--text-log-viewer-color); font-size: 0.85em; - background-color: white; + background-color: var(--bg-log-viewer-color); } .log_viewer.wrap_lines { @@ -821,7 +593,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { } .log_viewer .line_selected { - background-color: #c5cae9; + background-color: var(--bg-log-line-selected-color); } .row.header .meta .page { @@ -831,7 +603,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { .tag { padding: 2px 6px; color: white; - background-color: #337ab7; + background-color: var(--blue-2); border: 1px solid #2e6da4; border-radius: 4px; } @@ -841,7 +613,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { } .line-separator { - border-bottom: 1px solid #777; + border-bottom: 1px solid var(--grey-26); width: 50%; margin: 20px auto 10px auto; } @@ -866,7 +638,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { } .striked::after { - border-bottom: 0.2em solid #777777; + border-bottom: 0.2em solid var(--grey-26); content: ''; left: 0; margin-top: calc(0.2em / 2 * -1); @@ -878,7 +650,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { .striketext:before, .striketext:after { - background-color: #777777; + background-color: var(--grey-26); content: ''; display: inline-block; height: 1px; @@ -910,20 +682,51 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { /*angular-multi-select override*/ .multiSelect > button { min-height: 30px !important; - background-color: unset; - background-image: unset; + background-image: var(--bg-image-multiselect-button); + border-color: var(--border-multiselect); + color: var(--text-multiselect); + background-color: var(--bg-multiselect-color); +} + +.multiSelect > button:hover { + background-image: var(--bg-image-multiselect-hover); +} + +.multiSelect .checkboxLayer { + border-color: var(--border-multiselect-checkboxlayer); +} + +.multiSelect .checkBoxContainer { + background-color: var(--bg-multiselect-checkboxcontainer); +} + +.multiSelect .multiSelectItem { + color: var(--text-multiselect-item); +} + +.multiSelect .helperContainer { + background-color: var(--bg-multiselect-helpercontainer); +} + +.multiSelect .multiSelectFocus { + background-image: var(--bg-image-multiselect); } .multiSelect .multiSelectItem:not(.multiSelectGroup).selected { - background-image: linear-gradient(#337ab7, #337ab7); - color: #fff; + background-image: var(--bg-image-multiselect); + color: var(--white-color); border: none; } .multiSelect .multiSelectItem:hover, .multiSelect .multiSelectGroup:hover { - background-image: linear-gradient(#337ab7, #337ab7) !important; - color: #fff !important; + border-color: var(--grey-3); +} + +.multiSelect .multiSelectItem:hover, +.multiSelect .multiSelectGroup:hover { + background-image: var(--bg-image-multiselect) !important; + color: var(--white-color) !important; } .multiSelect .tickMark, @@ -949,7 +752,7 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { #loading-bar .bar { position: relative; height: 3px; - background: #738bc0; + background: var(--blue-3); } /*!angular-loading-bar override*/ @@ -961,11 +764,11 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { /* json-tree override */ json-tree { font-size: 13px; - color: #30426a; + color: var(--blue-5); } json-tree .key { - color: #738bc0; + color: var(--blue-3); padding-right: 5px; } @@ -978,7 +781,7 @@ json-tree .branch-preview { /* uib-progressbar override */ .progress-bar { - color: #4e4e4e; + color: var(--text-progress-bar-color); } /* !uib-progressbar override */ @@ -1009,7 +812,7 @@ json-tree .branch-preview { } .sk-fold-cube:before { - background-color: #337ab7; + background-color: var(--blue-2); } /* !spinkit override */ @@ -1028,3 +831,14 @@ json-tree .branch-preview { margin-top: 2rem; margin-bottom: 2rem; } + +.kubectl-shell { + display: block; + text-align: center; + padding-bottom: 5px; +} + +.text-wrap { + word-break: break-all; + white-space: normal; +} diff --git a/app/assets/css/index.js b/app/assets/css/index.js index c035bf022..b812316ab 100644 --- a/app/assets/css/index.js +++ b/app/assets/css/index.js @@ -1,2 +1,4 @@ import './rdash.css'; import './app.css'; +import './theme.css'; +import './vendor-override.css'; diff --git a/app/assets/css/rdash.css b/app/assets/css/rdash.css index 8ee24cd8d..e885c51f3 100644 --- a/app/assets/css/rdash.css +++ b/app/assets/css/rdash.css @@ -14,9 +14,6 @@ padding-left: 70px; } } -#page-wrapper.open #sidebar-wrapper { - left: 150px; -} /** * Hamburg Menu @@ -56,7 +53,7 @@ */ .row.header { height: 60px; - background: #fff; + background: var(--bg-row-header-color); margin-bottom: 15px; } .row.header > div:last-child { @@ -206,9 +203,9 @@ html { overflow-y: scroll; } body { - background: #f3f3f3; + background: var(--bg-body-color); font-family: 'Montserrat'; - color: #333333 !important; + color: var(--text-body-color) !important; } .row { margin-left: 0 !important; @@ -240,7 +237,7 @@ body { background: #23ae89 !important; } .blue { - background: #2361ae !important; + background: var(--blue-color) !important; } .orange { background: #d3a938 !important; @@ -255,139 +252,6 @@ div.input-mask { padding-top: 7px; } -/* #592727 RED */ -/* #2f5927 GREEN */ -/* #30426a BLUE (default)*/ -/* Sidebar background color */ -/* Sidebar header and footer color */ -/* Sidebar title text colour */ -/* Sidebar menu item hover color */ -/** - * Sidebar - */ -#sidebar-wrapper { - background: #30426a; -} -ul.sidebar .sidebar-main a, -.sidebar-footer, -ul.sidebar .sidebar-list a:hover, -#page-wrapper:not(.open) ul.sidebar .sidebar-title.separator { - /* Sidebar header and footer color */ - background: #2d3e63; -} -ul.sidebar { - position: absolute; - top: 0; - bottom: 0; - padding: 0; - margin: 0; - list-style: none; - text-indent: 20px; - overflow-x: hidden; - overflow-y: auto; -} -ul.sidebar li a { - color: #fff; - display: block; - float: left; - text-decoration: none; - width: 250px; -} -ul.sidebar .sidebar-main { - height: 65px; -} -ul.sidebar .sidebar-main a { - font-size: 18px; - line-height: 60px; -} -ul.sidebar .sidebar-main a:hover { - cursor: pointer; -} -ul.sidebar .sidebar-main .menu-icon { - float: right; - font-size: 18px; - padding-right: 28px; - line-height: 60px; -} -ul.sidebar .sidebar-title { - color: #738bc0; - font-size: 12px; - height: 35px; - line-height: 40px; - text-transform: uppercase; - transition: all 0.6s ease 0s; -} -ul.sidebar .sidebar-list { - height: 40px; -} -ul.sidebar .sidebar-list a { - text-indent: 25px; - font-size: 15px; - color: #b2bfdc; - line-height: 40px; -} -ul.sidebar .sidebar-list a:hover { - color: #fff; - border-left: 3px solid #e99d1a; - text-indent: 22px; -} -ul.sidebar .sidebar-list a:hover .menu-icon { - text-indent: 25px; -} -ul.sidebar .sidebar-list .menu-icon { - float: right; - padding-right: 29px; - line-height: 40px; - width: 70px; -} -#page-wrapper:not(.open) ul.sidebar { - bottom: 0; -} -#page-wrapper:not(.open) ul.sidebar .sidebar-title { - display: none; - height: 0px; - text-indent: -100px; -} -#page-wrapper:not(.open) ul.sidebar .sidebar-title.separator { - display: block; - height: 2px; - margin: 13px 0; -} -#page-wrapper:not(.open) ul.sidebar .sidebar-list a:hover span { - border-left: 3px solid #e99d1a; - text-indent: 22px; -} -#page-wrapper:not(.open) .sidebar-footer { - display: none; -} -.sidebar-footer { - position: absolute; - height: 40px; - bottom: 0; - width: 100%; - padding: 0; - margin: 0; - transition: all 0.6s ease 0s; - text-align: center; -} -.sidebar-footer div a { - color: #b2bfdc; - font-size: 12px; - line-height: 43px; -} -.sidebar-footer div a:hover { - color: #ffffff; - text-decoration: none; -} - -/* #592727 RED */ -/* #2f5927 GREEN */ -/* #30426a BLUE (default)*/ -/* Sidebar background color */ -/* Sidebar header and footer color */ -/* Sidebar title text colour */ -/* Sidebar menu item hover color */ - /** * Widgets */ @@ -395,20 +259,20 @@ ul.sidebar .sidebar-list .menu-icon { -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); - background: #ffffff; + background: var(--bg-widget-color); border: 1px solid transparent; border-radius: 2px; - border-color: #e9e9e9; + border-color: var(--border-widget-color); } .widget .widget-header .pagination, .widget .widget-footer .pagination { margin: 0; } .widget .widget-header { - color: #767676; - background-color: #f6f6f6; + color: var(--text-widget-header-color); + background-color: var(--bg-widget-header-color); padding: 10px 15px; - border-bottom: 1px solid #e9e9e9; + border-bottom: 1px solid var(--border-widget-color); line-height: 30px; } .widget .widget-header i { @@ -418,7 +282,7 @@ ul.sidebar .sidebar-list .menu-icon { padding: 20px; } .widget .widget-body table thead { - background: #fafafa; + background: var(--bg-widget-table-color); } .widget .widget-body table thead * { font-size: 14px !important; diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css new file mode 100644 index 000000000..b186f81c6 --- /dev/null +++ b/app/assets/css/theme.css @@ -0,0 +1,576 @@ +/* Color Variable */ +html { + --black-color: #000; + --white-color: #fff; + + --grey-1: #212121; + --grey-2: #181818; + --grey-3: #383838; + --grey-4: #585858; + --grey-5: #323c48; + --grey-6: #333333; + --grey-7: #767676; + --grey-8: #aaa; + --grey-9: #f3f3f3; + --grey-10: #f6f6f6; + --grey-11: #eeeeee; + --grey-12: #ececec; + --grey-13: #fafafa; + --grey-14: #f5f5f5; + --grey-15: #f9f2f4; + --grey-16: #eee; + --grey-17: #f7f7f7; + --grey-18: #c5cae9; + --grey-19: #ddd; + --grey-20: #dae3f3; + --grey-21: #d5e8f3; + --grey-22: #c3c3e4; + --grey-23: #e7f6ff; + --grey-24: #f1f9fd; + --grey-25: #555555; + --grey-26: #777777; + --grey-27: #4e4e4e; + --grey-28: #262626; + --grey-29: #555; + --grey-30: #444; + --grey-31: #868686; + --grey-32: #65798e; + --grey-34: #314252; + --grey-35: #546477; + --grey-36: #55637d; + --grey-37: #2d3e63; + --grey-38: #434343; + --grey-39: #194973; + --grey-40: #cfddfc; + --grey-41: #b4b4b4; + --grey-42: #d2d1d1; + --grey-43: #e9e9e9; + --grey-44: #ccc; + --grey-45: #e5e5e5; + --grey-46: #bbbbbb; + --grey-47: #d4d4d5; + --grey-48: #c6c6c6; + --grey-49: rgba(0, 0, 0, 0.54); + --grey-50: rgba(161, 170, 166, 0.5); + --grey-51: rgba(0, 0, 0, 0.15); + --grey-52: rgba(255, 255, 255, 0.3); + --grey-53: rgba(255, 255, 255, 0.6); + --grey-54: rgb(54, 54, 54); + --grey-55: rgba(255, 255, 255, 0.8); + --grey-56: #b2bfdc; + --grey-57: #999; + --grey-58: #ebf4f8; + --grey-59: #e6e6e6; + --grey-60: #cacaca; + + --blue-1: #219; + --blue-2: #337ab7; + --blue-3: #738bc0; + --blue-4: #23527c; + --blue-5: #30426a; + --blue-6: #577bc9; + --blue-7: #6b9aff; + --blue-8: #90ccff; + --blue-9: #3ea6ff; + --blue-10: #61b6ff; + --blue-11: #3ea5ff; + --blue-12: #41a6ff; + --blue-13: #2361ae; + --blue-14: #357ebd; + + --red-1: #a94442; + --red-2: #c7254e; + --red-3: #a11; + --red-4: #d9534f; + --red-5: #ff2727; + --red-6: #ff00e0; + --red-7: #f00; + + --green-1: #164; + --green-2: #1ec863; +} + +:root { + --bg-card-color: var(--grey-10); + --bg-main-color: var(--white-color); + --bg-body-color: var(--grey-9); + --bg-checkbox-border-color: var(--grey-49); + --bg-sidebar-color: var(--grey-37); + --bg-sidebar-header-color: var(--grey-37); + --bg-widget-color: var(--white-color); + --bg-widget-header-color: var(--grey-10); + --bg-widget-table-color: var(--grey-13); + --bg-header-color: var(--white-color); + --bg-hover-table-color: var(--grey-14); + --bg-switch-box-color: var(--white-color); + --bg-input-group-addon-color: var(--grey-11); + --bg-btn-default-color: var(--white-color); + --bg-blocklist-hover-color: var(--grey-12); + --bg-boxselector-color: var(--white-color); + --bg-table-color: var(--white-color); + --bg-md-checkbox-color: var(--grey-12); + --bg-form-control-disabled-color: var(--grey-11); + --bg-modal-content-color: var(--white-color); + --bg-code-color: var(--grey-15); + --bg-navtabs-color: var(--white-color); + --bg-navtabs-hover-color: var(--grey-16); + --bg-table-selected-color: var(--grey-14); + --bg-codemirror-gutters-color: var(--grey-17); + --bg-dropdown-menu-color: var(--white-color); + --bg-log-viewer-color: var(--white-color); + --bg-log-line-selected-color: var(--grey-18); + --bg-pre-color: var(--grey-14); + --bg-blocklist-item-selected-color: var(--grey-12); + --bg-progress-color: var(--grey-14); + --bg-pagination-color: var(--white-color); + --bg-pagination-span-color: var(--white-color); + --bg-pagination-hover-color: var(--grey-11); + --bg-ui-select-hover-color: var(--grey-14); + --bg-motd-body-color: var(--grey-20); + --bg-item-highlighted-color: var(--grey-21); + --bg-item-highlighted-null-color: var(--grey-14); + --bg-row-header-color: var(--white-color); + --bg-image-multiselect-button: linear-gradient(var(--white-color), var(--grey-17)); + --bg-multiselect-checkbox-color: var(--white-color); + --bg-sidebar-wrapper-color: var(--blue-5); + --bg-panel-body-color: var(--white-color); + --bg-codemirror-color: var(--white-color); + --bg-codemirror-selected-color: var(--grey-22); + --bg-multiselect-color: var(--white-color); + --bg-daterangepicker-color: var(--white-color); + --bg-calendar-color: var(--white-color); + --bg-calendar-table-color: var(--white-color); + --bg-daterangepicker-end-date: var(--white-color); + --bg-daterangepicker-hover: var(--grey-16); + --bg-daterangepicker-in-range: var(--grey-58); + --bg-daterangepicker-active: var(--blue-14); + --bg-tooltip-color: var(--white-color); + --bg-input-autofill-color: var(--white-color); + --bg-btn-default-hover-color: var(--grey-59); + --bg-btn-focus: var(--grey-59); + --bg-boxselector-disabled-color: var(--white-color); + --bg-small-select-color: var(--white-color); + + --text-main-color: var(--grey-7); + --text-body-color: var(--grey-6); + --text-sidebar-title-color: var(--blue-3); + --text-widget-header-color: var(--grey-7); + --text-form-control-color: var(--grey-25); + --text-muted-color: var(--grey-26); + --text-link-color: var(--blue-2); + --text-link-hover-color: var(--blue-4); + --text-input-group-addon-color: var(--grey-25); + --text-btn-default-color: var(--grey-6); + --text-blocklist-hover-color: var(--grey-37); + --text-dashboard-item-color: var(--grey-32); + --text-danger-color: var(--red-1); + --text-code-color: var(--red-2); + --text-navtabs-color: var(--grey-25); + --text-form-section-title-color: var(--grey-26); + --text-cm-default-color: var(--blue-1); + --text-cm-meta-color: var(--black-color); + --text-cm-string-color: var(--red-3); + --text-cm-number-color: var(--green-1); + --text-codemirror-color: var(--black-color); + --text-dropdown-menu-color: var(--grey-6); + --text-log-viewer-color: var(--black-color); + --text-json-tree-color: var(--blue-3); + --text-json-tree-leaf-color: var(--blue-5); + --text-json-tree-branch-preview-color: var(--blue-5); + --text-pre-color: var(--grey-6); + --text-blocklist-item-selected-color: var(--grey-37); + --text-progress-bar-color: var(--grey-27); + --text-pagination-color: var(--grey-26); + --text-pagination-span-color: var(--blue-2); + --text-pagination-span-hover-color: var(--blue-4); + --text-ui-select-color: var(--grey-6); + --text-ui-select-hover-color: var(--grey-28); + --text-summary-color: var(--black-color); + --text-multiselect-button-color: var(--grey-29); + --text-multiselect-item-color: var(--grey-30); + --text-sidebar-list-color: var(--grey-56); + --text-rzslider-color: var(--grey-36); + --text-rzslider-limit-color: var(--grey-36); + --text-daterangepicker-end-date: var(--grey-57); + --text-daterangepicker-in-range: var(--black-color); + --text-daterangepicker-active: var(--white-color); + --text-tooltip-color: var(--grey-6); + --text-input-autofill-color: var(--black-color); + --text-button-hover-color: var(--grey-6); + --text-small-select-color: var(--grey-25); + + --border-color: var(--grey-42); + --border-widget-color: var(--grey-43); + --border-sidebar-color: var(--white-color); + --border-form-control-color: var(--grey-44); + --border-table-color: var(--grey-19); + --border-table-top-color: var(--grey-19); + --border-datatable-top-color: var(--grey-10); + --border-blocklist-color: var(--grey-44) ccc; + --border-input-group-addon-color: var(--grey-44); + --border-btn-default-color: var(--grey-44); + --border-boxselector-color: var(--grey-6); + --border-md-checkbox-color: var(--grey-19); + --border-modal-header-color: var(--grey-45); + --border-navtabs-color: var(--grey-19); + --border-form-section-title-color: var(--grey-26); + --border-codemirror-cursor-color: var(--black-color); + --border-codemirror-gutters-color: var(--grey-19); + --border-pre-color: var(--grey-43); + --border-blocklist-item-selected-color: var(--grey-46); + --border-pagination-color: var(--grey-19); + --border-pagination-span-color: var(--grey-19); + --border-pagination-hover-color: var(--grey-19); + --border-multiselect-button-color: var(--grey-48); + --border-searchbar-color: var(--grey-10); + --border-panel-color: var(--white-color); + --border-daterangepicker-color: var(--grey-19); + --border-calendar-table: var(--white-color); + --border-daterangepicker: var(--grey-19); + --border-pre-next-month: var(--black-color); + --border-daterangepicker-after: var(--white-color); + --border-tooltip-color: var(--grey-47); + --border-modal: 0px; + + --hover-sidebar-color: var(--grey-37); + --shadow-box-color: 0 3px 10px -2px var(--grey-50); + --shadow-boxselector-color: 0 3px 10px -2px var(--grey-50); + --blue-color: var(--blue-13); + --button-close-color: var(--black-color); + --button-opacity: 0.2; + --button-opacity-hover: 0.5; + --bg-boxselector-wrapper-color: var(--grey-6); + + --bg-image-multiselect: linear-gradient(var(--blue-2), var(--blue-2)); + --bg-image-multiselect-button: linear-gradient(var(--white-color), var(--grey-17)); + --bg-image-multiselect-hover: linear-gradient(var(--white-color), var(--grey-43)); + --border-multiselect: var(--grey-48); + --border-multiselect-checkboxlayer: var(--grey-51); + --text-multiselect: var(--grey-29); + --text-multiselect-selectitem: var(--white-color); + --bg-multiselect-checkboxcontainer: var(--white-color); + --text-multiselect-item: var(--grey-30); + --bg-multiselect-helpercontainer: var(--white-color); + --text-input-textarea: var(--white-color); + --bg-service-datatable-thead: var(--grey-23); + --bg-service-datatable-tbody: var(--grey-24); +} + +:root[theme='dark'] { + --bg-card-color: var(--grey-1); + --bg-main-color: var(--grey-2); + --bg-body-color: var(--grey-2); + --bg-checkbox-border-color: var(--grey-8); + --bg-sidebar-color: var(--grey-3); + --bg-widget-color: var(--grey-1); + --bg-widget-header-color: var(--grey-1); + --bg-widget-table-color: var(--grey-1); + --bg-header-color: var(--grey-2); + --bg-hover-table-color: var(--grey-3); + --bg-switch-box-color: var(--grey-53); + --bg-input-group-addon-color: var(--grey-3); + --bg-btn-default-color: var(--grey-3); + --bg-blocklist-hover-color: var(--grey-3); + --bg-boxselector-color: var(--grey-54); + --bg-table-color: var(--grey-1); + --bg-md-checkbox-color: var(--grey-31); + --bg-form-control-disabled-color: var(--grey-3); + --bg-modal-content-color: var(--grey-1); + --bg-code-color: var(--red-4); + --bg-navtabs-color: var(--grey-3); + --bg-navtabs-hover-color: var(--grey-3); + --bg-table-selected-color: var(--grey-3); + --bg-codemirror-color: var(--grey-2); + --bg-codemirror-gutters-color: var(--grey-2); + --bg-dropdown-menu-color: var(--grey-1); + --bg-log-viewer-color: var(--grey-2); + --bg-log-line-selected-color: var(--grey-3); + --bg-pre-color: var(--grey-2); + --bg-blocklist-item-selected-color: var(--grey-3); + --bg-progress-color: var(--grey-3); + --bg-pagination-color: var(--grey-3); + --bg-pagination-span-color: var(--grey-3); + --bg-pagination-hover-color: var(--grey-4); + --bg-ui-select-hover-color: var(--grey-3); + --bg-motd-body-color: var(--grey-1); + --bg-item-highlighted-color: var(--grey-2); + --bg-item-highlighted-null-color: var(--grey-2); + --bg-row-header-color: var(--grey-2); + --bg-multiselect-button-color: var(--grey-3); + --bg-image-multiselect-button: none !important; + --bg-multiselect-checkbox-color: var(--grey-3); + --bg-sidebar-wrapper-color: var(--grey-1); + --bg-panel-body-color: var(--grey-1); + --bg-boxselector-wrapper-disabled-color: var(--grey-39); + --bg-codemirror-selected-color: var(--grey-3); + --bg-sidebar-header-color: var(--grey-1); + --bg-multiselect-color: var(--grey-1); + --bg-daterangepicker-color: var(--grey-3); + --bg-calendar-color: var(--grey-3); + --bg-calendar-table-color: var(--grey-3); + --bg-daterangepicker-end-date: var(--grey-4); + --bg-daterangepicker-hover: var(--grey-4); + --bg-daterangepicker-in-range: var(--grey-2); + --bg-daterangepicker-active: var(--blue-14); + --bg-tooltip-color: var(--grey-3); + --bg-input-autofill-color: var(--grey-2); + --bg-btn-default-hover-color: var(--grey-3); + --bg-btn-focus: var(--grey-3); + --bg-boxselector-disabled-color: var(--grey-54); + --bg-small-select-color: var(--grey-2); + + --text-main-color: var(--white-color); + --text-body-color: var(--white-color); + --text-sidebar-title-color: var(--grey-8); + --text-widget-header-color: var(--white-color); + --text-form-control-color: var(--grey-8); + --text-muted-color: var(--grey-8); + --text-link-color: var(--blue-9); + --text-link-hover-color: var(--blue-2); + --text-input-group-addon-color: var(--grey-8); + --text-btn-default-color: var(--grey-8); + --text-blocklist-hover-color: var(--white-color); + --text-dashboard-item-color: var(--blue-2); + --text-danger-color: var(--red-4); + --text-code-color: var(--white-color); + --text-navtabs-color: var(--white-color); + --text-form-section-title-color: var(--grey-8); + --text-cm-default-color: var(--blue-10); + --text-cm-meta-color: var(--white-color); + --text-cm-string-color: var(--red-5); + --text-cm-number-color: var(--green-2); + --text-codemirror-color: var(--white-color); + --text-dropdown-menu-color: var(--white-color); + --text-log-viewer-color: var(--white-color); + --text-json-tree-color: var(--grey-40); + --text-json-tree-leaf-color: var(--blue-6); + --text-json-tree-branch-preview-color: var(--blue-7); + --text-pre-color: var(--white-color); + --text-blocklist-item-selected-color: var(--white-color); + --text-progress-bar-color: var(--white-color); + --text-pagination-color: var(--white-color); + --text-pagination-span-color: var(--white-color); + --text-pagination-span-hover-color: var(--white-color); + --text-ui-select-color: var(--white-color); + --text-ui-select-hover-color: var(--white-color); + --text-summary-color: var(--white-color); + --text-multiselect-button-color: var(--white-color); + --text-multiselect-item-color: var(--white-color); + --text-sidebar-list-color: var(--white-color); + --text-boxselector-wrapper-color: var(--white-color); + --text-daterangepicker-end-date: var(--grey-7); + --text-daterangepicker-in-range: var(--white-color); + --text-daterangepicker-active: var(--white-color); + --text-tooltip-color: var(--white-color); + --text-btn-default-color: var(--white-color); + --text-input-autofill-color: var(--grey-8); + --text-button-hover-color: var(--white-color); + --text-small-select-color: var(--grey-7); + + --border-color: var(--grey-3); + --border-widget-color: var(--grey-1); + --border-sidebar-color: var(--blue-9); + --border-form-control-color: var(--grey-54); + --border-table-color: var(--grey-3); + --border-table-top-color: var(--grey-3); + --border-datatable-top-color: var(--grey-3); + --border-blocklist-color: var(--grey-3); + --border-input-group-addon-color: var(--grey-38); + --border-btn-default-color: var(--grey-38); + --border-boxselector-color: var(--grey-1); + --border-md-checkbox-color: var(--grey-41); + --border-modal-header-color: var(--grey-1); + --border-navtabs-color: var(--grey-38); + --border-form-section-title-color: var(--grey-8); + --border-codemirror-cursor-color: var(--white-color); + --border-codemirror-gutters-color: var(--grey-26); + --border-pre-color: var(--grey-3); + --border-blocklist-item-selected-color: var(--grey-38); + --border-pagination-color: var(--grey-3); + --border-pagination-span-color: var(--grey-3); + --border-pagination-hover-color: var(--grey-3); + --border-pagination-hover-color: var(--grey-3); + --border-multiselect-button-color: var(--grey-3); + --border-searchbar-color: var(--grey-1); + --border-panel-color: var(--grey-2); + --border-daterangepicker-color: var(--grey-3); + --border-calendar-table: var(--grey-3); + --border-daterangepicker: var(--grey-4); + --border-pre-next-month: var(--white-color); + --border-daterangepicker-after: var(--grey-3); + --border-tooltip-color: var(--grey-3); + --border-modal: 0px; + + --hover-sidebar-color: var(--grey-3); + --blue-color: var(--blue-2); + --button-close-color: var(--white-color); + --button-opacity: 0.6; + --button-opacity-hover: 0.3; + --shadow-box-color: none; + --shadow-boxselector-color: none; + + --bg-image-multiselect: linear-gradient(var(--grey-38), var(--grey-38)); + --bg-image-multiselect-button: linear-gradient(var(--grey-1), var(--grey-1)); + --bg-image-multiselect-hover: linear-gradient(var(--grey-3), var(--grey-3)); + --border-multiselect: var(--grey-3); + --border-multiselect-checkboxlayer: var(--grey-3); + --text-multiselect: var(--white-color); + --bg-multiselect-checkboxcontainer: var(--grey-3); + --text-multiselect-item: var(--white-color); + --bg-multiselect-helpercontainer: var(--grey-1); + --text-input-textarea: var(--grey-1); + --bg-service-datatable-thead: var(--grey-1); + --bg-service-datatable-tbody: var(--grey-1); +} + +:root[theme='highcontrast'] { + --bg-card-color: var(--black-color); + --bg-main-color: var(--black-color); + --bg-body-color: var(--black-color); + --bg-checkbox-border-color: var(--grey-8); + --bg-sidebar-color: var(--black-color); + --bg-widget-color: var(--black-color); + --bg-widget-header-color: var(--black-color); + --bg-widget-table-color: var(--black-color); + --bg-header-color: var(--black-color); + --bg-hover-table-color: var(--grey-3); + --bg-switch-box-color: var(--grey-53); + --bg-panel-body-color: var(--black-color); + --bg-boxselector-wrapper-disabled-color: var(--grey-39); + --bg-dropdown-menu-color: var(--black-color); + --bg-codemirror-selected-color: var(--grey-3); + --bg-row-header-color: var(--black-color); + --bg-sidebar-wrapper-color: var(--black-color); + --bg-motd-body-color: var(--black-color); + --bg-blocklist-hover-color: var(--black-color); + --bg-blocklist-item-selected-color: var(--black-color); + --bg-input-group-addon-color: var(--grey-1); + --bg-table-color: var(--black-color); + --bg-codemirror-gutters-color: var(--black-color); + --bg-codemirror-color: var(--black-color); + --bg-codemirror-selected-color: var(--grey-3); + --bg-log-viewer-color: var(--black-color); + --bg-log-line-selected-color: var(--grey-3); + --bg-sidebar-header-color: var(--black-color); + --bg-modal-content-color: var(--black-color); + --bg-form-control-disabled-color: var(--grey-1); + --bg-input-sm-color: var(--black-color); + --bg-item-highlighted-color: var(--black-color); + --bg-service-datatable-thead: var(--black-color); + --bg-service-datatable-tbody: var(--black-color); + --bg-pagination-color: var(--grey-3); + --bg-pagination-span-color: var(--grey-3); + --bg-multiselect-color: var(--grey-1); + --bg-daterangepicker-color: var(--black-color); + --bg-calendar-color: var(--black-color); + --bg-calendar-table-color: var(--black-color); + --bg-daterangepicker-end-date: var(--grey-3); + --bg-daterangepicker-hover: var(--grey-3); + --bg-daterangepicker-in-range: var(--grey-2); + --bg-daterangepicker-active: var(--blue-14); + --bg-tooltip-color: var(--black-color); + --bg-table-selected-color: var(--grey-3); + --bg-pre-color: var(--grey-2); + --bg-navtabs-hover-color: var(--grey-3); + --bg-btn-default-color: var(--black-color); + --bg-code-color: var(--red-4); + --bg-navtabs-color: var(--black-color); + --bg-input-autofill-color: var(--black-color); + --bg-code-color: var(--grey-2); + --bg-navtabs-color: var(--grey-2); + --bg-navtabs-hover-color: var(--grey-3); + --bg-btn-default-hover-color: var(--grey-3); + --bg-btn-default-color: var(--black-color); + --bg-btn-focus: var(--black-color); + --bg-boxselector-color: var(--black-color); + --bg-boxselector-disabled-color: var(--black-color); + --bg-small-select-color: var(--black-color); + + --text-main-color: var(--white-color); + --text-body-color: var(--white-color); + --text-sidebar-title-color: var(--grey-8); + --text-widget-header-color: var(--white-color); + --text-link-color: var(--blue-9); + --text-link-hover-color: var(--blue-9); + --text-danger-color: var(--red-7); + --text-code-color: var(--red-7); + --text-form-control-color: var(--white-color); + --text-blocklist-hover-color: var(--blue-11); + --text-boxselector-wrapper-color: var(--white-color); + --text-dashboard-item-color: var(--blue-12); + --text-form-section-title-color: var(--white-color); + --text-muted-color: var(--white-color); + --text-tooltip-color: var(--white-color); + --text-blocklist-item-selected-color: var(--blue-9); + --text-input-group-addon-color: var(--white-color); + --text-codemirror-color: var(--white-color); + --text-log-viewer-color: var(--white-color); + --text-summary-color: var(--white-color); + --text-rzslider-color: var(--white-color); + --text-rzslider-limit-color: var(--white-color); + --text-pagination-color: var(--white-color); + --text-daterangepicker-end-date: var(--grey-7); + --text-daterangepicker-in-range: var(--white-color); + --text-daterangepicker-active: var(--white-color); + --text-sidebar-list-color: var(--white-color); + --text-ui-select-color: var(--white-color); + --text-btn-default-color: var(--white-color); + --text-json-tree-color: var(--white-color); + --text-json-tree-leaf-color: var(--white-color); + --text-json-tree-branch-preview-color: var(--white-color); + --text-pre-color: var(--white-color); + --text-navtabs-color: var(--white-color); + --text-input-autofill-color: var(--white-color); + --text-navtabs-color: var(--white-color); + --text-button-hover-color: var(--white-color); + --text-btn-default-color: var(--white-color); + --text-small-select-color: var(--white-color); + + --border-color: var(--grey-55); + --border-widget-color: var(--white-color); + --border-sidebar-color: var(--blue-9); + --border-form-control-color: var(--grey-54); + --border-table-color: var(--grey-55); + --border-table-top-color: var(--grey-55); + --border-datatable-top-color: var(--grey-55); + --border-sidebar-high-contrast: 1px solid var(--blue-9); + --border-code-high-contrast: 1px solid var(--white-color); + --border-boxselector-wrapper: 3px solid var(--blue-2); + --border-boxselector-wrapper-hover: 3px solid var(--blue-8); + --border-panel-color: var(--white-color); + --border-input-group-addon-color: var(--grey-54); + --border-modal-header-color: var(--grey-3); + --border-input-sm-color: var(--white-color); + --border-pagination-color: var(--grey-3); + --border-pagination-span-color: var(--grey-3); + --border-daterangepicker-color: var(--white-color); + --border-calendar-table: var(--black-color); + --border-daterangepicker: var(--black-color); + --border-pre-next-month: var(--white-color); + --border-daterangepicker-after: var(--black-color); + --border-tooltip-color: var(--white-color); + --border-pre-color: var(--grey-3); + --border-codemirror-cursor-color: var(--white-color); + --border-modal: 1px solid var(--white-color); + + --hover-sidebar-color: var(--blue-9); + --hover-sidebar-color: var(--black-color); + --shadow-box-color: none; + --shadow-boxselector-color: none; + + --bg-image-multiselect: linear-gradient(var(--black-color), var(--black-color)); + --bg-image-multiselect-button: linear-gradient(var(--grey-1), var(--grey-1)); + --bg-image-multiselect-hover: linear-gradient(var(--grey-3), var(--grey-3)); + --border-multiselect: var(--black-color); + --border-multiselect-checkboxlayer: var(--grey-3); + --text-multiselect: var(--white-color); + --bg-multiselect-checkboxcontainer: var(--grey-3); + --text-multiselect-item: var(--white-color); + --bg-multiselect-helpercontainer: var(--grey-1); + --text-input-textarea: var(--black-color); + --bg-item-highlighted-null-color: var(--grey-2); + --text-cm-default-color: var(--blue-9); + --text-cm-meta-color: var(--white-color); + --text-cm-string-color: var(--red-7); + --text-progress-bar-color: var(--black-color); +} diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css new file mode 100644 index 000000000..21572a7dd --- /dev/null +++ b/app/assets/css/vendor-override.css @@ -0,0 +1,399 @@ +/* Overide Vendor CSS */ +.form-control { + background-color: var(--bg-main-color) !important; + border: 1px solid var(--border-form-control-color); + color: var(--text-form-control-color); +} + +.text-muted { + color: var(--text-muted-color); +} + +.table > thead > tr > th { + border-bottom: 2px solid var(--border-table-color); +} + +.table-hover > tbody > tr:hover { + background-color: var(--bg-hover-table-color); +} + +.switch i, +.bootbox-form .checkbox i { + background: var(--bg-switch-box-color); +} + +.table > thead > tr > th, +.table > tbody > tr > th, +.table > tfoot > tr > th, +.table > thead > tr > td, +.table > tbody > tr > td, +.table > tfoot > tr > td { + border-top: 1px solid var(--border-table-top-color); +} + +a { + color: var(--text-link-color); +} + +a:hover, +a:focus { + color: var(--text-link-hover-color); +} + +.input-group-addon { + color: var(--text-input-group-addon-color); + background-color: var(--bg-input-group-addon-color); + border: 1px solid var(--border-input-group-addon-color); +} + +.btn-default { + color: var(--text-btn-default-color); + background-color: var(--bg-btn-default-color); + border-color: var(--border-btn-default-color); +} + +.text-danger { + color: var(--text-danger-color); +} + +.table .table { + background-color: var(--bg-table-color); +} + +.table-bordered { + border-color: var(--border-table-top-color); +} + +.table-bordered > thead > tr > th, +.table-bordered > tbody > tr > th, +.table-bordered > tfoot > tr > th, +.table-bordered > thead > tr > td, +.table-bordered > tbody > tr > td, +.table-bordered > tfoot > tr > td { + border-color: var(--border-table-top-color); +} + +.md-checkbox input[type='checkbox']:disabled + label:before { + background: var(--bg-md-checkbox-color) !important; + border-color: var(--border-md-checkbox-color) !important; +} + +.form-control[disabled], +.form-control[readonly], +fieldset[disabled] .form-control { + background-color: var(--bg-form-control-disabled-color) !important; +} + +.modal.in .modal-dialog { + border: var(--border-modal); +} + +.modal-content { + background-color: var(--bg-modal-content-color); +} + +.modal-header { + border-bottom: 1px solid var(--border-modal-header-color); +} + +.modal-footer { + border-top: 1px solid var(--border-modal-header-color); +} + +.close { + color: var(--button-close-color); + opacity: var(--button-opacity); +} + +.close:hover, +.close:focus { + color: var(--button-close-color); + opacity: var(--button-opacity-hover); +} + +code { + color: var(--text-code-color); + background-color: var(--bg-code-color); +} + +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:hover, +.nav-tabs > li.active > a:focus { + color: var(--text-navtabs-color); + background-color: var(--bg-navtabs-color); + border: 1px solid var(--border-navtabs-color); +} + +.nav-tabs { + border-bottom: 1px solid var(--border-navtabs-color); +} + +.nav-tabs > li > a:hover { + border-color: var(--border-navtabs-color); +} + +.nav > li > a:hover, +.nav > li > a:focus { + background-color: var(--bg-navtabs-hover-color); +} + +.table > thead > tr > td.active, +.table > tbody > tr > td.active, +.table > tfoot > tr > td.active, +.table > thead > tr > th.active, +.table > tbody > tr > th.active, +.table > tfoot > tr > th.active, +.table > thead > tr.active > td, +.table > tbody > tr.active > td, +.table > tfoot > tr.active > td, +.table > thead > tr.active > th, +.table > tbody > tr.active > th, +.table > tfoot > tr.active > th { + background-color: var(--bg-table-selected-color); +} +.table-hover > tbody > tr > td.active:hover, +.table-hover > tbody > tr > th.active:hover, +.table-hover > tbody > tr.active:hover > td, +.table-hover > tbody > tr:hover > .active, +.table-hover > tbody > tr.active:hover > th { + background-color: var(--bg-table-selected-color); +} + +.CodeMirror-gutters { + background: var(--bg-codemirror-gutters-color); + border-right: 1px solid var(--border-codemirror-gutters-color); +} + +.CodeMirror { + background: var(--bg-codemirror-color); + color: var(--text-codemirror-color); +} + +.CodeMirror-selected { + background: var(--bg-codemirror-selected-color) !important; +} + +.CodeMirror-cursor { + border-left: 1px solid var(--border-codemirror-cursor-color); +} + +.cm-s-default .cm-atom { + color: var(--text-cm-default-color); +} + +.cm-s-default .cm-meta { + color: var(--text-cm-meta-color); +} + +.cm-s-default .cm-string { + color: var(--text-cm-string-color); +} + +.cm-s-default .cm-number { + color: var(--text-cm-number-color); +} + +.dropdown-menu { + background: var(--bg-dropdown-menu-color); +} + +.dropdown-menu > li > a { + color: var(--text-dropdown-menu-color); +} + +pre { + border: 1px solid var(--border-pre-color); + background-color: var(--bg-pre-color); + color: var(--text-pre-color); +} +json-tree .key { + color: var(--text-json-tree-color); +} + +json-tree .leaf-value { + color: var(--text-json-tree-leaf-color); +} + +json-tree .branch-preview { + color: var(--text-json-tree-branch-preview-color); +} + +.progress { + background-color: var(--bg-progress-color); +} + +.pagination > .disabled > span, +.pagination > .disabled > span:hover, +.pagination > .disabled > span:focus, +.pagination > .disabled > a, +.pagination > .disabled > a:hover, +.pagination > .disabled > a:focus { + color: var(--text-pagination-color); + background-color: var(--bg-pagination-color); + border-color: var(--border-pagination-color); +} + +.pagination > li > a, +.pagination > li > span { + background-color: var(--bg-pagination-span-color); + border-color: var(--border-pagination-span-color); + color: var(--text-pagination-span-color); +} + +.pagination > li > a:hover, +.pagination > li > span:hover, +.pagination > li > a:focus, +.pagination > li > span:focus { + background-color: var(--bg-pagination-hover-color); + border-color: var(--border-pagination-hover-color); +} + +.pagination > li > a:hover, +.pagination > li > span:hover, +.pagination > li > a:focus, +.pagination > li > span:focus { + color: var(--text-pagination-span-hover-color); +} + +.ui-select-bootstrap .ui-select-choices-row > span { + color: var(--text-ui-select-color); +} + +.ui-select-bootstrap .ui-select-choices-row > span:hover, +.ui-select-bootstrap .ui-select-choices-row > span:focus { + background-color: var(--bg-ui-select-hover-color); + color: var(--text-ui-select-hover-color); +} + +.motd-body { + background-color: var(--bg-motd-body-color) !important; +} + +.panel-body { + background-color: var(--bg-panel-body-color) !important; +} + +.panel { + border: 1px solid var(--border-panel-color); +} + +.theme-information .col-sm-12 { + padding-left: 0px; + padding-right: 0px; + margin-top: 15px; +} + +.theme-panel { + margin-top: 15px; +} + +.summary { + color: var(--text-summary-color); + font-weight: 700; +} + +.input-sm { + background-color: var(--bg-input-sm-color); + border: 1px solid var(--border-input-sm-color); +} + +.rzslider .rz-bubble { + color: var(--text-rzslider-color); +} + +.rzslider .rz-bubble.rz-limit { + color: var(--text-rzslider-limit-color); +} +input, +button, +select, +textarea { + background: var(--text-input-textarea); +} + +.daterangepicker { + background-color: var(--bg-daterangepicker-color); + border: 1px solid var(--border-daterangepicker-color); +} + +.daterangepicker .drp-calendar.left { + background: var(--bg-calendar-color); +} + +.daterangepicker .drp-calendar.left .calendar-table { + background: var(--bg-calendar-table-color); +} + +.daterangepicker .drp-calendar.right { + background: var(--bg-calendar-color); +} + +.daterangepicker .drp-calendar.right .calendar-table { + background: var(--bg-calendar-table-color); +} + +.daterangepicker .calendar-table { + border: 1px solid var(--border-calendar-table); +} + +.daterangepicker td.off, +.daterangepicker td.off.in-range, +.daterangepicker td.off.start-date, +.daterangepicker td.off.end-date { + background-color: var(--bg-daterangepicker-end-date); + color: var(--text-daterangepicker-end-date); +} + +.daterangepicker td.available:hover, +.daterangepicker th.available:hover { + background-color: var(--bg-daterangepicker-hover); +} + +.daterangepicker td.in-range { + background-color: var(--bg-daterangepicker-in-range); + color: var(--text-daterangepicker-in-range); +} + +.daterangepicker td.active, +.daterangepicker td.active:hover { + background-color: var(--bg-daterangepicker-active); + color: var(--text-daterangepicker-active); +} + +.daterangepicker .drp-buttons { + border-top: 1px solid var(--border-daterangepicker); +} + +.daterangepicker .calendar-table .next span, +.daterangepicker .calendar-table .prev span { + border-color: var(--border-pre-next-month); +} + +.daterangepicker:after { + border-bottom: 6px solid var(--border-daterangepicker-after); +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 30px var(--bg-input-autofill-color) inset !important; + box-shadow: 0 0 0 30px var(--bg-input-autofill-color) inset !important; +} + +input:-webkit-autofill { + -webkit-text-fill-color: var(--text-input-autofill-color) !important; +} + +.btn:hover { + color: var(--text-button-hover-color); +} + +.btn-default:hover { + background-color: var(--bg-btn-default-hover-color); +} + +.btn-primary:hover { + color: var(--white-color) !important; +} +/* Overide Vendor CSS */ diff --git a/app/assets/images/portainer-github-banner.png b/app/assets/images/portainer-github-banner.png new file mode 100644 index 000000000..08776d3e3 Binary files /dev/null and b/app/assets/images/portainer-github-banner.png differ diff --git a/app/assets/js/angulartics-matomo.js b/app/assets/js/angulartics-matomo.js deleted file mode 100644 index 1b6211ca8..000000000 --- a/app/assets/js/angulartics-matomo.js +++ /dev/null @@ -1,223 +0,0 @@ -import angular from 'angular'; - -// forked from https://github.com/angulartics/angulartics-piwik/blob/master/src/angulartics-piwik.js - -/* global _paq */ -/** - * @ngdoc overview - * @name angulartics.piwik - * Enables analytics support for Piwik/Matomo (http://piwik.org/docs/tracking-api/) - */ -angular.module('angulartics.matomo', ['angulartics']).config([ - '$analyticsProvider', - '$windowProvider', - function ($analyticsProvider, $windowProvider) { - var $window = $windowProvider.$get(); - - $analyticsProvider.settings.pageTracking.trackRelativePath = true; - - // Add piwik specific trackers to angulartics API - - // Requires the CustomDimensions plugin for Piwik. - $analyticsProvider.api.setCustomDimension = function (dimensionId, value) { - if ($window._paq) { - $window._paq.push(['setCustomDimension', dimensionId, value]); - } - }; - - // Requires the CustomDimensions plugin for Piwik. - $analyticsProvider.api.deleteCustomDimension = function (dimensionId) { - if ($window._paq) { - $window._paq.push(['deleteCustomDimension', dimensionId]); - } - }; - - // scope: visit or page. Defaults to 'page' - $analyticsProvider.api.setCustomVariable = function (varIndex, varName, value, scope) { - if ($window._paq) { - scope = scope || 'page'; - $window._paq.push(['setCustomVariable', varIndex, varName, value, scope]); - } - }; - - // scope: visit or page. Defaults to 'page' - $analyticsProvider.api.deleteCustomVariable = function (varIndex, scope) { - if ($window._paq) { - scope = scope || 'page'; - $window._paq.push(['deleteCustomVariable', varIndex, scope]); - } - }; - - // trackSiteSearch(keyword, category, [searchCount]) - $analyticsProvider.api.trackSiteSearch = function (keyword, category, searchCount) { - // keyword is required - if ($window._paq && keyword) { - var params = ['trackSiteSearch', keyword, category || false]; - - // searchCount is optional - if (angular.isDefined(searchCount)) { - params.push(searchCount); - } - - $window._paq.push(params); - } - }; - - // logs a conversion for goal 1. revenue is optional - // trackGoal(goalID, [revenue]); - $analyticsProvider.api.trackGoal = function (goalID, revenue) { - if ($window._paq) { - _paq.push(['trackGoal', goalID, revenue || 0]); - } - }; - - // track outlink or download - // linkType is 'link' or 'download', 'link' by default - // trackLink(url, [linkType]); - $analyticsProvider.api.trackLink = function (url, linkType) { - var type = linkType || 'link'; - if ($window._paq) { - $window._paq.push(['trackLink', url, type]); - } - }; - - // Set default angulartics page and event tracking - - $analyticsProvider.registerSetUsername(function (username) { - if ($window._paq) { - $window._paq.push(['setUserId', username]); - } - }); - - // locationObj is the angular $location object - $analyticsProvider.registerPageTrack(function (path) { - if ($window._paq) { - $window._paq.push(['setDocumentTitle', $window.document.title]); - $window._paq.push(['setReferrerUrl', '']); - $window._paq.push(['setCustomUrl', 'http://portainer-ce.app' + path]); - $window._paq.push(['trackPageView']); - } - }); - - /** - * @name eventTrack - * Track a basic event in Piwik, or send an ecommerce event. - * - * @param {string} action A string corresponding to the type of event that needs to be tracked. - * @param {object} properties The properties that need to be logged with the event. - */ - $analyticsProvider.registerEventTrack(function (action, properties) { - if ($window._paq) { - properties = properties || {}; - - switch (action) { - /** - * @description Sets the current page view as a product or category page view. When you call - * setEcommerceView it must be followed by a call to trackPageView to record the product or - * category page view. - * - * @link https://piwik.org/docs/ecommerce-analytics/#tracking-product-page-views-category-page-views-optional - * @link https://developer.piwik.org/api-reference/tracking-javascript#ecommerce - * - * @property productSKU (required) SKU: Product unique identifier - * @property productName (optional) Product name - * @property categoryName (optional) Product category, or array of up to 5 categories - * @property price (optional) Product Price as displayed on the page - */ - case 'setEcommerceView': - $window._paq.push(['setEcommerceView', properties.productSKU, properties.productName, properties.categoryName, properties.price]); - break; - - /** - * @description Adds a product into the ecommerce order. Must be called for each product in - * the order. - * - * @link https://piwik.org/docs/ecommerce-analytics/#tracking-ecommerce-orders-items-purchased-required - * @link https://developer.piwik.org/api-reference/tracking-javascript#ecommerce - * - * @property productSKU (required) SKU: Product unique identifier - * @property productName (optional) Product name - * @property categoryName (optional) Product category, or array of up to 5 categories - * @property price (recommended) Product price - * @property quantity (optional, default to 1) Product quantity - */ - case 'addEcommerceItem': - $window._paq.push(['addEcommerceItem', properties.productSKU, properties.productName, properties.productCategory, properties.price, properties.quantity]); - break; - - /** - * @description Tracks a shopping cart. Call this javascript function every time a user is - * adding, updating or deleting a product from the cart. - * - * @link https://piwik.org/docs/ecommerce-analytics/#tracking-add-to-cart-items-added-to-the-cart-optional - * @link https://developer.piwik.org/api-reference/tracking-javascript#ecommerce - * - * @property grandTotal (required) Cart amount - */ - case 'trackEcommerceCartUpdate': - $window._paq.push(['trackEcommerceCartUpdate', properties.grandTotal]); - break; - - /** - * @description Tracks an Ecommerce order, including any ecommerce item previously added to - * the order. orderId and grandTotal (ie. revenue) are required parameters. - * - * @link https://piwik.org/docs/ecommerce-analytics/#tracking-ecommerce-orders-items-purchased-required - * @link https://developer.piwik.org/api-reference/tracking-javascript#ecommerce - * - * @property orderId (required) Unique Order ID - * @property grandTotal (required) Order Revenue grand total (includes tax, shipping, and subtracted discount) - * @property subTotal (optional) Order sub total (excludes shipping) - * @property tax (optional) Tax amount - * @property shipping (optional) Shipping amount - * @property discount (optional) Discount offered (set to false for unspecified parameter) - */ - case 'trackEcommerceOrder': - $window._paq.push(['trackEcommerceOrder', properties.orderId, properties.grandTotal, properties.subTotal, properties.tax, properties.shipping, properties.discount]); - break; - - /** - * @description Logs an event with an event category (Videos, Music, Games...), an event - * action (Play, Pause, Duration, Add Playlist, Downloaded, Clicked...), and an optional - * event name and optional numeric value. - * - * @link https://piwik.org/docs/event-tracking/ - * @link https://developer.piwik.org/api-reference/tracking-javascript#using-the-tracker-object - * - * @property category - * @property action - * @property name (optional, recommended) - * @property value (optional) - */ - default: - // PAQ requires that eventValue be an integer, see: http://piwik.org/docs/event-tracking - if (properties.value) { - var parsed = parseInt(properties.value, 10); - properties.value = isNaN(parsed) ? 0 : parsed; - } - - $window._paq.push([ - 'trackEvent', - properties.category, - action, - properties.name || properties.label, // Changed in favour of Piwik documentation. Added fallback so it's backwards compatible. - properties.value, - ]); - } - } - }); - - /** - * @name exceptionTrack - * Sugar on top of the eventTrack method for easily handling errors - * - * @param {object} error An Error object to track: error.toString() used for event 'action', error.stack used for event 'label'. - * @param {object} cause The cause of the error given from $exceptionHandler, not used. - */ - $analyticsProvider.registerExceptionTrack(function (error) { - if ($window._paq) { - $window._paq.push(['trackEvent', 'Exceptions', error.toString(), error.stack, 0]); - } - }); - }, -]); diff --git a/app/azure/_module.js b/app/azure/_module.js index 30fd789b9..10c6cf60c 100644 --- a/app/azure/_module.js +++ b/app/azure/_module.js @@ -20,7 +20,7 @@ angular.module('portainer.azure', ['portainer.app']).config([ EndpointProvider.setOfflineModeFromStatus(endpoint.Status); await StateManager.updateEndpointState(endpoint, []); } catch (e) { - Notifications.error('Failed loading endpoint', e); + Notifications.error('Failed loading environment', e); $state.go('portainer.home', {}, { reload: true }); } }); diff --git a/app/azure/components/azure-sidebar-content/azure-sidebar-content.js b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js deleted file mode 100644 index 1cdf11ca3..000000000 --- a/app/azure/components/azure-sidebar-content/azure-sidebar-content.js +++ /dev/null @@ -1,8 +0,0 @@ -import angular from 'angular'; - -angular.module('portainer.azure').component('azureSidebarContent', { - templateUrl: './azureSidebarContent.html', - bindings: { - endpointId: '<', - }, -}); diff --git a/app/azure/components/azure-sidebar-content/azureSidebarContent.html b/app/azure/components/azure-sidebar-content/azureSidebarContent.html deleted file mode 100644 index d6e68d12b..000000000 --- a/app/azure/components/azure-sidebar-content/azureSidebarContent.html +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/app/azure/components/azure-sidebar/azure-sidebar.html b/app/azure/components/azure-sidebar/azure-sidebar.html new file mode 100644 index 000000000..08fcec28e --- /dev/null +++ b/app/azure/components/azure-sidebar/azure-sidebar.html @@ -0,0 +1,19 @@ + + Dashboard + + + + Container instances + diff --git a/app/azure/components/azure-sidebar/index.js b/app/azure/components/azure-sidebar/index.js new file mode 100644 index 000000000..5b5b13f8b --- /dev/null +++ b/app/azure/components/azure-sidebar/index.js @@ -0,0 +1,8 @@ +import angular from 'angular'; + +angular.module('portainer.azure').component('azureSidebar', { + templateUrl: './azure-sidebar.html', + bindings: { + endpointId: '<', + }, +}); diff --git a/app/constants.js b/app/constants.js index febc848e8..97f9ca362 100644 --- a/app/constants.js +++ b/app/constants.js @@ -1,7 +1,7 @@ angular .module('portainer') .constant('API_ENDPOINT_AUTH', 'api/auth') - .constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub') + .constant('API_ENDPOINT_KUBERNETES', 'api/kubernetes') .constant('API_ENDPOINT_CUSTOM_TEMPLATES', 'api/custom_templates') .constant('API_ENDPOINT_EDGE_GROUPS', 'api/edge_groups') .constant('API_ENDPOINT_EDGE_JOBS', 'api/edge_jobs') @@ -27,8 +27,6 @@ angular .constant('PAGINATION_MAX_ITEMS', 10) .constant('APPLICATION_CACHE_VALIDITY', 3600) .constant('CONSOLE_COMMANDS_LABEL_PREFIX', 'io.portainer.commands.') - .constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none']) - .constant('KUBERNETES_DEFAULT_NAMESPACE', 'default') - .constant('KUBERNETES_SYSTEM_NAMESPACES', ['kube-system', 'kube-public', 'kube-node-lease', 'portainer']); + .constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none']); export const PORTAINER_FADEOUT = 1500; diff --git a/app/docker/__module.js b/app/docker/__module.js index 40cf82c24..a34a8e4cd 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -24,10 +24,10 @@ angular.module('portainer.docker', ['portainer.app']).config([ if (status === 2) { if (!endpoint.Snapshots[0]) { - throw new Error('Endpoint is unreachable and there is no snapshot available for offline browsing.'); + throw new Error('Environment is unreachable and there is no snapshot available for offline browsing.'); } if (endpoint.Snapshots[0].Swarm) { - throw new Error('Endpoint is unreachable. Connect to another swarm manager.'); + throw new Error('Environment is unreachable. Connect to another swarm manager.'); } } @@ -38,7 +38,7 @@ angular.module('portainer.docker', ['portainer.app']).config([ const extensions = await LegacyExtensionManager.initEndpointExtensions(endpoint); await StateManager.updateEndpointState(endpoint, extensions); } catch (e) { - Notifications.error('Failed loading endpoint', e); + Notifications.error('Failed loading environment', e); $state.go('portainer.home', {}, { reload: true }); } @@ -591,6 +591,26 @@ angular.module('portainer.docker', ['portainer.app']).config([ }, }; + const registries = { + name: 'docker.registries', + url: '/registries', + views: { + 'content@': { + component: 'endpointRegistriesView', + }, + }, + }; + + const registryAccess = { + name: 'docker.registries.access', + url: '/:id/access', + views: { + 'content@': { + component: 'dockerRegistryAccessView', + }, + }, + }; + $stateRegistryProvider.register(configs); $stateRegistryProvider.register(config); $stateRegistryProvider.register(configCreation); @@ -641,5 +661,7 @@ angular.module('portainer.docker', ['portainer.app']).config([ $stateRegistryProvider.register(volumeBrowse); $stateRegistryProvider.register(volumeCreation); $stateRegistryProvider.register(dockerFeaturesConfiguration); + $stateRegistryProvider.register(registries); + $stateRegistryProvider.register(registryAccess); }, ]); diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html index 77ce34153..f1a81acaf 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html @@ -73,7 +73,11 @@ - + {{ value.GlobalIPv6Address }} diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 12dbc9c3f..5cd726759 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -92,7 +92,7 @@ ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." - auto-focus + focus-if="!$ctrl.notAutoFocus" ng-model-options="{ debounce: 300 }" /> @@ -262,7 +262,7 @@ - + Loading... diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html index 285f2263e..521e324a9 100644 --- a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html @@ -1,6 +1,6 @@ -
+
- + - + diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index 3e72d09f4..cd7bb808e 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -56,7 +56,7 @@ ng-model="$ctrl.state.textFilter" ng-change="$ctrl.onTextFilterChange()" placeholder="Search..." - auto-focus + focus-if="!$ctrl.notAutoFocus" ng-model-options="{ debounce: 300 }" /> diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.js b/app/docker/components/datatables/services-datatable/servicesDatatable.js index 6e5bbfb77..0354eab2f 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.js +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.js @@ -15,5 +15,6 @@ angular.module('portainer.docker').component('servicesDatatable', { showStackColumn: '<', showTaskLogsButton: '<', refreshCallback: '<', + notAutoFocus: '<', }, }); diff --git a/app/docker/components/docker-sidebar/docker-sidebar.html b/app/docker/components/docker-sidebar/docker-sidebar.html new file mode 100644 index 000000000..7992dd60c --- /dev/null +++ b/app/docker/components/docker-sidebar/docker-sidebar.html @@ -0,0 +1,158 @@ + + Dashboard + + + + + Custom Templates + + + + + Stacks + + + + Services + + + + Containers + + + + Images + + + + Networks + + + + Volumes + + + + Configs + + + + Secrets + + + + Events + + + +
+ + Setup + + + + Registries + +
+
+ + +
+ + Setup + + + + Registries + +
+
diff --git a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js b/app/docker/components/docker-sidebar/docker-sidebar.js similarity index 62% rename from app/docker/components/dockerSidebarContent/docker-sidebar-content.js rename to app/docker/components/docker-sidebar/docker-sidebar.js index 088165ac3..65e679262 100644 --- a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js +++ b/app/docker/components/docker-sidebar/docker-sidebar.js @@ -1,12 +1,13 @@ -angular.module('portainer.docker').component('dockerSidebarContent', { - templateUrl: './dockerSidebarContent.html', +angular.module('portainer.docker').component('dockerSidebar', { + templateUrl: './docker-sidebar.html', bindings: { + isSidebarOpen: '<', + endpointApiVersion: '<', swarmManagement: '<', standaloneManagement: '<', adminAccess: '<', offlineMode: '<', - toggle: '<', currentRouteName: '<', endpointId: '<', showStacks: '<', diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html deleted file mode 100644 index 484451142..000000000 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js index f3114b526..809f1ba52 100644 --- a/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.controller.js @@ -1,31 +1,41 @@ +import EndpointHelper from '@/portainer/helpers/endpointHelper'; + export default class porImageRegistryContainerController { /* @ngInject */ - constructor(EndpointHelper, DockerHubService, Notifications) { - this.EndpointHelper = EndpointHelper; + constructor(DockerHubService, Notifications) { this.DockerHubService = DockerHubService; this.Notifications = Notifications; this.pullRateLimits = null; } - $onChanges({ isDockerHubRegistry }) { - if (isDockerHubRegistry && isDockerHubRegistry.currentValue) { + $onChanges({ registryId }) { + if (registryId) { this.fetchRateLimits(); } } + $onInit() { + this.setValidity = + this.setValidity || + (() => { + /* noop */ + }); + } + async fetchRateLimits() { this.pullRateLimits = null; - if (this.EndpointHelper.isAgentEndpoint(this.endpoint) || this.EndpointHelper.isLocalEndpoint(this.endpoint)) { - try { - this.pullRateLimits = await this.DockerHubService.checkRateLimits(this.endpoint); - this.setValidity(!this.pullRateLimits.limit || (this.pullRateLimits.limit && this.pullRateLimits.remaining >= 0)); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Failed loading DockerHub pull rate limits', e); - this.setValidity(true); - } - } else { + if (!EndpointHelper.isAgentEndpoint(this.endpoint) && !EndpointHelper.isLocalEndpoint(this.endpoint)) { + this.setValidity(true); + return; + } + + try { + this.pullRateLimits = await this.DockerHubService.checkRateLimits(this.endpoint, this.registryId || 0); + this.setValidity(!this.pullRateLimits.limit || (this.pullRateLimits.limit && this.pullRateLimits.remaining >= 0)); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed loading DockerHub pull rate limits', e); this.setValidity(true); } } diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.html b/app/docker/components/imageRegistry/por-image-registry-rate-limits.html index 31d7eaa82..7097fa66c 100644 --- a/app/docker/components/imageRegistry/por-image-registry-rate-limits.html +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.html @@ -1,4 +1,4 @@ -
+
diff --git a/app/docker/components/imageRegistry/por-image-registry-rate-limits.js b/app/docker/components/imageRegistry/por-image-registry-rate-limits.js index 3418054f6..b87835a9d 100644 --- a/app/docker/components/imageRegistry/por-image-registry-rate-limits.js +++ b/app/docker/components/imageRegistry/por-image-registry-rate-limits.js @@ -5,10 +5,12 @@ import controller from './por-image-registry-rate-limits.controller'; angular.module('portainer.docker').component('porImageRegistryRateLimits', { bindings: { endpoint: '<', + registry: '<', setValidity: '<', isAdmin: '<', isDockerHubRegistry: '<', isAuthenticated: '<', + registryId: '<', }, controller, transclude: { diff --git a/app/docker/components/imageRegistry/por-image-registry.controller.js b/app/docker/components/imageRegistry/por-image-registry.controller.js index edf570f8b..5cf3fc709 100644 --- a/app/docker/components/imageRegistry/por-image-registry.controller.js +++ b/app/docker/components/imageRegistry/por-image-registry.controller.js @@ -5,18 +5,21 @@ import { RegistryTypes } from '@/portainer/models/registryTypes'; class porImageRegistryController { /* @ngInject */ - constructor($async, $scope, ImageHelper, RegistryService, DockerHubService, ImageService, Notifications) { + constructor($async, $scope, ImageHelper, RegistryService, EndpointService, ImageService, Notifications) { this.$async = $async; this.$scope = $scope; this.ImageHelper = ImageHelper; this.RegistryService = RegistryService; - this.DockerHubService = DockerHubService; + this.EndpointService = EndpointService; this.ImageService = ImageService; this.Notifications = Notifications; - this.onInit = this.onInit.bind(this); this.onRegistryChange = this.onRegistryChange.bind(this); + this.registries = []; + this.images = []; + this.defaultRegistry = new DockerHubViewModel(); + this.$scope.$watch(() => this.model.Registry, this.onRegistryChange); } @@ -40,7 +43,7 @@ class porImageRegistryController { const registryImages = _.filter(this.images, (image) => _.includes(image, url)); images = _.map(registryImages, (image) => _.replace(image, new RegExp(url + '/?'), '')); } else { - const registries = _.filter(this.availableRegistries, (reg) => this.isKnownRegistry(reg)); + const registries = _.filter(this.registries, (reg) => this.isKnownRegistry(reg)); const registryImages = _.flatMap(registries, (registry) => _.filter(this.images, (image) => _.includes(image, registry.URL))); const imagesWithoutKnown = _.difference(this.images, registryImages); images = _.filter(imagesWithoutKnown, (image) => !this.ImageHelper.imageContainsURL(image)); @@ -49,7 +52,7 @@ class porImageRegistryController { } isDockerHubRegistry() { - return this.model.UseRegistry && this.model.Registry.Name === 'DockerHub'; + return this.model.UseRegistry && (this.model.Registry.Type === RegistryTypes.DOCKERHUB || this.model.Registry.Type === RegistryTypes.ANONYMOUS); } async onRegistryChange() { @@ -63,29 +66,49 @@ class porImageRegistryController { return this.getRegistryURL(this.model.Registry) || 'docker.io'; } - async onInit() { - try { - const [registries, dockerhub, images] = await Promise.all([ - this.RegistryService.registries(), - this.DockerHubService.dockerhub(), - this.autoComplete ? this.ImageService.images() : [], - ]); - this.images = this.ImageService.getUniqueTagListFromImages(images); - this.availableRegistries = _.concat(dockerhub, registries); + async reloadRegistries() { + return this.$async(async () => { + try { + const registries = await this.EndpointService.registries(this.endpoint.Id, this.namespace); + this.registries = _.concat(this.defaultRegistry, registries); - const id = this.model.Registry.Id; - if (!id) { - this.model.Registry = dockerhub; - } else { - this.model.Registry = _.find(this.availableRegistries, { Id: id }); + const id = this.model.Registry.Id; + const registry = _.find(this.registries, { Id: id }); + if (!registry) { + this.model.Registry = this.defaultRegistry; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registries'); } - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve registries'); + }); + } + + async loadImages() { + return this.$async(async () => { + try { + if (!this.autoComplete) { + this.images = []; + return; + } + + const images = await this.ImageService.images(); + this.images = this.ImageService.getUniqueTagListFromImages(images); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve images'); + } + }); + } + + $onChanges({ namespace, endpoint }) { + if ((namespace || endpoint) && this.endpoint.Id) { + this.reloadRegistries(); } } $onInit() { - return this.$async(this.onInit); + return this.$async(async () => { + await this.loadImages(); + }); } } diff --git a/app/docker/components/imageRegistry/por-image-registry.html b/app/docker/components/imageRegistry/por-image-registry.html index 97b5c07e4..e3197f678 100644 --- a/app/docker/components/imageRegistry/por-image-registry.html +++ b/app/docker/components/imageRegistry/por-image-registry.html @@ -6,10 +6,9 @@
@@ -27,6 +26,7 @@ placeholder="e.g. myImage:myTag" ng-change="$ctrl.onImageChange()" required + data-cy="component-imageInput" />
diff --git a/app/docker/components/imageRegistry/por-image-registry.js b/app/docker/components/imageRegistry/por-image-registry.js index 52b001db0..5c397045a 100644 --- a/app/docker/components/imageRegistry/por-image-registry.js +++ b/app/docker/components/imageRegistry/por-image-registry.js @@ -3,7 +3,6 @@ angular.module('portainer.docker').component('porImageRegistry', { controller: 'porImageRegistryController', bindings: { model: '=', // must be of type PorImageRegistryModel - pullWarning: '<', autoComplete: '<', labelClass: '@', inputClass: '@', @@ -12,6 +11,7 @@ angular.module('portainer.docker').component('porImageRegistry', { checkRateLimits: '<', onImageChange: '&', setValidity: '<', + namespace: '<', }, require: { form: '^form', diff --git a/app/docker/filters/filters.js b/app/docker/filters/filters.js index c7ee16f76..f3ee255b5 100644 --- a/app/docker/filters/filters.js +++ b/app/docker/filters/filters.js @@ -322,4 +322,7 @@ angular } return fullName.substring(0, versionIdx); }; + }) + .filter('unique', function () { + return _.uniqBy; }); diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js index af50cea4b..b5226534f 100644 --- a/app/docker/helpers/imageHelper.js +++ b/app/docker/helpers/imageHelper.js @@ -1,77 +1,95 @@ import _ from 'lodash-es'; -import { RegistryTypes } from '@/portainer/models/registryTypes'; +import { RegistryTypes } from 'Portainer/models/registryTypes'; -angular.module('portainer.docker').factory('ImageHelper', [ - function ImageHelperFactory() { - 'use strict'; +angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory); +function ImageHelperFactory() { + return { + isValidTag, + createImageConfigForContainer, + getImagesNamesForDownload, + removeDigestFromRepository, + imageContainsURL, + }; - var helper = {}; + function isValidTag(tag) { + return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g); + } - helper.isValidTag = isValidTag; - helper.createImageConfigForContainer = createImageConfigForContainer; - helper.getImagesNamesForDownload = getImagesNamesForDownload; - helper.removeDigestFromRepository = removeDigestFromRepository; - helper.imageContainsURL = imageContainsURL; + function getImagesNamesForDownload(images) { + var names = images.map(function (image) { + return image.RepoTags[0] !== ':' ? image.RepoTags[0] : image.Id; + }); + return { + names: names, + }; + } - function isValidTag(tag) { - return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g); + /** + * + * @param {PorImageRegistryModel} registry + */ + function createImageConfigForContainer(imageModel) { + return { + fromImage: buildImageFullURI(imageModel), + }; + } + + function imageContainsURL(image) { + const split = _.split(image, '/'); + const url = split[0]; + if (split.length > 1) { + return _.includes(url, '.') || _.includes(url, ':'); } + return false; + } - function getImagesNamesForDownload(images) { - var names = images.map(function (image) { - return image.RepoTags[0] !== ':' ? image.RepoTags[0] : image.Id; - }); - return { - names: names, - }; - } + function removeDigestFromRepository(repository) { + return repository.split('@sha')[0]; + } +} +/** + * builds the complete uri for an image based on its registry + * @param {PorImageRegistryModel} imageModel + */ +export function buildImageFullURI(imageModel) { + if (!imageModel.UseRegistry) { + return ensureTag(imageModel.Image); + } - /** - * - * @param {PorImageRegistryModel} registry - */ - function createImageConfigForContainer(registry) { - const data = { - fromImage: '', - }; - let fullImageName = ''; + const imageName = buildImageFullURIWithRegistry(imageModel); - if (registry.UseRegistry) { - if (registry.Registry.Type === RegistryTypes.GITLAB) { - const slash = _.startsWith(registry.Image, ':') ? '' : '/'; - fullImageName = registry.Registry.URL + '/' + registry.Registry.Gitlab.ProjectPath + slash + registry.Image; - } else if (registry.Registry.Type === RegistryTypes.QUAY) { - const name = registry.Registry.Quay.UseOrganisation ? registry.Registry.Quay.OrganisationName : registry.Registry.Username; - const url = registry.Registry.URL ? registry.Registry.URL + '/' : ''; - fullImageName = url + name + '/' + registry.Image; - } else { - const url = registry.Registry.URL ? registry.Registry.URL + '/' : ''; - fullImageName = url + registry.Image; - } - if (!_.includes(registry.Image, ':')) { - fullImageName += ':latest'; - } - } else { - fullImageName = registry.Image; - } + return ensureTag(imageName); - data.fromImage = fullImageName; - return data; - } + function ensureTag(image, defaultTag = 'latest') { + return image.includes(':') ? image : `${image}:${defaultTag}`; + } +} - function imageContainsURL(image) { - const split = _.split(image, '/'); - const url = split[0]; - if (split.length > 1) { - return _.includes(url, '.') || _.includes(url, ':'); - } - return false; - } +function buildImageFullURIWithRegistry(imageModel) { + switch (imageModel.Registry.Type) { + case RegistryTypes.GITLAB: + return buildImageURIForGitLab(imageModel); + case RegistryTypes.QUAY: + return buildImageURIForQuay(imageModel); + case RegistryTypes.ANONYMOUS: + return imageModel.Image; + default: + return buildImageURIForOtherRegistry(imageModel); + } - function removeDigestFromRepository(repository) { - return repository.split('@sha')[0]; - } + function buildImageURIForGitLab(imageModel) { + const slash = imageModel.Image.startsWith(':') ? '' : '/'; + return `${imageModel.Registry.URL}/${imageModel.Registry.Gitlab.ProjectPath}${slash}${imageModel.Image}`; + } - return helper; - }, -]); + function buildImageURIForQuay(imageModel) { + const name = imageModel.Registry.Quay.UseOrganisation ? imageModel.Registry.Quay.OrganisationName : imageModel.Registry.Username; + const url = imageModel.Registry.URL ? imageModel.Registry.URL + '/' : ''; + return `${url}${name}/${imageModel.Image}`; + } + + function buildImageURIForOtherRegistry(imageModel) { + const url = imageModel.Registry.URL ? imageModel.Registry.URL + '/' : ''; + return url + imageModel.Image; + } +} diff --git a/app/docker/models/volume.js b/app/docker/models/volume.js index 0de34bbc5..82ebcd0ba 100644 --- a/app/docker/models/volume.js +++ b/app/docker/models/volume.js @@ -13,6 +13,8 @@ export function VolumeViewModel(data) { } this.Mountpoint = data.Mountpoint; + this.ResourceId = data.ResourceID; + if (data.Portainer) { if (data.Portainer.ResourceControl) { this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); diff --git a/app/docker/services/imageService.js b/app/docker/services/imageService.js index 2f04f8fb7..e22f0a3af 100644 --- a/app/docker/services/imageService.js +++ b/app/docker/services/imageService.js @@ -133,7 +133,7 @@ angular.module('portainer.docker').factory('ImageService', [ Image.create({}, imageConfiguration) .$promise.then(function success(data) { - var err = data.length > 0 && data[data.length - 1].hasOwnProperty('message'); + var err = data.length > 0 && data[data.length - 1].message; if (err) { var detail = data[data.length - 1]; deferred.reject({ msg: detail.message }); diff --git a/app/docker/views/configs/create/createConfigController.js b/app/docker/views/configs/create/createConfigController.js index 0d764fe34..7921bf5ac 100644 --- a/app/docker/views/configs/create/createConfigController.js +++ b/app/docker/views/configs/create/createConfigController.js @@ -62,6 +62,10 @@ class CreateConfigController { } } + $onDestroy() { + this.state.isEditorDirty = false; + } + async uiCanExit() { if (this.formValues.displayCodeEditor && this.formValues.ConfigContent && this.state.isEditorDirty) { return this.ModalService.confirmWebEditorDiscard(); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index f75b000ae..8b7c062d6 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -575,7 +575,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ } function loadFromContainerImageConfig() { - RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image) + RegistryService.retrievePorRegistryModelFromRepository($scope.config.Image, endpoint.Id) .then((model) => { $scope.formValues.RegistryModel = model; }) diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index 6f2aaa9cd..0ce6d50b1 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -40,15 +40,14 @@ diff --git a/app/docker/views/containers/edit/container.html b/app/docker/views/containers/edit/container.html index 097162ce0..d878f3a67 100644 --- a/app/docker/views/containers/edit/container.html +++ b/app/docker/views/containers/edit/container.html @@ -190,7 +190,16 @@
- +
@@ -201,7 +210,14 @@
- +
diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index 83db4e944..6cededbe0 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -21,7 +21,6 @@ angular.module('portainer.docker').controller('ContainerController', [ 'ImageService', 'HttpRequestHelper', 'Authentication', - 'StateManager', 'endpoint', function ( $q, @@ -42,9 +41,10 @@ angular.module('portainer.docker').controller('ContainerController', [ ImageService, HttpRequestHelper, Authentication, - StateManager, endpoint ) { + $scope.endpoint = endpoint; + $scope.isAdmin = Authentication.isAdmin(); $scope.activityTime = 0; $scope.portBindings = []; $scope.displayRecreateButton = false; @@ -58,8 +58,14 @@ angular.module('portainer.docker').controller('ContainerController', [ recreateContainerInProgress: false, joinNetworkInProgress: false, leaveNetworkInProgress: false, + pullImageValidity: false, }; + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullImageValidity = validity; + } + $scope.updateRestartPolicy = updateRestartPolicy; var update = function () { @@ -295,7 +301,7 @@ angular.module('portainer.docker').controller('ContainerController', [ if (!pullImage) { return $q.when(); } - return RegistryService.retrievePorRegistryModelFromRepository(container.Config.Image).then(function pullImage(registryModel) { + return RegistryService.retrievePorRegistryModelFromRepository(container.Config.Image, endpoint.Id).then((registryModel) => { return ImageService.pullImage(registryModel, true); }); } diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 2e909e6bc..cdc2ba083 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -1,6 +1,6 @@ - Endpoint summary + Environment summary
@@ -34,12 +34,12 @@
- +
@@ -54,7 +54,7 @@
- + - +
EndpointEnvironment {{ endpoint.Name }} diff --git a/app/docker/views/docker-features-configuration/docker-features-configuration.html b/app/docker/views/docker-features-configuration/docker-features-configuration.html index d906d86c6..3ca6b421f 100644 --- a/app/docker/views/docker-features-configuration/docker-features-configuration.html +++ b/app/docker/views/docker-features-configuration/docker-features-configuration.html @@ -14,7 +14,7 @@
- These features are only available for an Agent enabled endpoints. + These features are only available for an Agent enabled environments.
diff --git a/app/docker/views/images/build/buildImageController.js b/app/docker/views/images/build/buildImageController.js index 8974f915b..1c3afec45 100644 --- a/app/docker/views/images/build/buildImageController.js +++ b/app/docker/views/images/build/buildImageController.js @@ -23,6 +23,10 @@ function BuildImageController($scope, $async, $window, ModalService, BuildServic } }; + $scope.$on('$destroy', function () { + $scope.state.isEditorDirty = false; + }); + $scope.addImageName = function () { $scope.formValues.ImageNames.push({ Name: '' }); }; diff --git a/app/docker/views/images/edit/image.html b/app/docker/views/images/edit/image.html index b8a686edb..ebe41e5dc 100644 --- a/app/docker/views/images/edit/image.html +++ b/app/docker/views/images/edit/image.html @@ -63,7 +63,15 @@
- +
@@ -74,7 +82,7 @@
- +
diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js index 597cdb7a0..e4ae295d6 100644 --- a/app/docker/views/images/edit/imageController.js +++ b/app/docker/views/images/edit/imageController.js @@ -2,11 +2,12 @@ import _ from 'lodash-es'; import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; angular.module('portainer.docker').controller('ImageController', [ + '$async', '$q', '$scope', '$transition$', '$state', - '$timeout', + 'Authentication', 'ImageService', 'ImageHelper', 'RegistryService', @@ -15,13 +16,34 @@ angular.module('portainer.docker').controller('ImageController', [ 'ModalService', 'FileSaver', 'Blob', - function ($q, $scope, $transition$, $state, $timeout, ImageService, ImageHelper, RegistryService, Notifications, HttpRequestHelper, ModalService, FileSaver, Blob) { + 'endpoint', + function ( + $async, + $q, + $scope, + $transition$, + $state, + Authentication, + ImageService, + ImageHelper, + RegistryService, + Notifications, + HttpRequestHelper, + ModalService, + FileSaver, + Blob, + endpoint + ) { + $scope.endpoint = endpoint; + $scope.isAdmin = Authentication.isAdmin(); + $scope.formValues = { RegistryModel: new PorImageRegistryModel(), }; $scope.state = { exportInProgress: false, + pullImageValidity: false, }; $scope.sortType = 'Order'; @@ -38,6 +60,11 @@ angular.module('portainer.docker').controller('ImageController', [ $('#layer-command-' + layerId + '-full').toggle(); }; + $scope.setPullImageValidity = setPullImageValidity; + function setPullImageValidity(validity) { + $scope.state.pullImageValidity = validity; + } + $scope.tagImage = function () { const registryModel = $scope.formValues.RegistryModel; @@ -53,39 +80,38 @@ angular.module('portainer.docker').controller('ImageController', [ }); }; - $scope.pushTag = function (repository) { - $('#uploadResourceHint').show(); - RegistryService.retrievePorRegistryModelFromRepository(repository) - .then(function success(registryModel) { - return ImageService.pushImage(registryModel); - }) - .then(function success() { - Notifications.success('Image successfully pushed', repository); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to push image to repository'); - }) - .finally(function final() { - $('#uploadResourceHint').hide(); - }); - }; + $scope.pushTag = pushTag; - $scope.pullTag = function (repository) { - $('#downloadResourceHint').show(); - RegistryService.retrievePorRegistryModelFromRepository(repository) - .then(function success(registryModel) { - return ImageService.pullImage(registryModel, false); - }) - .then(function success() { + async function pushTag(repository) { + return $async(async () => { + $('#uploadResourceHint').show(); + try { + const registryModel = await RegistryService.retrievePorRegistryModelFromRepository(repository, endpoint.Id); + await ImageService.pushImage(registryModel); + Notifications.success('Image successfully pushed', repository); + } catch (err) { + Notifications.error('Failure', err, 'Unable to push image to repository'); + } finally { + $('#uploadResourceHint').hide(); + } + }); + } + + $scope.pullTag = pullTag; + async function pullTag(repository) { + return $async(async () => { + $('#downloadResourceHint').show(); + try { + const registryModel = await RegistryService.retrievePorRegistryModelFromRepository(repository, endpoint.Id); + await ImageService.pullImage(registryModel); Notifications.success('Image successfully pulled', repository); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to pull image'); - }) - .finally(function final() { + } catch (err) { + Notifications.error('Failure', err, 'Unable to pull image from repository'); + } finally { $('#downloadResourceHint').hide(); - }); - }; + } + }); + } $scope.removeTag = function (repository) { ImageService.deleteImage(repository, false) diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index a2f3034c3..7130a8bd1 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -17,7 +17,6 @@ + + Registries > {{ $ctrl.registry.Name }} > Access management + + + + + + diff --git a/app/docker/views/registries/access/registryAccess.js b/app/docker/views/registries/access/registryAccess.js new file mode 100644 index 000000000..7ee0814dd --- /dev/null +++ b/app/docker/views/registries/access/registryAccess.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('dockerRegistryAccessView', { + templateUrl: './registryAccess.html', + controller: 'DockerRegistryAccessController', + bindings: { + endpoint: '<', + }, +}); diff --git a/app/docker/views/registries/access/registryAccessController.js b/app/docker/views/registries/access/registryAccessController.js new file mode 100644 index 000000000..c30d25dd4 --- /dev/null +++ b/app/docker/views/registries/access/registryAccessController.js @@ -0,0 +1,67 @@ +import { TeamAccessViewModel, UserAccessViewModel } from 'Portainer/models/access'; + +class DockerRegistryAccessController { + /* @ngInject */ + constructor($async, $state, Notifications, EndpointService, GroupService) { + this.$async = $async; + this.$state = $state; + this.Notifications = Notifications; + this.EndpointService = EndpointService; + this.GroupService = GroupService; + + this.updateAccess = this.updateAccess.bind(this); + this.filterUsers = this.filterUsers.bind(this); + } + + updateAccess() { + return this.$async(async () => { + this.state.actionInProgress = true; + try { + await this.EndpointService.updateRegistryAccess(this.state.endpointId, this.state.registryId, this.registryEndpointAccesses); + this.Notifications.success('Access successfully updated'); + this.$state.reload(); + } catch (err) { + this.state.actionInProgress = false; + this.Notifications.error('Failure', err, 'Unable to update accesses'); + } + }); + } + + filterUsers(users) { + const endpointUsers = this.endpoint.UserAccessPolicies; + const endpointTeams = this.endpoint.TeamAccessPolicies; + + const endpointGroupUsers = this.endpointGroup.UserAccessPolicies; + const endpointGroupTeams = this.endpointGroup.TeamAccessPolicies; + + return users.filter((userOrTeam) => { + const userRole = userOrTeam instanceof UserAccessViewModel && (endpointUsers[userOrTeam.Id] || endpointGroupUsers[userOrTeam.Id]); + const teamRole = userOrTeam instanceof TeamAccessViewModel && (endpointTeams[userOrTeam.Id] || endpointGroupTeams[userOrTeam.Id]); + + return userRole || teamRole; + }); + } + + $onInit() { + return this.$async(async () => { + try { + this.state = { + viewReady: false, + actionInProgress: false, + endpointId: this.$state.params.endpointId, + registryId: this.$state.params.id, + }; + this.registry = await this.EndpointService.registry(this.state.endpointId, this.state.registryId); + this.registryEndpointAccesses = this.registry.RegistryAccesses[this.state.endpointId] || {}; + this.endpointGroup = await this.GroupService.group(this.endpoint.GroupId); + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve registry details'); + } finally { + this.state.viewReady = true; + } + }); + } +} + +export default DockerRegistryAccessController; +angular.module('portainer.docker').controller('DockerRegistryAccessController', DockerRegistryAccessController); diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js index f1aef75a4..73a4f70be 100644 --- a/app/docker/views/services/edit/serviceController.js +++ b/app/docker/views/services/edit/serviceController.js @@ -50,7 +50,6 @@ angular.module('portainer.docker').controller('ServiceController', [ 'VolumeService', 'ImageHelper', 'WebhookService', - 'EndpointProvider', 'clipboard', 'WebhookHelper', 'NetworkService', @@ -82,7 +81,6 @@ angular.module('portainer.docker').controller('ServiceController', [ VolumeService, ImageHelper, WebhookService, - EndpointProvider, clipboard, WebhookHelper, NetworkService, @@ -337,7 +335,7 @@ angular.module('portainer.docker').controller('ServiceController', [ Notifications.error('Failure', err, 'Unable to delete webhook'); }); } else { - WebhookService.createServiceWebhook(service.Id, EndpointProvider.endpointID()) + WebhookService.createServiceWebhook(service.Id, endpoint.Id) .then(function success(data) { $scope.WebhookExists = true; $scope.webhookID = data.Id; @@ -688,7 +686,7 @@ angular.module('portainer.docker').controller('ServiceController', [ availableImages: ImageService.images(), availableLoggingDrivers: PluginService.loggingPlugins(apiVersion < 1.25), availableNetworks: NetworkService.networks(true, true, apiVersion >= 1.25), - webhooks: WebhookService.webhooks(service.Id, EndpointProvider.endpointID()), + webhooks: WebhookService.webhooks(service.Id, endpoint.Id), }); }) .then(async function success(data) { diff --git a/app/docker/views/volumes/edit/volume.html b/app/docker/views/volumes/edit/volume.html index 167984bf3..6141cc537 100644 --- a/app/docker/views/volumes/edit/volume.html +++ b/app/docker/views/volumes/edit/volume.html @@ -78,7 +78,7 @@
- +
diff --git a/app/edge/__module.js b/app/edge/__module.js index a1507f356..72af140a5 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -1,6 +1,8 @@ import angular from 'angular'; -angular.module('portainer.edge', []).config(function config($stateRegistryProvider) { +import edgeStackModule from './views/edge-stacks'; + +angular.module('portainer.edge', [edgeStackModule]).config(function config($stateRegistryProvider) { const edge = { name: 'edge', url: '/edge', diff --git a/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html index d19f4b8bc..f5da826c8 100644 --- a/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html +++ b/app/edge/components/associated-endpoints-datatable/associatedEndpointsDatatable.html @@ -52,7 +52,7 @@
Loading...
No endpoint available.No environment available.
diff --git a/app/edge/components/edge-groups-selector/edgeGroupsSelector.html b/app/edge/components/edge-groups-selector/edgeGroupsSelector.html index 41fa18a68..1210f3cb0 100644 --- a/app/edge/components/edge-groups-selector/edgeGroupsSelector.html +++ b/app/edge/components/edge-groups-selector/edgeGroupsSelector.html @@ -1,4 +1,12 @@ - + + {{ $item.Name }} diff --git a/app/edge/components/edge-groups-selector/index.js b/app/edge/components/edge-groups-selector/index.js index 0a7f7cbb3..902139191 100644 --- a/app/edge/components/edge-groups-selector/index.js +++ b/app/edge/components/edge-groups-selector/index.js @@ -3,7 +3,8 @@ import angular from 'angular'; angular.module('portainer.edge').component('edgeGroupsSelector', { templateUrl: './edgeGroupsSelector.html', bindings: { - model: '=', + model: '<', items: '<', + onChange: '<', }, }); diff --git a/app/edge/components/edge-job-form/edgeJobForm.html b/app/edge/components/edge-job-form/edgeJobForm.html index d94f24004..b3d75e3a2 100644 --- a/app/edge/components/edge-job-form/edgeJobForm.html +++ b/app/edge/components/edge-job-form/edgeJobForm.html @@ -75,7 +75,7 @@
- Time should be set according to the chosen endpoints' timezone. + Time should be set according to the chosen environments' timezone.
@@ -130,7 +130,7 @@ />
- Time should be set according to the chosen endpoints' timezone. + Time should be set according to the chosen environments' timezone.
@@ -215,7 +215,7 @@
- Target endpoints + Target environments
- Endpoint + Environment
diff --git a/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.controller.js b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.controller.js new file mode 100644 index 000000000..3cedd0464 --- /dev/null +++ b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.controller.js @@ -0,0 +1,21 @@ +export default class EdgeStackDeploymentTypeSelectorController { + /* @ngInject */ + constructor() { + this.deploymentOptions = [ + { id: 'deployment_compose', icon: 'fab fa-docker', label: 'Compose', description: 'docker-compose format', value: 0 }, + { + id: 'deployment_kube', + icon: 'fa fa-cubes', + label: 'Kubernetes', + description: 'Kubernetes manifest format', + value: 1, + disabled: () => { + return this.hasDockerEndpoint(); + }, + tooltip: () => { + return this.hasDockerEndpoint() ? 'Cannot use this option with Edge Docker endpoints' : ''; + }, + }, + ]; + } +} diff --git a/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html new file mode 100644 index 000000000..4eae73cc5 --- /dev/null +++ b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html @@ -0,0 +1,4 @@ +
+ Deployment type +
+ diff --git a/app/edge/components/edge-stack-deployment-type-selector/index.js b/app/edge/components/edge-stack-deployment-type-selector/index.js new file mode 100644 index 000000000..c175249fd --- /dev/null +++ b/app/edge/components/edge-stack-deployment-type-selector/index.js @@ -0,0 +1,15 @@ +import angular from 'angular'; +import controller from './edge-stack-deployment-type-selector.controller.js'; + +export const edgeStackDeploymentTypeSelector = { + templateUrl: './edge-stack-deployment-type-selector.html', + controller, + + bindings: { + value: '<', + onChange: '<', + hasDockerEndpoint: '<', + }, +}; + +angular.module('portainer.edge').component('edgeStackDeploymentTypeSelector', edgeStackDeploymentTypeSelector); diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html index 69bb34b98..2cb16bc40 100644 --- a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html +++ b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatable.html @@ -57,7 +57,7 @@ Loading... - No endpoint available. + No environment available. diff --git a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js index 8a0c12765..f71f6d330 100644 --- a/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js +++ b/app/edge/components/edge-stack-endpoints-datatable/edgeStackEndpointsDatatableController.js @@ -101,7 +101,7 @@ export class EdgeStackEndpointsDatatableController { this.state.filteredDataSet = endpoints; this.state.totalFilteredDataSet = totalCount; } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve endpoints'); + this.Notifications.error('Failure', err, 'Unable to retrieve environments'); } finally { this.state.loading = false; } diff --git a/app/edge/components/edge-stack-status/edgeStackStatus.html b/app/edge/components/edge-stack-status/edgeStackStatus.html index 5ffda22b5..02c8a07d0 100644 --- a/app/edge/components/edge-stack-status/edgeStackStatus.html +++ b/app/edge/components/edge-stack-status/edgeStackStatus.html @@ -1,3 +1,3 @@ -{{ $ctrl.status.acknowledged || 0 }} -{{ $ctrl.status.ok || 0 }} -{{ $ctrl.status.error || 0 }} +{{ $ctrl.status.acknowledged || 0 }} +{{ $ctrl.status.ok || 0 }} +{{ $ctrl.status.error || 0 }} diff --git a/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html index a5e1dbc66..76c74c4e8 100644 --- a/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html +++ b/app/edge/components/edge-stacks-datatable/edgeStacksDatatable.html @@ -6,7 +6,7 @@ Edge Stacks
-
+
Settings