Compare commits
147 Commits
release/2.
...
feat/EE-25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24c88a5d5c | ||
|
|
14ed6ed2a3 | ||
|
|
9f4549212d | ||
|
|
37209918ad | ||
|
|
aefa34d6d2 | ||
|
|
eaffde39f6 | ||
|
|
d71d291895 | ||
|
|
a894e3182a | ||
|
|
ff7847aaa5 | ||
|
|
a89c3773dd | ||
|
|
5d75ca34ea | ||
|
|
d47a9d590e | ||
|
|
bd679ae806 | ||
|
|
5de7ecb5f0 | ||
|
|
b3cd9c69df | ||
|
|
73311b6f32 | ||
|
|
93ddcfecd9 | ||
|
|
2bffba7371 | ||
|
|
37ca62eb06 | ||
|
|
fa208c7f2a | ||
|
|
6fac3fa127 | ||
|
|
171392c5ca | ||
|
|
d48ff2921b | ||
|
|
3165d354b5 | ||
|
|
9c2dbac479 | ||
|
|
318844226c | ||
|
|
e96f63023e | ||
|
|
1765b99336 | ||
|
|
74a0d4c12e | ||
|
|
3372f78cbf | ||
|
|
fe082f762f | ||
|
|
a8d3cda3fa | ||
|
|
ad7f87122d | ||
|
|
6f6f78fbe5 | ||
|
|
1bb02eea59 | ||
|
|
cf459a2d28 | ||
|
|
7d91ab72e1 | ||
|
|
cb804e8813 | ||
|
|
0973808234 | ||
|
|
edd5193100 | ||
|
|
0ad66510a9 | ||
|
|
5a6cd2002d | ||
|
|
1fbf13e812 | ||
|
|
a9406764ee | ||
|
|
dfb0ba9efe | ||
|
|
df2269a2fe | ||
|
|
8b4a74f06e | ||
|
|
48f2e7316a | ||
|
|
b76bcf0ee7 | ||
|
|
24893573aa | ||
|
|
118809a9c0 | ||
|
|
61be10bb00 | ||
|
|
4bd3f61ce6 | ||
|
|
48c2f127f8 | ||
|
|
b588d901cf | ||
|
|
2c4c638f46 | ||
|
|
3ed92e5fee | ||
|
|
804fdd414e | ||
|
|
661f0aad49 | ||
|
|
58de8e175f | ||
|
|
1e21aeb7e8 | ||
|
|
a79aa221d3 | ||
|
|
50b2f789a3 | ||
|
|
bc70198102 | ||
|
|
1b1a50d6b5 | ||
|
|
34cc8ea96a | ||
|
|
59ec22f706 | ||
|
|
c47e840b37 | ||
|
|
edf048570b | ||
|
|
b71ca2afb0 | ||
|
|
9ff8f42a66 | ||
|
|
125d84cbd1 | ||
|
|
fa798665cd | ||
|
|
95fbf7500c | ||
|
|
584a46d9d4 | ||
|
|
085762a1f4 | ||
|
|
6c32edc5b5 | ||
|
|
389561eb28 | ||
|
|
bc54d687be | ||
|
|
8e45076f35 | ||
|
|
87dda810fc | ||
|
|
4e77d2d772 | ||
|
|
0b62a3d664 | ||
|
|
84f354452b | ||
|
|
c24d8fab0f | ||
|
|
5362e15624 | ||
|
|
07c6ce84c2 | ||
|
|
ecd0eb6170 | ||
|
|
8dbb802fb1 | ||
|
|
07e7fbd270 | ||
|
|
65821aaccc | ||
|
|
d33ac8c588 | ||
|
|
102a07346a | ||
|
|
8fc5a5e8a1 | ||
|
|
cdfa9b25a8 | ||
|
|
e7fc996424 | ||
|
|
1c374b9fd2 | ||
|
|
d9db789511 | ||
|
|
5a3687a564 | ||
|
|
6e53bf5dc7 | ||
|
|
e25141d899 | ||
|
|
4f7b432f44 | ||
|
|
c5fe994cd2 | ||
|
|
c30292cedd | ||
|
|
33a29159d2 | ||
|
|
187b66f5cb | ||
|
|
730fdb160d | ||
|
|
efa125790f | ||
|
|
ac9ca7d5e3 | ||
|
|
f99329eb7e | ||
|
|
b02bf0c9d7 | ||
|
|
7ae5a3042c | ||
|
|
eb9f6c77f4 | ||
|
|
7088da5157 | ||
|
|
da422d6ed6 | ||
|
|
eb517c2e12 | ||
|
|
76916b0ad6 | ||
|
|
19a09b4730 | ||
|
|
8f32517baa | ||
|
|
f864b1bf69 | ||
|
|
e57454cd7c | ||
|
|
b3e04adee3 | ||
|
|
a78d8a4ff1 | ||
|
|
9f5ac154aa | ||
|
|
0627e16b35 | ||
|
|
2a1b8efaed | ||
|
|
98972dec0d | ||
|
|
aa8fc52106 | ||
|
|
5839f96787 | ||
|
|
7cc28b10a0 | ||
|
|
4aea5690a8 | ||
|
|
335f951e6b | ||
|
|
42e782452c | ||
|
|
d2fe76368a | ||
|
|
aa7d7845c1 | ||
|
|
a86c7046df | ||
|
|
ff6185cc81 | ||
|
|
f360392d39 | ||
|
|
fa44a62c4a | ||
|
|
2a384d4c64 | ||
|
|
b6fbf8eecc | ||
|
|
69c17986d9 | ||
|
|
120584909c | ||
|
|
c24dc3112b | ||
|
|
1e80061186 | ||
|
|
c267355759 | ||
|
|
47c1af93ea |
@@ -1,4 +1,5 @@
|
||||
# prettier
|
||||
cf5056d9c03b62d91a25c3b9127caac838695f98
|
||||
|
||||
# prettier v2 (put here after fix/EE-2344/fix-eslint-issues is merged)
|
||||
# prettier v2
|
||||
42e7db0ae7897d3cb72b0ea1ecf57ee2dd694169
|
||||
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@@ -2,7 +2,7 @@
|
||||
|
||||
Thanks for opening an issue on Portainer !
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack http://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
|
||||
Do you need help or have a question? Come chat with us on Slack https://portainer.io/slack/ or gitter https://gitter.im/portainer/Lobby.
|
||||
|
||||
If you are reporting a new issue, make sure that we do not have any duplicates
|
||||
already open. You can ensure this by searching the issue list for this
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/Bug_report.md
vendored
@@ -12,7 +12,7 @@ Thanks for reporting a bug for Portainer !
|
||||
|
||||
You can find more information about Portainer support framework policy here: https://www.portainer.io/2019/04/portainer-support-policy/
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack http://portainer.slack.com/
|
||||
Do you need help or have a question? Come chat with us on Slack https://portainer.io/slack/
|
||||
|
||||
Before opening a new issue, make sure that we do not have any duplicates
|
||||
already open. You can ensure this by searching the issue list for this
|
||||
@@ -47,7 +47,7 @@ You can see how [here](https://documentation.portainer.io/r/portainer-logs)
|
||||
- Platform (windows/linux):
|
||||
- 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.
|
||||
- Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commercial setup.
|
||||
- Have you reviewed our technical documentation and knowledge base? Yes/No
|
||||
|
||||
**Additional context**
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/Custom.md
vendored
6
.github/ISSUE_TEMPLATE/Custom.md
vendored
@@ -4,11 +4,11 @@ about: Ask us a question about Portainer usage or deployment
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
Before you start, we need a little bit more information from you:
|
||||
|
||||
Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commerical setup.
|
||||
Use Case (delete as appropriate): Using Portainer at Home, Using Portainer in a Commercial setup.
|
||||
|
||||
Have you reviewed our technical documentation and knowledge base? Yes/No
|
||||
|
||||
@@ -16,7 +16,7 @@ Have you reviewed our technical documentation and knowledge base? Yes/No
|
||||
|
||||
You can find more information about Portainer support framework policy here: https://old.portainer.io/2019/04/portainer-support-policy/
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack http://portainer.slack.com/
|
||||
Do you need help or have a question? Come chat with us on Slack https://portainer.io/slack/
|
||||
|
||||
Also, be sure to check our FAQ and documentation first: https://documentation.portainer.io/
|
||||
-->
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
3
.github/ISSUE_TEMPLATE/Feature_request.md
vendored
@@ -4,14 +4,13 @@ about: Suggest a feature/enhancement that should be added in Portainer
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Thanks for opening a feature request for Portainer !
|
||||
|
||||
Do you need help or have a question? Come chat with us on Slack http://portainer.slack.com/
|
||||
Do you need help or have a question? Come chat with us on Slack https://portainer.io/slack/
|
||||
|
||||
Before opening a new issue, make sure that we do not have any duplicates
|
||||
already open. You can ensure this by searching the issue list for this
|
||||
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
closes #0 <!-- Github issue number (remove if unknown) -->
|
||||
closes [CE-0] <!-- Jira link number (remove if unknown). Please also add the same [CE-XXX] at the back of the PR title -->
|
||||
|
||||
### Changes:
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
# ESLint and Prettier must be in `package.json`
|
||||
- name: Install Node.js dependencies
|
||||
run: yarn
|
||||
run: yarn --frozen-lockfile
|
||||
|
||||
- name: Run linters
|
||||
uses: wearerequired/lint-action@v1
|
||||
|
||||
2
.github/workflows/test-client.yaml
vendored
2
.github/workflows/test-client.yaml
vendored
@@ -6,6 +6,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Install modules
|
||||
run: yarn
|
||||
run: yarn --frozen-lockfile
|
||||
- name: Run tests
|
||||
run: yarn test:client
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceRoot}/api/cmd/portainer/main.go",
|
||||
"program": "${workspaceRoot}/api/cmd/portainer",
|
||||
"cwd": "${workspaceRoot}",
|
||||
"env": {},
|
||||
"showLog": true,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintFlags": ["--fast", "-E", "exportloopref"],
|
||||
"gitlens.advanced.blame.customArguments": ["–ignore-revs-file", ".git-blame-ignore-revs"]
|
||||
"gopls": {
|
||||
"build.expandWorkspaceToModule": false
|
||||
},
|
||||
"gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".git-blame-ignore-revs"]
|
||||
}
|
||||
|
||||
@@ -42,10 +42,10 @@ View [this](https://www.portainer.io/products) table to see all of the Portainer
|
||||
|
||||
Portainer CE is an open source project and is supported by the community. You can buy a supported version of Portainer at portainer.io
|
||||
|
||||
Learn more about Portainers community support channels [here.](https://www.portainer.io/community_help)
|
||||
Learn more about Portainer's community support channels [here.](https://www.portainer.io/community_help)
|
||||
|
||||
- Issues: https://github.com/portainer/portainer/issues
|
||||
- Slack (chat): [https://portainer.slack.com/](https://join.slack.com/t/portainer/shared_invite/zt-txh3ljab-52QHTyjCqbe5RibC2lcjKA)
|
||||
- Slack (chat): [https://portainer.io/slack](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.
|
||||
|
||||
|
||||
@@ -80,8 +80,7 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
|
||||
}
|
||||
|
||||
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
|
||||
dbFileName := datastore.Connection().GetDatabaseFileName()
|
||||
backupWriter, err := os.Create(filepath.Join(backupDirPath, dbFileName))
|
||||
backupWriter, err := os.Create(filepath.Join(backupDirPath, "portainer.db"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
"github.com/portainer/portainer/api/http/offlinegate"
|
||||
)
|
||||
|
||||
var filesToRestore = filesToBackup
|
||||
var filesToRestore = append(filesToBackup, "portainer.db")
|
||||
|
||||
// Restores system state from backup archive, will trigger system shutdown, when finished.
|
||||
func RestoreArchive(archive io.Reader, password string, filestorePath string, gate *offlinegate.OfflineGate, datastore dataservices.DataStore, shutdownTrigger context.CancelFunc) error {
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
@@ -105,6 +107,11 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
if isNew {
|
||||
// from MigrateData
|
||||
store.VersionService.StoreDBVersion(portainer.DBVersion)
|
||||
|
||||
err := updateSettingsFromFlags(store, flags)
|
||||
if err != nil {
|
||||
logrus.Fatalf("Failed updating settings from flags: %v", err)
|
||||
}
|
||||
} else {
|
||||
storedVersion, err := store.VersionService.DBVersion()
|
||||
if err != nil {
|
||||
@@ -120,15 +127,24 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
|
||||
err = updateSettingsFromFlags(store, flags)
|
||||
if err != nil {
|
||||
logrus.Fatalf("Failed updating settings from flags: %v", err)
|
||||
log.Fatalf("Failed updating settings from flags: %v", err)
|
||||
}
|
||||
|
||||
// this is for the db restore functionality - needs more tests.
|
||||
go func() {
|
||||
<-shutdownCtx.Done()
|
||||
defer connection.Close()
|
||||
}()
|
||||
|
||||
exportFilename := path.Join(*flags.Data, fmt.Sprintf("export-%d.json", time.Now().Unix()))
|
||||
|
||||
err := store.Export(exportFilename)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Debugf("Failed to export to %s", exportFilename)
|
||||
} else {
|
||||
logrus.Debugf("exported to %s", exportFilename)
|
||||
}
|
||||
connection.Close()
|
||||
}()
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -141,7 +157,14 @@ func initComposeStackManager(assetsPath string, configPath string, reverseTunnel
|
||||
return composeWrapper
|
||||
}
|
||||
|
||||
func initSwarmStackManager(assetsPath string, configPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore) (portainer.SwarmStackManager, error) {
|
||||
func initSwarmStackManager(
|
||||
assetsPath string,
|
||||
configPath string,
|
||||
signatureService portainer.DigitalSignatureService,
|
||||
fileService portainer.FileService,
|
||||
reverseTunnelService portainer.ReverseTunnelService,
|
||||
dataStore dataservices.DataStore,
|
||||
) (portainer.SwarmStackManager, error) {
|
||||
return exec.NewSwarmStackManager(assetsPath, configPath, signatureService, fileService, reverseTunnelService, dataStore)
|
||||
}
|
||||
|
||||
@@ -375,7 +398,6 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, dataStore dataservices.
|
||||
TLSConfig: tlsConfiguration,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: []portainer.TagID{},
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
@@ -437,7 +459,6 @@ func createUnsecuredEndpoint(endpointURL string, dataStore dataservices.DataStor
|
||||
TLSConfig: portainer.TLSConfiguration{},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: []portainer.TagID{},
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
|
||||
@@ -29,8 +29,12 @@ type Connection interface {
|
||||
GetNextIdentifier(bucketName string) int
|
||||
CreateObject(bucketName string, fn func(uint64) (int, interface{})) error
|
||||
CreateObjectWithId(bucketName string, id int, obj interface{}) error
|
||||
CreateObjectWithStringId(bucketName string, id []byte, obj interface{}) error
|
||||
CreateObjectWithSetSequence(bucketName string, id int, obj interface{}) error
|
||||
GetAll(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error
|
||||
GetAllWithJsoniter(bucketName string, obj interface{}, append func(o interface{}) (interface{}, error)) error
|
||||
ConvertToKey(v int) []byte
|
||||
|
||||
BackupMetadata() (map[string]interface{}, error)
|
||||
RestoreMetadata(s map[string]interface{}) error
|
||||
}
|
||||
|
||||
@@ -314,6 +314,19 @@ func (connection *DbConnection) CreateObjectWithId(bucketName string, id int, ob
|
||||
})
|
||||
}
|
||||
|
||||
// CreateObjectWithStringId creates a new object in the bucket, using the specified id
|
||||
func (connection *DbConnection) CreateObjectWithStringId(bucketName string, id []byte, obj interface{}) error {
|
||||
return connection.Batch(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketName))
|
||||
data, err := connection.MarshalObject(obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return bucket.Put(id, data)
|
||||
})
|
||||
}
|
||||
|
||||
// CreateObjectWithSetSequence creates a new object in the bucket, using the specified id, and sets the bucket sequence
|
||||
// avoid this :)
|
||||
func (connection *DbConnection) CreateObjectWithSetSequence(bucketName string, id int, obj interface{}) error {
|
||||
@@ -376,3 +389,43 @@ func (connection *DbConnection) GetAllWithJsoniter(bucketName string, obj interf
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) BackupMetadata() (map[string]interface{}, error) {
|
||||
buckets := map[string]interface{}{}
|
||||
|
||||
err := connection.View(func(tx *bolt.Tx) error {
|
||||
err := tx.ForEach(func(name []byte, bucket *bolt.Bucket) error {
|
||||
bucketName := string(name)
|
||||
bucket = tx.Bucket([]byte(bucketName))
|
||||
seqId := bucket.Sequence()
|
||||
buckets[bucketName] = int(seqId)
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
return buckets, err
|
||||
}
|
||||
|
||||
func (connection *DbConnection) RestoreMetadata(s map[string]interface{}) error {
|
||||
var err error
|
||||
|
||||
for bucketName, v := range s {
|
||||
id, ok := v.(float64) // JSON ints are unmarshalled to interface as float64. See: https://pkg.go.dev/encoding/json#Decoder.Decode
|
||||
if !ok {
|
||||
logrus.Errorf("Failed to restore metadata to bucket %s, skipped", bucketName)
|
||||
continue
|
||||
}
|
||||
|
||||
err = connection.Batch(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketName))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
return bucket.SetSequence(uint64(id))
|
||||
})
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package fdoprofile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ type (
|
||||
BackupTo(w io.Writer) error
|
||||
Export(filename string) (err error)
|
||||
IsErrObjectNotFound(err error) bool
|
||||
Connection() portainer.Connection
|
||||
|
||||
CustomTemplate() CustomTemplateService
|
||||
EdgeGroup() EdgeGroupService
|
||||
EdgeJob() EdgeJobService
|
||||
|
||||
@@ -103,10 +103,6 @@ func (store *Store) IsErrObjectNotFound(e error) bool {
|
||||
return e == portainerErrors.ErrObjectNotFound
|
||||
}
|
||||
|
||||
func (store *Store) Connection() portainer.Connection {
|
||||
return store.connection
|
||||
}
|
||||
|
||||
func (store *Store) Rollback(force bool) error {
|
||||
return store.connectionRollback(force)
|
||||
}
|
||||
|
||||
@@ -96,7 +96,6 @@ func newEndpoint(endpointType portainer.EndpointType, id portainer.EndpointID, n
|
||||
},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: []portainer.TagID{},
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
|
||||
@@ -369,6 +369,7 @@ type storeExport struct {
|
||||
User []portainer.User `json:"users,omitempty"`
|
||||
Version map[string]string `json:"version,omitempty"`
|
||||
Webhook []portainer.Webhook `json:"webhooks,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (store *Store) Export(filename string) (err error) {
|
||||
@@ -561,6 +562,11 @@ func (store *Store) Export(filename string) (err error) {
|
||||
"INSTANCE_ID": instance,
|
||||
}
|
||||
|
||||
backup.Metadata, err = store.connection.BackupMetadata()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Errorf("Exporting Metadata")
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(backup, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -569,6 +575,7 @@ func (store *Store) Export(filename string) (err error) {
|
||||
}
|
||||
|
||||
func (store *Store) Import(filename string) (err error) {
|
||||
|
||||
backup := storeExport{}
|
||||
|
||||
s, err := ioutil.ReadFile(filename)
|
||||
@@ -669,5 +676,5 @@ func (store *Store) Import(filename string) (err error) {
|
||||
store.Webhook().UpdateWebhook(v.ID, &v)
|
||||
}
|
||||
|
||||
return nil
|
||||
return store.connection.RestoreMetadata(backup.Metadata)
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ require (
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220113045708-6569596db840
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220225003350-cec58db3549e
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
|
||||
github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc
|
||||
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f
|
||||
github.com/rkl-/digest v0.0.0-20180419075440-8316caa4a777
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
|
||||
@@ -613,14 +613,14 @@ 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 v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220113045708-6569596db840 h1:Nciddt8Y8G8nTMmyDfWxeN23PZUcsqbZE2zOFB/F1xg=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220113045708-6569596db840/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220225003350-cec58db3549e h1:wLnlzAXeVkjkZxuc81nEVUukl52fSEPUlOsUItrTK10=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220225003350-cec58db3549e/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-20210929000907-825e93d62108 h1:5e8KAnDa2G3cEHK7aV/ue8lOaoQwBZUzoALslwWkR04=
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108/go.mod h1:YvYAk7krKTzB+rFwDr0jQ3sQu2BtiXK1AR0sZH7nhJA=
|
||||
github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc h1:vxVN9srGND+iA9oBmyFgtbtOvnmOCLmxw20ncYCJ5HA=
|
||||
github.com/portainer/libhttp v0.0.0-20211021135806-13e6c55c5fbc/go.mod h1:nyQA6IahOruIvENCcBk54aaUvV2WHFdXkvBjIutg+SY=
|
||||
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f h1:GMIjRVV2LADpJprPG2+8MdRH6XvrFgC7wHm7dFUdOpc=
|
||||
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f/go.mod h1:nyQA6IahOruIvENCcBk54aaUvV2WHFdXkvBjIutg+SY=
|
||||
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
|
||||
github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -34,7 +34,5 @@ func NewHandler(bouncer *security.RequestBouncer) *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
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package endpointproxy
|
||||
|
||||
// TODO: legacy extension management
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"net/http"
|
||||
)
|
||||
|
||||
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 environment identifier route variable", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if handler.DataStore.IsErrObjectNotFound(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 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 environment", err}
|
||||
}
|
||||
|
||||
var storidgeExtension *portainer.EndpointExtension
|
||||
for _, extension := range endpoint.Extensions {
|
||||
if extension.Type == portainer.StoridgeEndpointExtension {
|
||||
storidgeExtension = &extension
|
||||
}
|
||||
}
|
||||
|
||||
if storidgeExtension == nil {
|
||||
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
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetLegacyExtensionProxy(proxyExtensionKey)
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateLegacyExtensionProxy(proxyExtensionKey, storidgeExtension.URL)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy", err}
|
||||
}
|
||||
}
|
||||
|
||||
id := strconv.Itoa(endpointID)
|
||||
http.StripPrefix("/"+id+"/storidge", proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
@@ -284,7 +284,6 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
|
||||
PublicURL: payload.PublicURL,
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
AzureCredentials: credentials,
|
||||
TagIDs: payload.TagIDs,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
@@ -330,7 +329,6 @@ func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload)
|
||||
},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: payload.TagIDs,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
@@ -385,7 +383,6 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
|
||||
},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: payload.TagIDs,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
@@ -421,7 +418,6 @@ func (handler *Handler) createKubernetesEndpoint(payload *endpointCreatePayload)
|
||||
},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: payload.TagIDs,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
@@ -451,7 +447,6 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload,
|
||||
},
|
||||
UserAccessPolicies: portainer.UserAccessPolicies{},
|
||||
TeamAccessPolicies: portainer.TeamAccessPolicies{},
|
||||
Extensions: []portainer.EndpointExtension{},
|
||||
TagIDs: payload.TagIDs,
|
||||
Status: portainer.EndpointStatusUp,
|
||||
Snapshots: []portainer.DockerSnapshot{},
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package endpoints
|
||||
|
||||
// TODO: legacy extension management
|
||||
|
||||
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 endpointExtensionAddPayload struct {
|
||||
Type int
|
||||
URL string
|
||||
}
|
||||
|
||||
func (payload *endpointExtensionAddPayload) Validate(r *http.Request) error {
|
||||
if payload.Type != 1 {
|
||||
return errors.New("Invalid type value. Value must be one of: 1 (Storidge)")
|
||||
}
|
||||
if payload.Type == 1 && govalidator.IsNull(payload.URL) {
|
||||
return errors.New("Invalid extension URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id endpointExtensionAdd
|
||||
// @tags endpoints
|
||||
// @deprecated
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @success 204 "Success"
|
||||
// @router /endpoints/{id}/extensions [post]
|
||||
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 environment identifier route variable", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if handler.DataStore.IsErrObjectNotFound(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 environment with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
var payload endpointExtensionAddPayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
extensionType := portainer.EndpointExtensionType(payload.Type)
|
||||
|
||||
var extension *portainer.EndpointExtension
|
||||
for idx := range endpoint.Extensions {
|
||||
if endpoint.Extensions[idx].Type == extensionType {
|
||||
extension = &endpoint.Extensions[idx]
|
||||
}
|
||||
}
|
||||
|
||||
if extension != nil {
|
||||
extension.URL = payload.URL
|
||||
} else {
|
||||
extension = &portainer.EndpointExtension{
|
||||
Type: extensionType,
|
||||
URL: payload.URL,
|
||||
}
|
||||
endpoint.Extensions = append(endpoint.Extensions, *extension)
|
||||
}
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
|
||||
}
|
||||
|
||||
return response.JSON(w, extension)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package endpoints
|
||||
|
||||
// TODO: legacy extension management
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// @id endpointExtensionRemove
|
||||
// @tags endpoints
|
||||
// @deprecated
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param extensionType path string true "Extension Type"
|
||||
// @success 204 "Success"
|
||||
// @router /endpoints/{id}/extensions/{extensionType} [delete]
|
||||
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 environment identifier route variable", err}
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if handler.DataStore.IsErrObjectNotFound(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 environment with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
extensionType, err := request.RetrieveNumericRouteVariableValue(r, "extensionType")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension type route variable", err}
|
||||
}
|
||||
|
||||
for idx, ext := range endpoint.Extensions {
|
||||
if ext.Type == portainer.EndpointExtensionType(extensionType) {
|
||||
endpoint.Extensions = append(endpoint.Extensions[:idx], endpoint.Extensions[idx+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Endpoint().UpdateEndpoint(endpoint.ID, endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment changes inside the database", err}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
@@ -62,10 +62,6 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/endpoints/{id}/dockerhub/{registryId}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointDockerhubStatus))).Methods(http.MethodGet)
|
||||
h.Handle("/endpoints/{id}/extensions",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints/{id}/extensions/{extensionType}",
|
||||
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete)
|
||||
h.Handle("/endpoints/{id}/snapshot",
|
||||
bouncer.AdminAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
|
||||
h.Handle("/endpoints/{id}/status",
|
||||
|
||||
@@ -80,7 +80,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.11.1
|
||||
// @version 2.11.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
@@ -190,8 +190,6 @@ 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, "/kubernetes/"):
|
||||
http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r)
|
||||
case strings.Contains(r.URL.Path, "/storidge/"):
|
||||
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/"):
|
||||
|
||||
@@ -2,9 +2,8 @@ package registries
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
|
||||
@@ -116,18 +116,22 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||
}
|
||||
|
||||
if payload.HelmRepositoryURL != nil {
|
||||
newHelmRepo := strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/")
|
||||
if *payload.HelmRepositoryURL != "" {
|
||||
|
||||
newHelmRepo := strings.TrimSuffix(strings.ToLower(*payload.HelmRepositoryURL), "/")
|
||||
|
||||
if newHelmRepo != settings.HelmRepositoryURL && newHelmRepo != portainer.DefaultHelmRepositoryURL {
|
||||
err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Helm repository URL. Must correspond to a valid URL format", err}
|
||||
}
|
||||
|
||||
if newHelmRepo != settings.HelmRepositoryURL && newHelmRepo != portainer.DefaultHelmRepositoryURL {
|
||||
err := libhelm.ValidateHelmRepositoryURL(*payload.HelmRepositoryURL)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid Helm repository URL. Must correspond to a valid URL format", err}
|
||||
}
|
||||
}
|
||||
|
||||
settings.HelmRepositoryURL = newHelmRepo
|
||||
} else {
|
||||
settings.HelmRepositoryURL = ""
|
||||
settings.HelmRepositoryURL = newHelmRepo
|
||||
} else {
|
||||
settings.HelmRepositoryURL = ""
|
||||
}
|
||||
}
|
||||
|
||||
if payload.BlackListedLabels != nil {
|
||||
|
||||
@@ -123,15 +123,6 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
|
||||
}
|
||||
}
|
||||
|
||||
// Must not be git based stack. stop the auto update job if there is any
|
||||
if stack.AutoUpdate != nil {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
stack.AutoUpdate = nil
|
||||
}
|
||||
if stack.GitConfig != nil {
|
||||
stack.FromAppTemplate = true
|
||||
}
|
||||
|
||||
updateError := handler.updateAndDeployStack(r, stack, endpoint)
|
||||
if updateError != nil {
|
||||
return updateError
|
||||
@@ -171,6 +162,15 @@ func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.S
|
||||
}
|
||||
|
||||
func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
// Must not be git based stack. stop the auto update job if there is any
|
||||
if stack.AutoUpdate != nil {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
stack.AutoUpdate = nil
|
||||
}
|
||||
if stack.GitConfig != nil {
|
||||
stack.FromAppTemplate = true
|
||||
}
|
||||
|
||||
var payload updateComposeStackPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
@@ -199,6 +199,15 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||
}
|
||||
|
||||
func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
// Must not be git based stack. stop the auto update job if there is any
|
||||
if stack.AutoUpdate != nil {
|
||||
stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler)
|
||||
stack.AutoUpdate = nil
|
||||
}
|
||||
if stack.GitConfig != nil {
|
||||
stack.FromAppTemplate = true
|
||||
}
|
||||
|
||||
var payload updateSwarmStackPayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
|
||||
@@ -211,9 +211,6 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, end
|
||||
}
|
||||
|
||||
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")}
|
||||
}
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Failed to retrieve user token data", Err: err}
|
||||
|
||||
@@ -3,6 +3,7 @@ package users
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -99,6 +100,7 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure}
|
||||
}
|
||||
user.TokenIssueAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
if payload.Role != 0 {
|
||||
@@ -116,6 +118,5 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
||||
|
||||
// remove all of the users persisted API keys
|
||||
handler.apiKeyService.InvalidateUserKeyCache(user.ID)
|
||||
|
||||
return response.JSON(w, user)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package users
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -85,6 +86,8 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to hash user password", errCryptoHashFailure}
|
||||
}
|
||||
|
||||
user.TokenIssueAt = time.Now().Unix()
|
||||
|
||||
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}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
// Handler is the HTTP handler used to handle webhook operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer *security.RequestBouncer
|
||||
DataStore dataservices.DataStore
|
||||
DockerClientFactory *docker.ClientFactory
|
||||
}
|
||||
@@ -20,7 +23,8 @@ type Handler struct {
|
||||
// NewHandler creates a handler to manage webhooks operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
h.Handle("/webhooks",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookCreate))).Methods(http.MethodPost)
|
||||
@@ -34,3 +38,43 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.webhookExecute))).Methods(http.MethodPost)
|
||||
return h
|
||||
}
|
||||
|
||||
func (handler *Handler) checkResourceAccess(r *http.Request, resourceID string, resourceControlType portainer.ResourceControlType) *httperror.HandlerError {
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user info from request context", Err: err}
|
||||
}
|
||||
// non-admins
|
||||
rc, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(resourceID, resourceControlType)
|
||||
if rc == nil || err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the resource", Err: err}
|
||||
}
|
||||
userTeamIDs := make([]portainer.TeamID, 0)
|
||||
for _, membership := range securityContext.UserMemberships {
|
||||
userTeamIDs = append(userTeamIDs, membership.TeamID)
|
||||
}
|
||||
canAccess := authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, rc)
|
||||
if !canAccess {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "This operation is disabled for non-admin users and unassigned access users"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) checkAuthorization(r *http.Request, endpoint *portainer.Endpoint, authorizations []portainer.Authorization) (bool, *httperror.HandlerError) {
|
||||
err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
|
||||
if err != nil {
|
||||
return false, &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access environment", Err: err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return false, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user info from request context", Err: err}
|
||||
}
|
||||
|
||||
authService := authorization.NewService(handler.DataStore)
|
||||
isAdminOrAuthorized, err := authService.UserIsAdminOrAuthorized(securityContext.UserID, endpoint.ID, authorizations)
|
||||
if err != nil {
|
||||
return false, &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to get user authorizations", Err: err}
|
||||
}
|
||||
return isAdminOrAuthorized, nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ package webhooks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils/access"
|
||||
"net/http"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gofrs/uuid"
|
||||
@@ -65,6 +64,15 @@ func (handler *Handler) webhookCreate(w http.ResponseWriter, r *http.Request) *h
|
||||
|
||||
endpointID := portainer.EndpointID(payload.EndpointID)
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user info from request context", Err: err}
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Not authorized to create a webhook", Err: errors.New("not authorized to create a webhook")}
|
||||
}
|
||||
|
||||
if payload.RegistryID != 0 {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -25,6 +27,15 @@ func (handler *Handler) webhookDelete(w http.ResponseWriter, r *http.Request) *h
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid webhook id", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user info from request context", Err: err}
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Not authorized to delete a webhook", Err: errors.New("not authorized to delete a webhook")}
|
||||
}
|
||||
|
||||
err = handler.DataStore.Webhook().DeleteWebhook(portainer.WebhookID(id))
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the webhook from the database", err}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -111,7 +112,15 @@ func (handler *Handler) executeServiceWebhook(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if imageTag != "" {
|
||||
rc, err := dockerClient.ImagePull(context.Background(), service.Spec.TaskTemplate.ContainerSpec.Image, dockertypes.ImagePullOptions{RegistryAuth: serviceUpdateOptions.EncodedRegistryAuth})
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Error pulling image with the specified tag", Err: err}
|
||||
}
|
||||
defer func(rc io.ReadCloser) {
|
||||
_ = rc.Close()
|
||||
}(rc)
|
||||
}
|
||||
_, err = dockerClient.ServiceUpdate(context.Background(), resourceID, service.Version, service.Spec, serviceUpdateOptions)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -33,6 +34,14 @@ func (handler *Handler) webhookList(w http.ResponseWriter, r *http.Request) *htt
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: filters", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user info from request context", Err: err}
|
||||
}
|
||||
if !securityContext.IsAdmin {
|
||||
return response.JSON(w, []portainer.Webhook{})
|
||||
}
|
||||
|
||||
webhooks, err := handler.DataStore.Webhook().Webhooks()
|
||||
webhooks = filterWebhooks(webhooks, &filters)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
@@ -53,6 +54,15 @@ func (handler *Handler) webhookUpdate(w http.ResponseWriter, r *http.Request) *h
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a webhooks with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve user info from request context", Err: err}
|
||||
}
|
||||
|
||||
if !securityContext.IsAdmin {
|
||||
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Not authorized to update a webhook", Err: errors.New("not authorized to update a webhook")}
|
||||
}
|
||||
|
||||
if payload.RegistryID != 0 {
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,8 +2,6 @@ package factory
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -40,18 +38,6 @@ func NewProxyFactory(dataStore dataservices.DataStore, signatureService portaine
|
||||
}
|
||||
}
|
||||
|
||||
// NewLegacyExtensionProxy returns a new HTTP proxy to a legacy extension server (Storidge)
|
||||
func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (http.Handler, error) {
|
||||
extensionURL, err := url.Parse(extensionAPIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
extensionURL.Scheme = "http"
|
||||
proxy := httputil.NewSingleHostReverseProxy(extensionURL)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (transport *baseTransport) refreshRegistry(request *http.Request, namespace string) (err error) {
|
||||
|
||||
@@ -15,25 +15,21 @@ import (
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
)
|
||||
|
||||
// TODO: contain code related to legacy extension management
|
||||
|
||||
type (
|
||||
// Manager represents a service used to manage proxies to environments(endpoints) and extensions.
|
||||
// Manager represents a service used to manage proxies to environments (endpoints).
|
||||
Manager struct {
|
||||
proxyFactory *factory.ProxyFactory
|
||||
endpointProxies cmap.ConcurrentMap
|
||||
legacyExtensionProxies cmap.ConcurrentMap
|
||||
k8sClientFactory *cli.ClientFactory
|
||||
proxyFactory *factory.ProxyFactory
|
||||
endpointProxies cmap.ConcurrentMap
|
||||
k8sClientFactory *cli.ClientFactory
|
||||
}
|
||||
)
|
||||
|
||||
// NewManager initializes a new proxy Service
|
||||
func NewManager(dataStore dataservices.DataStore, signatureService portainer.DigitalSignatureService, tunnelService portainer.ReverseTunnelService, clientFactory *docker.ClientFactory, kubernetesClientFactory *cli.ClientFactory, kubernetesTokenCacheManager *kubernetes.TokenCacheManager) *Manager {
|
||||
return &Manager{
|
||||
endpointProxies: cmap.New(),
|
||||
legacyExtensionProxies: cmap.New(),
|
||||
k8sClientFactory: kubernetesClientFactory,
|
||||
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager),
|
||||
endpointProxies: cmap.New(),
|
||||
k8sClientFactory: kubernetesClientFactory,
|
||||
proxyFactory: factory.NewProxyFactory(dataStore, signatureService, tunnelService, clientFactory, kubernetesClientFactory, kubernetesTokenCacheManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,26 +69,6 @@ func (manager *Manager) DeleteEndpointProxy(endpointID portainer.EndpointID) {
|
||||
manager.k8sClientFactory.RemoveKubeClient(endpointID)
|
||||
}
|
||||
|
||||
// CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies
|
||||
func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) (http.Handler, error) {
|
||||
proxy, err := manager.proxyFactory.NewLegacyExtensionProxy(extensionAPIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager.legacyExtensionProxies.Set(key, proxy)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// GetLegacyExtensionProxy returns a legacy extension proxy associated to a key
|
||||
func (manager *Manager) GetLegacyExtensionProxy(key string) http.Handler {
|
||||
proxy, ok := manager.legacyExtensionProxies.Get(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return proxy.(http.Handler)
|
||||
}
|
||||
|
||||
// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API
|
||||
func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) {
|
||||
return manager.proxyFactory.NewGitlabProxy(url)
|
||||
|
||||
@@ -153,7 +153,6 @@ func DefaultEndpointAuthorizationsForEndpointAdministratorRole() portainer.Autho
|
||||
portainer.OperationPortainerWebhookList: true,
|
||||
portainer.OperationPortainerWebhookCreate: true,
|
||||
portainer.OperationPortainerWebhookDelete: true,
|
||||
portainer.OperationIntegrationStoridgeAdmin: true,
|
||||
portainer.EndpointResourcesAccess: true,
|
||||
}
|
||||
}
|
||||
@@ -412,21 +411,19 @@ func DefaultEndpointAuthorizationsForReadOnlyUserRole(volumeBrowsingAuthorizatio
|
||||
// DefaultPortainerAuthorizations returns the default Portainer authorizations used by non-admin users.
|
||||
func DefaultPortainerAuthorizations() portainer.Authorizations {
|
||||
return map[portainer.Authorization]bool{
|
||||
portainer.OperationPortainerDockerHubInspect: true,
|
||||
portainer.OperationPortainerEndpointGroupList: true,
|
||||
portainer.OperationPortainerEndpointList: true,
|
||||
portainer.OperationPortainerEndpointInspect: true,
|
||||
portainer.OperationPortainerEndpointExtensionAdd: true,
|
||||
portainer.OperationPortainerEndpointExtensionRemove: true,
|
||||
portainer.OperationPortainerMOTD: true,
|
||||
portainer.OperationPortainerRegistryList: true,
|
||||
portainer.OperationPortainerRegistryInspect: true,
|
||||
portainer.OperationPortainerTeamList: true,
|
||||
portainer.OperationPortainerTemplateList: true,
|
||||
portainer.OperationPortainerTemplateInspect: true,
|
||||
portainer.OperationPortainerUserList: true,
|
||||
portainer.OperationPortainerUserInspect: true,
|
||||
portainer.OperationPortainerUserMemberships: true,
|
||||
portainer.OperationPortainerDockerHubInspect: true,
|
||||
portainer.OperationPortainerEndpointGroupList: true,
|
||||
portainer.OperationPortainerEndpointList: true,
|
||||
portainer.OperationPortainerEndpointInspect: true,
|
||||
portainer.OperationPortainerMOTD: true,
|
||||
portainer.OperationPortainerRegistryList: true,
|
||||
portainer.OperationPortainerRegistryInspect: true,
|
||||
portainer.OperationPortainerTeamList: true,
|
||||
portainer.OperationPortainerTemplateList: true,
|
||||
portainer.OperationPortainerTemplateInspect: true,
|
||||
portainer.OperationPortainerUserList: true,
|
||||
portainer.OperationPortainerUserInspect: true,
|
||||
portainer.OperationPortainerUserMemberships: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,3 +600,21 @@ func getAuthorizationsFromRoles(roleIdentifiers []portainer.RoleID, roles []port
|
||||
|
||||
return authorizations
|
||||
}
|
||||
|
||||
func (service *Service) UserIsAdminOrAuthorized(userID portainer.UserID, endpointID portainer.EndpointID, authorizations []portainer.Authorization) (bool, error) {
|
||||
user, err := service.dataStore.User().User(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if user.Role == portainer.AdministratorRole {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
for _, authorization := range authorizations {
|
||||
_, authorized := user.EndpointAuthorizations[endpointID][authorization]
|
||||
if authorized {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"io"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/dataservices/errors"
|
||||
)
|
||||
@@ -33,7 +32,6 @@ type testDatastore struct {
|
||||
user dataservices.UserService
|
||||
version dataservices.VersionService
|
||||
webhook dataservices.WebhookService
|
||||
connection portainer.Connection
|
||||
}
|
||||
|
||||
func (d *testDatastore) BackupTo(io.Writer) error { return nil }
|
||||
@@ -81,10 +79,6 @@ func (d *testDatastore) IsErrObjectNotFound(e error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *testDatastore) Connection() portainer.Connection {
|
||||
return d.connection
|
||||
}
|
||||
|
||||
func (d *testDatastore) Export(filename string) (err error) {
|
||||
return nil
|
||||
}
|
||||
@@ -97,12 +91,10 @@ type datastoreOption = func(d *testDatastore)
|
||||
// NewDatastore creates new instance of testDatastore.
|
||||
// Will apply options before returning, opts will be applied from left to right.
|
||||
func NewDatastore(options ...datastoreOption) *testDatastore {
|
||||
conn, _ := database.NewDatabase("boltdb", "", nil)
|
||||
d := testDatastore{connection: conn}
|
||||
d := testDatastore{}
|
||||
for _, o := range options {
|
||||
o(&d)
|
||||
}
|
||||
|
||||
return &d
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,14 @@ func (service *Service) ParseAndVerifyToken(token string) (*portainer.TokenData,
|
||||
|
||||
if err == nil && parsedToken != nil {
|
||||
if cl, ok := parsedToken.Claims.(*claims); ok && parsedToken.Valid {
|
||||
|
||||
user, err := service.dataStore.User().User(portainer.UserID(cl.UserID))
|
||||
if err != nil {
|
||||
return nil, errInvalidJWTToken
|
||||
}
|
||||
if user.TokenIssueAt > cl.StandardClaims.IssuedAt {
|
||||
return nil, errInvalidJWTToken
|
||||
}
|
||||
return &portainer.TokenData{
|
||||
ID: portainer.UserID(cl.UserID),
|
||||
Username: cl.Username,
|
||||
@@ -162,6 +170,7 @@ func (service *Service) generateSignedToken(data *portainer.TokenData, expiresAt
|
||||
Scope: scope,
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
ExpiresAt: expiresAt,
|
||||
IssuedAt: time.Now().Unix(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
208
api/portainer.go
208
api/portainer.go
@@ -296,10 +296,9 @@ type (
|
||||
// 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:""`
|
||||
PublicURL string `json:"PublicURL" example:"docker.mydomain.tld:2375"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty" example:""`
|
||||
// List of tag identifiers to which this environment(endpoint) is associated
|
||||
TagIDs []TagID `json:"TagIds"`
|
||||
// The status of the environment(endpoint) (1 - up, 2 - down)
|
||||
@@ -349,17 +348,6 @@ type (
|
||||
// EndpointAuthorizations represents the authorizations associated to a set of environments(endpoints)
|
||||
EndpointAuthorizations map[EndpointID]Authorizations
|
||||
|
||||
// EndpointExtension represents a deprecated form of Portainer extension
|
||||
// TODO: legacy extension management
|
||||
EndpointExtension struct {
|
||||
Type EndpointExtensionType `json:"Type"`
|
||||
URL string `json:"URL"`
|
||||
}
|
||||
|
||||
// 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 environments(endpoints)
|
||||
EndpointGroup struct {
|
||||
// Environment(Endpoint) group Identifier
|
||||
@@ -1138,7 +1126,8 @@ type (
|
||||
// User Theme
|
||||
UserTheme string `example:"dark"`
|
||||
// User role (1 for administrator account and 2 for regular account)
|
||||
Role UserRole `json:"Role" example:"1"`
|
||||
Role UserRole `json:"Role" example:"1"`
|
||||
TokenIssueAt int64 `json:"TokenIssueAt" example:"1"`
|
||||
|
||||
// Deprecated fields
|
||||
// Deprecated in DBVersion == 25
|
||||
@@ -1347,7 +1336,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.11.1"
|
||||
APIVersion = "2.11.0"
|
||||
// DBVersion is the version number of the Portainer database
|
||||
DBVersion = 35
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
@@ -1449,12 +1438,6 @@ const (
|
||||
StatusAcknowledged
|
||||
)
|
||||
|
||||
const (
|
||||
_ EndpointExtensionType = iota
|
||||
// StoridgeEndpointExtension represents the Storidge extension
|
||||
StoridgeEndpointExtension
|
||||
)
|
||||
|
||||
const (
|
||||
_ EndpointStatus = iota
|
||||
// EndpointStatusUp is used to represent an available environment(endpoint)
|
||||
@@ -1729,101 +1712,102 @@ const (
|
||||
OperationDockerAgentBrowsePut Authorization = "DockerAgentBrowsePut"
|
||||
OperationDockerAgentBrowseRename Authorization = "DockerAgentBrowseRename"
|
||||
|
||||
OperationPortainerDockerHubInspect Authorization = "PortainerDockerHubInspect"
|
||||
OperationPortainerDockerHubUpdate Authorization = "PortainerDockerHubUpdate"
|
||||
OperationPortainerEndpointGroupCreate Authorization = "PortainerEndpointGroupCreate"
|
||||
OperationPortainerEndpointGroupList Authorization = "PortainerEndpointGroupList"
|
||||
OperationPortainerEndpointGroupDelete Authorization = "PortainerEndpointGroupDelete"
|
||||
OperationPortainerEndpointGroupInspect Authorization = "PortainerEndpointGroupInspect"
|
||||
OperationPortainerEndpointGroupUpdate Authorization = "PortainerEndpointGroupEdit"
|
||||
OperationPortainerEndpointGroupAccess Authorization = "PortainerEndpointGroupAccess "
|
||||
OperationPortainerEndpointList Authorization = "PortainerEndpointList"
|
||||
OperationPortainerEndpointInspect Authorization = "PortainerEndpointInspect"
|
||||
OperationPortainerEndpointCreate Authorization = "PortainerEndpointCreate"
|
||||
OperationPortainerEndpointExtensionAdd Authorization = "PortainerEndpointExtensionAdd"
|
||||
OperationPortainerEndpointJob Authorization = "PortainerEndpointJob"
|
||||
OperationPortainerEndpointSnapshots Authorization = "PortainerEndpointSnapshots"
|
||||
OperationPortainerEndpointSnapshot Authorization = "PortainerEndpointSnapshot"
|
||||
OperationPortainerEndpointUpdate Authorization = "PortainerEndpointUpdate"
|
||||
OperationPortainerEndpointUpdateAccess Authorization = "PortainerEndpointUpdateAccess"
|
||||
OperationPortainerEndpointDelete Authorization = "PortainerEndpointDelete"
|
||||
OperationPortainerEndpointExtensionRemove Authorization = "PortainerEndpointExtensionRemove"
|
||||
OperationPortainerExtensionList Authorization = "PortainerExtensionList"
|
||||
OperationPortainerExtensionInspect Authorization = "PortainerExtensionInspect"
|
||||
OperationPortainerExtensionCreate Authorization = "PortainerExtensionCreate"
|
||||
OperationPortainerExtensionUpdate Authorization = "PortainerExtensionUpdate"
|
||||
OperationPortainerExtensionDelete Authorization = "PortainerExtensionDelete"
|
||||
OperationPortainerMOTD Authorization = "PortainerMOTD"
|
||||
OperationPortainerRegistryList Authorization = "PortainerRegistryList"
|
||||
OperationPortainerRegistryInspect Authorization = "PortainerRegistryInspect"
|
||||
OperationPortainerRegistryCreate Authorization = "PortainerRegistryCreate"
|
||||
OperationPortainerRegistryConfigure Authorization = "PortainerRegistryConfigure"
|
||||
OperationPortainerRegistryUpdate Authorization = "PortainerRegistryUpdate"
|
||||
OperationPortainerRegistryUpdateAccess Authorization = "PortainerRegistryUpdateAccess"
|
||||
OperationPortainerRegistryDelete Authorization = "PortainerRegistryDelete"
|
||||
OperationPortainerResourceControlCreate Authorization = "PortainerResourceControlCreate"
|
||||
OperationPortainerResourceControlUpdate Authorization = "PortainerResourceControlUpdate"
|
||||
OperationPortainerResourceControlDelete Authorization = "PortainerResourceControlDelete"
|
||||
OperationPortainerRoleList Authorization = "PortainerRoleList"
|
||||
OperationPortainerRoleInspect Authorization = "PortainerRoleInspect"
|
||||
OperationPortainerRoleCreate Authorization = "PortainerRoleCreate"
|
||||
OperationPortainerRoleUpdate Authorization = "PortainerRoleUpdate"
|
||||
OperationPortainerRoleDelete Authorization = "PortainerRoleDelete"
|
||||
OperationPortainerScheduleList Authorization = "PortainerScheduleList"
|
||||
OperationPortainerScheduleInspect Authorization = "PortainerScheduleInspect"
|
||||
OperationPortainerScheduleFile Authorization = "PortainerScheduleFile"
|
||||
OperationPortainerScheduleTasks Authorization = "PortainerScheduleTasks"
|
||||
OperationPortainerScheduleCreate Authorization = "PortainerScheduleCreate"
|
||||
OperationPortainerScheduleUpdate Authorization = "PortainerScheduleUpdate"
|
||||
OperationPortainerScheduleDelete Authorization = "PortainerScheduleDelete"
|
||||
OperationPortainerSettingsInspect Authorization = "PortainerSettingsInspect"
|
||||
OperationPortainerSettingsUpdate Authorization = "PortainerSettingsUpdate"
|
||||
OperationPortainerSettingsLDAPCheck Authorization = "PortainerSettingsLDAPCheck"
|
||||
OperationPortainerStackList Authorization = "PortainerStackList"
|
||||
OperationPortainerStackInspect Authorization = "PortainerStackInspect"
|
||||
OperationPortainerStackFile Authorization = "PortainerStackFile"
|
||||
OperationPortainerStackCreate Authorization = "PortainerStackCreate"
|
||||
OperationPortainerStackMigrate Authorization = "PortainerStackMigrate"
|
||||
OperationPortainerStackUpdate Authorization = "PortainerStackUpdate"
|
||||
OperationPortainerStackDelete Authorization = "PortainerStackDelete"
|
||||
OperationPortainerTagList Authorization = "PortainerTagList"
|
||||
OperationPortainerTagCreate Authorization = "PortainerTagCreate"
|
||||
OperationPortainerTagDelete Authorization = "PortainerTagDelete"
|
||||
OperationPortainerTeamMembershipList Authorization = "PortainerTeamMembershipList"
|
||||
OperationPortainerTeamMembershipCreate Authorization = "PortainerTeamMembershipCreate"
|
||||
OperationPortainerTeamMembershipUpdate Authorization = "PortainerTeamMembershipUpdate"
|
||||
OperationPortainerTeamMembershipDelete Authorization = "PortainerTeamMembershipDelete"
|
||||
OperationPortainerTeamList Authorization = "PortainerTeamList"
|
||||
OperationPortainerTeamInspect Authorization = "PortainerTeamInspect"
|
||||
OperationPortainerTeamMemberships Authorization = "PortainerTeamMemberships"
|
||||
OperationPortainerTeamCreate Authorization = "PortainerTeamCreate"
|
||||
OperationPortainerTeamUpdate Authorization = "PortainerTeamUpdate"
|
||||
OperationPortainerTeamDelete Authorization = "PortainerTeamDelete"
|
||||
OperationPortainerTemplateList Authorization = "PortainerTemplateList"
|
||||
OperationPortainerTemplateInspect Authorization = "PortainerTemplateInspect"
|
||||
OperationPortainerTemplateCreate Authorization = "PortainerTemplateCreate"
|
||||
OperationPortainerTemplateUpdate Authorization = "PortainerTemplateUpdate"
|
||||
OperationPortainerTemplateDelete Authorization = "PortainerTemplateDelete"
|
||||
OperationPortainerUploadTLS Authorization = "PortainerUploadTLS"
|
||||
OperationPortainerUserList Authorization = "PortainerUserList"
|
||||
OperationPortainerUserInspect Authorization = "PortainerUserInspect"
|
||||
OperationPortainerUserMemberships Authorization = "PortainerUserMemberships"
|
||||
OperationPortainerUserCreate Authorization = "PortainerUserCreate"
|
||||
OperationPortainerUserUpdate Authorization = "PortainerUserUpdate"
|
||||
OperationPortainerUserUpdatePassword Authorization = "PortainerUserUpdatePassword"
|
||||
OperationPortainerUserDelete Authorization = "PortainerUserDelete"
|
||||
OperationPortainerWebsocketExec Authorization = "PortainerWebsocketExec"
|
||||
OperationPortainerWebhookList Authorization = "PortainerWebhookList"
|
||||
OperationPortainerWebhookCreate Authorization = "PortainerWebhookCreate"
|
||||
OperationPortainerWebhookDelete Authorization = "PortainerWebhookDelete"
|
||||
|
||||
OperationIntegrationStoridgeAdmin Authorization = "IntegrationStoridgeAdmin"
|
||||
OperationPortainerDockerHubInspect Authorization = "PortainerDockerHubInspect"
|
||||
OperationPortainerDockerHubUpdate Authorization = "PortainerDockerHubUpdate"
|
||||
OperationPortainerEndpointGroupCreate Authorization = "PortainerEndpointGroupCreate"
|
||||
OperationPortainerEndpointGroupList Authorization = "PortainerEndpointGroupList"
|
||||
OperationPortainerEndpointGroupDelete Authorization = "PortainerEndpointGroupDelete"
|
||||
OperationPortainerEndpointGroupInspect Authorization = "PortainerEndpointGroupInspect"
|
||||
OperationPortainerEndpointGroupUpdate Authorization = "PortainerEndpointGroupEdit"
|
||||
OperationPortainerEndpointGroupAccess Authorization = "PortainerEndpointGroupAccess "
|
||||
OperationPortainerEndpointList Authorization = "PortainerEndpointList"
|
||||
OperationPortainerEndpointInspect Authorization = "PortainerEndpointInspect"
|
||||
OperationPortainerEndpointCreate Authorization = "PortainerEndpointCreate"
|
||||
OperationPortainerEndpointJob Authorization = "PortainerEndpointJob"
|
||||
OperationPortainerEndpointSnapshots Authorization = "PortainerEndpointSnapshots"
|
||||
OperationPortainerEndpointSnapshot Authorization = "PortainerEndpointSnapshot"
|
||||
OperationPortainerEndpointUpdate Authorization = "PortainerEndpointUpdate"
|
||||
OperationPortainerEndpointUpdateAccess Authorization = "PortainerEndpointUpdateAccess"
|
||||
OperationPortainerEndpointDelete Authorization = "PortainerEndpointDelete"
|
||||
OperationPortainerExtensionList Authorization = "PortainerExtensionList"
|
||||
OperationPortainerExtensionInspect Authorization = "PortainerExtensionInspect"
|
||||
OperationPortainerExtensionCreate Authorization = "PortainerExtensionCreate"
|
||||
OperationPortainerExtensionUpdate Authorization = "PortainerExtensionUpdate"
|
||||
OperationPortainerExtensionDelete Authorization = "PortainerExtensionDelete"
|
||||
OperationPortainerMOTD Authorization = "PortainerMOTD"
|
||||
OperationPortainerRegistryList Authorization = "PortainerRegistryList"
|
||||
OperationPortainerRegistryInspect Authorization = "PortainerRegistryInspect"
|
||||
OperationPortainerRegistryCreate Authorization = "PortainerRegistryCreate"
|
||||
OperationPortainerRegistryConfigure Authorization = "PortainerRegistryConfigure"
|
||||
OperationPortainerRegistryUpdate Authorization = "PortainerRegistryUpdate"
|
||||
OperationPortainerRegistryUpdateAccess Authorization = "PortainerRegistryUpdateAccess"
|
||||
OperationPortainerRegistryDelete Authorization = "PortainerRegistryDelete"
|
||||
OperationPortainerResourceControlCreate Authorization = "PortainerResourceControlCreate"
|
||||
OperationPortainerResourceControlUpdate Authorization = "PortainerResourceControlUpdate"
|
||||
OperationPortainerResourceControlDelete Authorization = "PortainerResourceControlDelete"
|
||||
OperationPortainerRoleList Authorization = "PortainerRoleList"
|
||||
OperationPortainerRoleInspect Authorization = "PortainerRoleInspect"
|
||||
OperationPortainerRoleCreate Authorization = "PortainerRoleCreate"
|
||||
OperationPortainerRoleUpdate Authorization = "PortainerRoleUpdate"
|
||||
OperationPortainerRoleDelete Authorization = "PortainerRoleDelete"
|
||||
OperationPortainerScheduleList Authorization = "PortainerScheduleList"
|
||||
OperationPortainerScheduleInspect Authorization = "PortainerScheduleInspect"
|
||||
OperationPortainerScheduleFile Authorization = "PortainerScheduleFile"
|
||||
OperationPortainerScheduleTasks Authorization = "PortainerScheduleTasks"
|
||||
OperationPortainerScheduleCreate Authorization = "PortainerScheduleCreate"
|
||||
OperationPortainerScheduleUpdate Authorization = "PortainerScheduleUpdate"
|
||||
OperationPortainerScheduleDelete Authorization = "PortainerScheduleDelete"
|
||||
OperationPortainerSettingsInspect Authorization = "PortainerSettingsInspect"
|
||||
OperationPortainerSettingsUpdate Authorization = "PortainerSettingsUpdate"
|
||||
OperationPortainerSettingsLDAPCheck Authorization = "PortainerSettingsLDAPCheck"
|
||||
OperationPortainerStackList Authorization = "PortainerStackList"
|
||||
OperationPortainerStackInspect Authorization = "PortainerStackInspect"
|
||||
OperationPortainerStackFile Authorization = "PortainerStackFile"
|
||||
OperationPortainerStackCreate Authorization = "PortainerStackCreate"
|
||||
OperationPortainerStackMigrate Authorization = "PortainerStackMigrate"
|
||||
OperationPortainerStackUpdate Authorization = "PortainerStackUpdate"
|
||||
OperationPortainerStackDelete Authorization = "PortainerStackDelete"
|
||||
OperationPortainerTagList Authorization = "PortainerTagList"
|
||||
OperationPortainerTagCreate Authorization = "PortainerTagCreate"
|
||||
OperationPortainerTagDelete Authorization = "PortainerTagDelete"
|
||||
OperationPortainerTeamMembershipList Authorization = "PortainerTeamMembershipList"
|
||||
OperationPortainerTeamMembershipCreate Authorization = "PortainerTeamMembershipCreate"
|
||||
OperationPortainerTeamMembershipUpdate Authorization = "PortainerTeamMembershipUpdate"
|
||||
OperationPortainerTeamMembershipDelete Authorization = "PortainerTeamMembershipDelete"
|
||||
OperationPortainerTeamList Authorization = "PortainerTeamList"
|
||||
OperationPortainerTeamInspect Authorization = "PortainerTeamInspect"
|
||||
OperationPortainerTeamMemberships Authorization = "PortainerTeamMemberships"
|
||||
OperationPortainerTeamCreate Authorization = "PortainerTeamCreate"
|
||||
OperationPortainerTeamUpdate Authorization = "PortainerTeamUpdate"
|
||||
OperationPortainerTeamDelete Authorization = "PortainerTeamDelete"
|
||||
OperationPortainerTemplateList Authorization = "PortainerTemplateList"
|
||||
OperationPortainerTemplateInspect Authorization = "PortainerTemplateInspect"
|
||||
OperationPortainerTemplateCreate Authorization = "PortainerTemplateCreate"
|
||||
OperationPortainerTemplateUpdate Authorization = "PortainerTemplateUpdate"
|
||||
OperationPortainerTemplateDelete Authorization = "PortainerTemplateDelete"
|
||||
OperationPortainerUploadTLS Authorization = "PortainerUploadTLS"
|
||||
OperationPortainerUserList Authorization = "PortainerUserList"
|
||||
OperationPortainerUserInspect Authorization = "PortainerUserInspect"
|
||||
OperationPortainerUserMemberships Authorization = "PortainerUserMemberships"
|
||||
OperationPortainerUserCreate Authorization = "PortainerUserCreate"
|
||||
OperationPortainerUserUpdate Authorization = "PortainerUserUpdate"
|
||||
OperationPortainerUserUpdatePassword Authorization = "PortainerUserUpdatePassword"
|
||||
OperationPortainerUserDelete Authorization = "PortainerUserDelete"
|
||||
OperationPortainerWebsocketExec Authorization = "PortainerWebsocketExec"
|
||||
OperationPortainerWebhookList Authorization = "PortainerWebhookList"
|
||||
OperationPortainerWebhookCreate Authorization = "PortainerWebhookCreate"
|
||||
OperationPortainerWebhookDelete Authorization = "PortainerWebhookDelete"
|
||||
|
||||
OperationDockerUndefined Authorization = "DockerUndefined"
|
||||
OperationDockerAgentUndefined Authorization = "DockerAgentUndefined"
|
||||
OperationPortainerUndefined Authorization = "PortainerUndefined"
|
||||
|
||||
EndpointResourcesAccess Authorization = "EndpointResourcesAccess"
|
||||
|
||||
// Deprecated operations
|
||||
OperationPortainerEndpointExtensionAdd Authorization = "PortainerEndpointExtensionAdd"
|
||||
OperationPortainerEndpointExtensionRemove Authorization = "PortainerEndpointExtensionRemove"
|
||||
OperationIntegrationStoridgeAdmin Authorization = "IntegrationStoridgeAdmin"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -849,10 +849,6 @@ definitions:
|
||||
EdgeKey:
|
||||
description: The key which is used to map the agent to Portainer
|
||||
type: string
|
||||
Extensions:
|
||||
items:
|
||||
$ref: '#/definitions/portainer.EndpointExtension'
|
||||
type: array
|
||||
GroupId:
|
||||
description: Endpoint group identifier
|
||||
example: 1
|
||||
@@ -926,13 +922,6 @@ definitions:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/portainer.Authorizations'
|
||||
type: object
|
||||
portainer.EndpointExtension:
|
||||
properties:
|
||||
Type:
|
||||
type: integer
|
||||
URL:
|
||||
type: string
|
||||
type: object
|
||||
portainer.EndpointGroup:
|
||||
properties:
|
||||
AuthorizedTeams:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { renderWithQueryClient } from '@/react-tools/test-utils';
|
||||
|
||||
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
|
||||
jest.mock('@uirouter/react', () => ({
|
||||
...jest.requireActual('@uirouter/react'),
|
||||
useCurrentStateAndParams: jest.fn(() => ({
|
||||
params: { endpointId: 5 },
|
||||
})),
|
||||
}));
|
||||
|
||||
test('submit button should be disabled when name or image is missing', async () => {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
|
||||
const { findByText, getByText, getByLabelText } = renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<CreateContainerInstanceForm />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await expect(findByText(/Azure settings/)).resolves.toBeVisible();
|
||||
|
||||
const button = getByText(/Deploy the container/);
|
||||
expect(button).toBeVisible();
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const nameInput = getByLabelText(/name/i);
|
||||
userEvent.type(nameInput, 'name');
|
||||
|
||||
const imageInput = getByLabelText(/image/i);
|
||||
userEvent.type(imageInput, 'image');
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
|
||||
|
||||
expect(nameInput).toHaveValue('name');
|
||||
userEvent.clear(nameInput);
|
||||
|
||||
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
|
||||
import { FormControl } from '@/portainer/components/form-components/FormControl';
|
||||
import { Input, Select } from '@/portainer/components/form-components/Input';
|
||||
import { FormSectionTitle } from '@/portainer/components/form-components/FormSectionTitle';
|
||||
import { LoadingButton } from '@/portainer/components/Button/LoadingButton';
|
||||
import { InputListError } from '@/portainer/components/form-components/InputList/InputList';
|
||||
import { AccessControlForm } from '@/portainer/components/accessControlForm';
|
||||
import { ContainerInstanceFormValues } from '@/azure/types';
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { isAdmin, useUser } from '@/portainer/hooks/useUser';
|
||||
|
||||
import { validationSchema } from './CreateContainerInstanceForm.validation';
|
||||
import { PortMapping, PortsMappingField } from './PortsMappingField';
|
||||
import { useLoadFormState } from './useLoadFormState';
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
import { useCreateInstance } from './useCreateInstanceMutation';
|
||||
|
||||
export function CreateContainerInstanceForm() {
|
||||
const {
|
||||
params: { endpointId: environmentId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
if (!environmentId) {
|
||||
throw new Error('endpointId url param is required');
|
||||
}
|
||||
|
||||
const { user } = useUser();
|
||||
const isUserAdmin = isAdmin(user);
|
||||
|
||||
const { initialValues, isLoading, providers, subscriptions, resourceGroups } =
|
||||
useLoadFormState(environmentId, isUserAdmin);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync } = useCreateInstance(
|
||||
resourceGroups,
|
||||
environmentId,
|
||||
user?.Id
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik<ContainerInstanceFormValues>
|
||||
initialValues={initialValues}
|
||||
validationSchema={() => validationSchema(isUserAdmin)}
|
||||
onSubmit={onSubmit}
|
||||
validateOnMount
|
||||
validateOnChange
|
||||
enableReinitialize
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
handleSubmit,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
values,
|
||||
setFieldValue,
|
||||
}) => (
|
||||
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
|
||||
<FormSectionTitle>Azure settings</FormSectionTitle>
|
||||
<FormControl
|
||||
label="Subscription"
|
||||
inputId="subscription-input"
|
||||
errors={errors.subscription}
|
||||
>
|
||||
<Field
|
||||
name="subscription"
|
||||
as={Select}
|
||||
id="subscription-input"
|
||||
options={subscriptions}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Resource group"
|
||||
inputId="resourceGroup-input"
|
||||
errors={errors.resourceGroup}
|
||||
>
|
||||
<Field
|
||||
name="resourceGroup"
|
||||
as={Select}
|
||||
id="resourceGroup-input"
|
||||
options={getSubscriptionResourceGroups(
|
||||
values.subscription,
|
||||
resourceGroups
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Location"
|
||||
inputId="location-input"
|
||||
errors={errors.location}
|
||||
>
|
||||
<Field
|
||||
name="location"
|
||||
as={Select}
|
||||
id="location-input"
|
||||
options={getSubscriptionLocations(values.subscription, providers)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormSectionTitle>Container configuration</FormSectionTitle>
|
||||
|
||||
<FormControl label="Name" inputId="name-input" errors={errors.name}>
|
||||
<Field
|
||||
name="name"
|
||||
as={Input}
|
||||
id="name-input"
|
||||
placeholder="e.g. myContainer"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Image"
|
||||
inputId="image-input"
|
||||
errors={errors.image}
|
||||
>
|
||||
<Field
|
||||
name="image"
|
||||
as={Input}
|
||||
id="image-input"
|
||||
placeholder="e.g. nginx:alpine"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="OS" inputId="os-input" errors={errors.os}>
|
||||
<Field
|
||||
name="os"
|
||||
as={Select}
|
||||
id="os-input"
|
||||
options={[
|
||||
{ label: 'Linux', value: 'Linux' },
|
||||
{ label: 'Windows', value: 'Windows' },
|
||||
]}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<PortsMappingField
|
||||
value={values.ports}
|
||||
onChange={(value) => setFieldValue('ports', value)}
|
||||
errors={errors.ports as InputListError<PortMapping>[]}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 small text-muted">
|
||||
This will automatically deploy a container with a public IP
|
||||
address
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormSectionTitle>Container Resources</FormSectionTitle>
|
||||
|
||||
<FormControl label="CPU" inputId="cpu-input" errors={errors.cpu}>
|
||||
<Field
|
||||
name="cpu"
|
||||
as={Input}
|
||||
id="cpu-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Memory"
|
||||
inputId="cpu-input"
|
||||
errors={errors.memory}
|
||||
>
|
||||
<Field
|
||||
name="memory"
|
||||
as={Input}
|
||||
id="memory-input"
|
||||
type="number"
|
||||
placeholder="1"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<AccessControlForm
|
||||
formNamespace="accessControl"
|
||||
onChange={(values) => setFieldValue('accessControl', values)}
|
||||
values={values.accessControl}
|
||||
errors={errors.accessControl}
|
||||
/>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<LoadingButton
|
||||
disabled={!isValid}
|
||||
isLoading={isSubmitting}
|
||||
loadingText="Deployment in progress..."
|
||||
>
|
||||
<i className="fa fa-plus space-right" aria-hidden="true" />
|
||||
Deploy the container
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
|
||||
async function onSubmit(values: ContainerInstanceFormValues) {
|
||||
try {
|
||||
await mutateAsync(values);
|
||||
notifications.success('Container successfully created', values.name);
|
||||
router.stateService.go('azure.containerinstances');
|
||||
} catch (e) {
|
||||
notifications.error('Failure', e as Error, 'Unable to create container');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { object, string, number, boolean } from 'yup';
|
||||
|
||||
import { validationSchema as accessControlSchema } from '@/portainer/components/accessControlForm/AccessControlForm.validation';
|
||||
|
||||
import { validationSchema as portsSchema } from './PortsMappingField.validation';
|
||||
|
||||
export function validationSchema(isAdmin: boolean) {
|
||||
return object().shape({
|
||||
name: string().required('Name is required.'),
|
||||
image: string().required('Image is required.'),
|
||||
subscription: string().required('Subscription is required.'),
|
||||
resourceGroup: string().required('Resource group is required.'),
|
||||
location: string().required('Location is required.'),
|
||||
os: string().oneOf(['Linux', 'Windows']),
|
||||
cpu: number().positive(),
|
||||
memory: number().positive(),
|
||||
allocatePublicIP: boolean(),
|
||||
ports: portsSchema(),
|
||||
accessControl: accessControlSchema(isAdmin),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item .inputs {
|
||||
}
|
||||
|
||||
.item .errors {
|
||||
position: absolute;
|
||||
bottom: -20px;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ButtonSelector } from '@/portainer/components/form-components/ButtonSelector/ButtonSelector';
|
||||
import { FormError } from '@/portainer/components/form-components/FormError';
|
||||
import { InputGroup } from '@/portainer/components/form-components/InputGroup';
|
||||
import { InputList } from '@/portainer/components/form-components/InputList';
|
||||
import {
|
||||
InputListError,
|
||||
ItemProps,
|
||||
} from '@/portainer/components/form-components/InputList/InputList';
|
||||
|
||||
import styles from './PortsMappingField.module.css';
|
||||
|
||||
type Protocol = 'TCP' | 'UDP';
|
||||
|
||||
export interface PortMapping {
|
||||
host: string;
|
||||
container: string;
|
||||
protocol: Protocol;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: PortMapping[];
|
||||
onChange(value: PortMapping[]): void;
|
||||
errors?: InputListError<PortMapping>[] | string;
|
||||
}
|
||||
|
||||
export function PortsMappingField({ value, onChange, errors }: Props) {
|
||||
return (
|
||||
<>
|
||||
<InputList<PortMapping>
|
||||
label="Port mapping"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
addLabel="map additional port"
|
||||
itemBuilder={() => ({ host: '', container: '', protocol: 'TCP' })}
|
||||
item={Item}
|
||||
errors={errors}
|
||||
/>
|
||||
{typeof errors === 'string' && (
|
||||
<div className="form-group col-md-12">
|
||||
<FormError>{errors}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({ onChange, item, error }: ItemProps<PortMapping>) {
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
<div className={styles.inputs}>
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>host</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
value={item.host}
|
||||
onChange={(e) => handleChange('host', e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<span style={{ margin: '0 10px 0 10px' }}>
|
||||
<i className="fa fa-long-arrow-alt-right" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
<InputGroup size="small">
|
||||
<InputGroup.Addon>container</InputGroup.Addon>
|
||||
<InputGroup.Input
|
||||
placeholder="e.g. 80"
|
||||
value={item.container}
|
||||
onChange={(e) => handleChange('container', e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<ButtonSelector<Protocol>
|
||||
onChange={(value) => handleChange('protocol', value)}
|
||||
value={item.protocol}
|
||||
options={[{ value: 'TCP' }, { value: 'UDP' }]}
|
||||
/>
|
||||
</div>
|
||||
{!!error && (
|
||||
<div className={styles.errors}>
|
||||
<FormError>{Object.values(error)[0]}</FormError>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
function handleChange(name: string, value: string) {
|
||||
onChange({ ...item, [name]: value });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { array, object, string } from 'yup';
|
||||
|
||||
export function validationSchema() {
|
||||
return array(
|
||||
object().shape({
|
||||
host: string().required('host is required'),
|
||||
container: string().required('container is required'),
|
||||
protocol: string().oneOf(['TCP', 'UDP']),
|
||||
})
|
||||
).min(1, 'At least one port binding is required');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
|
||||
import { createContainerGroup } from '@/azure/services/container-groups.service';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import {
|
||||
ContainerGroup,
|
||||
ContainerInstanceFormValues,
|
||||
ResourceGroup,
|
||||
} from '@/azure/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { applyResourceControl } from '@/portainer/resource-control/resource-control.service';
|
||||
|
||||
import { getSubscriptionResourceGroups } from './utils';
|
||||
|
||||
export function useCreateInstance(
|
||||
resourceGroups: {
|
||||
[k: string]: ResourceGroup[];
|
||||
},
|
||||
environmentId: EnvironmentId,
|
||||
userId?: UserId
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<ContainerGroup, unknown, ContainerInstanceFormValues>(
|
||||
(values) => {
|
||||
if (!values.subscription) {
|
||||
throw new PortainerError('subscription is required');
|
||||
}
|
||||
|
||||
const subscriptionResourceGroup = getSubscriptionResourceGroups(
|
||||
values.subscription,
|
||||
resourceGroups
|
||||
);
|
||||
const resourceGroup = subscriptionResourceGroup.find(
|
||||
(r) => r.value === values.resourceGroup
|
||||
);
|
||||
if (!resourceGroup) {
|
||||
throw new PortainerError('resource group not found');
|
||||
}
|
||||
|
||||
return createContainerGroup(
|
||||
values,
|
||||
environmentId,
|
||||
values.subscription,
|
||||
resourceGroup.label
|
||||
);
|
||||
},
|
||||
{
|
||||
async onSuccess(containerGroup, values) {
|
||||
if (!userId) {
|
||||
throw new Error('missing user id');
|
||||
}
|
||||
|
||||
const resourceControl = containerGroup.Portainer.ResourceControl;
|
||||
const accessControlData = values.accessControl;
|
||||
await applyResourceControl(userId, accessControlData, resourceControl);
|
||||
queryClient.invalidateQueries(['azure', 'container-instances']);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useQueries, useQuery } from 'react-query';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import { Option } from '@/portainer/components/form-components/Input/Select';
|
||||
import { getResourceGroups } from '@/azure/services/resource-groups.service';
|
||||
import { getSubscriptions } from '@/azure/services/subscription.service';
|
||||
import { getContainerInstanceProvider } from '@/azure/services/provider.service';
|
||||
import { ContainerInstanceFormValues, Subscription } from '@/azure/types';
|
||||
import { parseFromResourceControl } from '@/portainer/components/accessControlForm/model';
|
||||
|
||||
import {
|
||||
getSubscriptionLocations,
|
||||
getSubscriptionResourceGroups,
|
||||
} from './utils';
|
||||
|
||||
export function useLoadFormState(
|
||||
environmentId: EnvironmentId,
|
||||
isUserAdmin: boolean
|
||||
) {
|
||||
const { subscriptions, isLoading: isLoadingSubscriptions } =
|
||||
useSubscriptions(environmentId);
|
||||
const { resourceGroups, isLoading: isLoadingResourceGroups } =
|
||||
useResourceGroups(environmentId, subscriptions);
|
||||
const { providers, isLoading: isLoadingProviders } = useProviders(
|
||||
environmentId,
|
||||
subscriptions
|
||||
);
|
||||
|
||||
const subscriptionOptions =
|
||||
subscriptions?.map((s) => ({
|
||||
value: s.subscriptionId,
|
||||
label: s.displayName,
|
||||
})) || [];
|
||||
|
||||
const initSubscriptionId = getFirstValue(subscriptionOptions);
|
||||
|
||||
const subscriptionResourceGroups = getSubscriptionResourceGroups(
|
||||
initSubscriptionId,
|
||||
resourceGroups
|
||||
);
|
||||
|
||||
const subscriptionLocations = getSubscriptionLocations(
|
||||
initSubscriptionId,
|
||||
providers
|
||||
);
|
||||
|
||||
const initialValues: ContainerInstanceFormValues = {
|
||||
name: '',
|
||||
location: getFirstValue(subscriptionLocations),
|
||||
subscription: initSubscriptionId,
|
||||
resourceGroup: getFirstValue(subscriptionResourceGroups),
|
||||
image: '',
|
||||
os: 'Linux',
|
||||
memory: 1,
|
||||
cpu: 1,
|
||||
ports: [{ container: '80', host: '80', protocol: 'TCP' }],
|
||||
allocatePublicIP: true,
|
||||
accessControl: parseFromResourceControl(isUserAdmin),
|
||||
};
|
||||
|
||||
return {
|
||||
isUserAdmin,
|
||||
initialValues,
|
||||
subscriptions: subscriptionOptions,
|
||||
resourceGroups,
|
||||
providers,
|
||||
isLoading:
|
||||
isLoadingProviders || isLoadingResourceGroups || isLoadingSubscriptions,
|
||||
};
|
||||
|
||||
function getFirstValue<T extends string | number>(arr: Option<T>[]) {
|
||||
if (arr.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return arr[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
function useSubscriptions(environmentId: EnvironmentId) {
|
||||
const { data, isError, error, isLoading } = useQuery(
|
||||
'azure.subscriptions',
|
||||
() => getSubscriptions(environmentId)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
error as PortainerError,
|
||||
'Unable to retrieve Azure resources'
|
||||
);
|
||||
}
|
||||
}, [isError, error]);
|
||||
|
||||
return { subscriptions: data || [], isLoading };
|
||||
}
|
||||
|
||||
function useResourceGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptions: Subscription[]
|
||||
) {
|
||||
const queries = useQueries(
|
||||
subscriptions.map((subscription) => ({
|
||||
queryKey: ['azure.resourceGroups', subscription.subscriptionId],
|
||||
queryFn: () =>
|
||||
getResourceGroups(environmentId, subscription.subscriptionId),
|
||||
}))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const failedQuery = queries.find((q) => q.error);
|
||||
if (failedQuery) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
failedQuery.error as PortainerError,
|
||||
'Unable to retrieve Azure resources'
|
||||
);
|
||||
}
|
||||
}, [queries]);
|
||||
|
||||
return {
|
||||
resourceGroups: Object.fromEntries(
|
||||
queries.map((q, index) => [
|
||||
subscriptions[index].subscriptionId,
|
||||
q.data || [],
|
||||
])
|
||||
),
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
};
|
||||
}
|
||||
|
||||
function useProviders(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptions: Subscription[]
|
||||
) {
|
||||
const queries = useQueries(
|
||||
subscriptions.map((subscription) => ({
|
||||
queryKey: [
|
||||
'azure.containerInstanceProvider',
|
||||
subscription.subscriptionId,
|
||||
],
|
||||
queryFn: () =>
|
||||
getContainerInstanceProvider(
|
||||
environmentId,
|
||||
subscription.subscriptionId
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const failedQuery = queries.find((q) => q.error);
|
||||
if (failedQuery) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
failedQuery.error as PortainerError,
|
||||
'Unable to retrieve Azure resources'
|
||||
);
|
||||
}
|
||||
}, [queries]);
|
||||
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
queries.map((q, index) => [subscriptions[index].subscriptionId, q.data])
|
||||
),
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ProviderViewModel } from '@/azure/models/provider';
|
||||
import { ResourceGroup } from '@/azure/types';
|
||||
|
||||
export function getSubscriptionResourceGroups(
|
||||
subscriptionId?: string,
|
||||
resourceGroups?: Record<string, ResourceGroup[]>
|
||||
) {
|
||||
if (!subscriptionId || !resourceGroups || !resourceGroups[subscriptionId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return resourceGroups[subscriptionId].map(({ name, id }) => ({
|
||||
value: id,
|
||||
label: name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSubscriptionLocations(
|
||||
subscriptionId?: string,
|
||||
containerInstanceProviders?: Record<string, ProviderViewModel | undefined>
|
||||
) {
|
||||
if (!subscriptionId || !containerInstanceProviders) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const provider = containerInstanceProviders[subscriptionId];
|
||||
if (!provider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return provider.locations.map((location) => ({
|
||||
value: location,
|
||||
label: location,
|
||||
}));
|
||||
}
|
||||
34
app/azure/ContainerInstances/CreateContainerInstanceView.tsx
Normal file
34
app/azure/ContainerInstances/CreateContainerInstanceView.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { PageHeader } from '@/portainer/components/PageHeader';
|
||||
import { Widget, WidgetBody } from '@/portainer/components/widget';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
|
||||
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
|
||||
|
||||
export function CreateContainerInstanceView() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create container instance"
|
||||
breadcrumbs={[
|
||||
{ link: 'azure.containerinstances', label: 'Container instances' },
|
||||
{ label: 'Add container' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<CreateContainerInstanceForm />
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const CreateContainerInstanceViewAngular = r2a(
|
||||
CreateContainerInstanceView,
|
||||
[]
|
||||
);
|
||||
11
app/azure/ContainerInstances/index.ts
Normal file
11
app/azure/ContainerInstances/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { CreateContainerInstanceViewAngular } from './CreateContainerInstanceView';
|
||||
|
||||
export const containerInstancesModule = angular
|
||||
.module('portainer.azure.containerInstances', [])
|
||||
|
||||
.component(
|
||||
'createContainerInstanceView',
|
||||
CreateContainerInstanceViewAngular
|
||||
).name;
|
||||
144
app/azure/Dashboard/DashboardView.test.tsx
Normal file
144
app/azure/Dashboard/DashboardView.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { renderWithQueryClient, within } from '@/react-tools/test-utils';
|
||||
import { UserContext } from '@/portainer/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { server, rest } from '@/setup-tests/server';
|
||||
import {
|
||||
createMockResourceGroups,
|
||||
createMockSubscriptions,
|
||||
} from '@/react-tools/test-mocks';
|
||||
|
||||
import { DashboardView } from './DashboardView';
|
||||
|
||||
jest.mock('@uirouter/react', () => ({
|
||||
...jest.requireActual('@uirouter/react'),
|
||||
useCurrentStateAndParams: jest.fn(() => ({
|
||||
params: { endpointId: 1 },
|
||||
})),
|
||||
}));
|
||||
|
||||
test('dashboard items should render correctly', async () => {
|
||||
const { getByLabelText } = await renderComponent();
|
||||
|
||||
const subscriptionsItem = getByLabelText('Subscriptions');
|
||||
expect(subscriptionsItem).toBeVisible();
|
||||
|
||||
const subscriptionElements = within(subscriptionsItem);
|
||||
expect(subscriptionElements.getByLabelText('value')).toBeVisible();
|
||||
expect(subscriptionElements.getByLabelText('icon')).toHaveClass('fa-th-list');
|
||||
expect(subscriptionElements.getByLabelText('resourceType')).toHaveTextContent(
|
||||
'Subscriptions'
|
||||
);
|
||||
|
||||
const resourceGroupsItem = getByLabelText('Resource groups');
|
||||
expect(resourceGroupsItem).toBeVisible();
|
||||
|
||||
const resourceGroupElements = within(resourceGroupsItem);
|
||||
expect(resourceGroupElements.getByLabelText('value')).toBeVisible();
|
||||
expect(resourceGroupElements.getByLabelText('icon')).toHaveClass(
|
||||
'fa-th-list'
|
||||
);
|
||||
expect(
|
||||
resourceGroupElements.getByLabelText('resourceType')
|
||||
).toHaveTextContent('Resource groups');
|
||||
});
|
||||
|
||||
test('when there are no subscriptions, should show 0 subscriptions and 0 resource groups', async () => {
|
||||
const { getByLabelText } = await renderComponent();
|
||||
|
||||
const subscriptionElements = within(getByLabelText('Subscriptions'));
|
||||
expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('0');
|
||||
|
||||
const resourceGroupElements = within(getByLabelText('Resource groups'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
test('when there is subscription & resource group data, should display these', async () => {
|
||||
const { getByLabelText } = await renderComponent(1, { 'subscription-1': 2 });
|
||||
|
||||
const subscriptionElements = within(getByLabelText('Subscriptions'));
|
||||
expect(subscriptionElements.getByLabelText('value')).toHaveTextContent('1');
|
||||
|
||||
const resourceGroupElements = within(getByLabelText('Resource groups'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
test('should correctly show total number of resource groups across multiple subscriptions', async () => {
|
||||
const { getByLabelText } = await renderComponent(2, {
|
||||
'subscription-1': 2,
|
||||
'subscription-2': 3,
|
||||
});
|
||||
|
||||
const resourceGroupElements = within(getByLabelText('Resource groups'));
|
||||
expect(resourceGroupElements.getByLabelText('value')).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
test('when only subscriptions fail to load, dont show the dashboard', async () => {
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
1,
|
||||
{ 'subscription-1': 1 },
|
||||
500,
|
||||
200
|
||||
);
|
||||
expect(queryByLabelText('Subscriptions')).not.toBeInTheDocument();
|
||||
expect(queryByLabelText('Resource groups')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('when only resource groups fail to load, still show the subscriptions', async () => {
|
||||
const { queryByLabelText } = await renderComponent(
|
||||
1,
|
||||
{ 'subscription-1': 1 },
|
||||
200,
|
||||
500
|
||||
);
|
||||
expect(queryByLabelText('Subscriptions')).toBeInTheDocument();
|
||||
expect(queryByLabelText('Resource groups')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
async function renderComponent(
|
||||
subscriptionsCount = 0,
|
||||
resourceGroups: Record<string, number> = {},
|
||||
subscriptionsStatus = 200,
|
||||
resourceGroupsStatus = 200
|
||||
) {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
const state = { user };
|
||||
|
||||
server.use(
|
||||
rest.get(
|
||||
'/api/endpoints/:endpointId/azure/subscriptions',
|
||||
(req, res, ctx) =>
|
||||
res(
|
||||
ctx.json(createMockSubscriptions(subscriptionsCount)),
|
||||
ctx.status(subscriptionsStatus)
|
||||
)
|
||||
),
|
||||
rest.get(
|
||||
'/api/endpoints/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups',
|
||||
(req, res, ctx) => {
|
||||
if (typeof req.params.subscriptionId !== 'string') {
|
||||
throw new Error("Provided subscriptionId must be of type: 'string'");
|
||||
}
|
||||
|
||||
const { subscriptionId } = req.params;
|
||||
return res(
|
||||
ctx.json(
|
||||
createMockResourceGroups(
|
||||
req.params.subscriptionId,
|
||||
resourceGroups[subscriptionId] || 0
|
||||
)
|
||||
),
|
||||
ctx.status(resourceGroupsStatus)
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
const renderResult = renderWithQueryClient(
|
||||
<UserContext.Provider value={state}>
|
||||
<DashboardView />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
|
||||
await expect(renderResult.findByText(/Home/)).resolves.toBeVisible();
|
||||
|
||||
return renderResult;
|
||||
}
|
||||
75
app/azure/Dashboard/DashboardView.tsx
Normal file
75
app/azure/Dashboard/DashboardView.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/hooks/useEnvironmentId';
|
||||
import { PageHeader } from '@/portainer/components/PageHeader';
|
||||
import { DashboardItem } from '@/portainer/components/Dashboard/DashboardItem';
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
|
||||
import { useResourceGroups, useSubscriptions } from '../queries';
|
||||
|
||||
export function DashboardView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const subscriptionsQuery = useSubscriptions(environmentId);
|
||||
useEffect(() => {
|
||||
if (subscriptionsQuery.isError) {
|
||||
notifyError(
|
||||
'Failure',
|
||||
subscriptionsQuery.error as PortainerError,
|
||||
'Unable to retrieve subscriptions'
|
||||
);
|
||||
}
|
||||
}, [subscriptionsQuery.error, subscriptionsQuery.isError]);
|
||||
|
||||
const resourceGroupsQuery = useResourceGroups(
|
||||
environmentId,
|
||||
subscriptionsQuery.data
|
||||
);
|
||||
useEffect(() => {
|
||||
if (resourceGroupsQuery.isError && resourceGroupsQuery.error) {
|
||||
notifyError(
|
||||
'Failure',
|
||||
resourceGroupsQuery.error as PortainerError,
|
||||
`Unable to retrieve resource groups`
|
||||
);
|
||||
}
|
||||
}, [resourceGroupsQuery.error, resourceGroupsQuery.isError]);
|
||||
|
||||
const isLoading =
|
||||
subscriptionsQuery.isLoading || resourceGroupsQuery.isLoading;
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionsCount = subscriptionsQuery?.data?.length;
|
||||
const resourceGroupsCount = Object.values(
|
||||
resourceGroupsQuery?.resourceGroups
|
||||
).flatMap((x) => Object.values(x)).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Home" breadcrumbs={[{ label: 'Dashboard' }]} />
|
||||
|
||||
{!subscriptionsQuery.isError && (
|
||||
<div className="row">
|
||||
<DashboardItem
|
||||
value={subscriptionsCount as number}
|
||||
icon="fa fa-th-list"
|
||||
type="Subscriptions"
|
||||
/>
|
||||
{!resourceGroupsQuery.isError && (
|
||||
<DashboardItem
|
||||
value={resourceGroupsCount as number}
|
||||
icon="fa fa-th-list"
|
||||
type="Resource groups"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const DashboardViewAngular = r2a(DashboardView, []);
|
||||
1
app/azure/Dashboard/index.ts
Normal file
1
app/azure/Dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { DashboardViewAngular, DashboardView } from './DashboardView';
|
||||
@@ -1,79 +1,85 @@
|
||||
angular.module('portainer.azure', ['portainer.app']).config([
|
||||
'$stateRegistryProvider',
|
||||
function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
import angular from 'angular';
|
||||
|
||||
var azure = {
|
||||
name: 'azure',
|
||||
url: '/azure',
|
||||
parent: 'endpoint',
|
||||
abstract: true,
|
||||
onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, EndpointProvider, Notifications, StateManager) {
|
||||
return $async(async () => {
|
||||
if (endpoint.Type !== 3) {
|
||||
$state.go('portainer.home');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
EndpointProvider.setEndpointID(endpoint.Id);
|
||||
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
|
||||
EndpointProvider.setOfflineModeFromStatus(endpoint.Status);
|
||||
await StateManager.updateEndpointState(endpoint, []);
|
||||
} catch (e) {
|
||||
Notifications.error('Failed loading environment', e);
|
||||
$state.go('portainer.home', {}, { reload: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
import { DashboardViewAngular } from './Dashboard/DashboardView';
|
||||
import { containerInstancesModule } from './ContainerInstances';
|
||||
|
||||
var containerInstances = {
|
||||
name: 'azure.containerinstances',
|
||||
url: '/containerinstances',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/containerinstances/containerinstances.html',
|
||||
controller: 'AzureContainerInstancesController',
|
||||
angular
|
||||
.module('portainer.azure', ['portainer.app', containerInstancesModule])
|
||||
.config([
|
||||
'$stateRegistryProvider',
|
||||
function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
|
||||
var azure = {
|
||||
name: 'azure',
|
||||
url: '/azure',
|
||||
parent: 'endpoint',
|
||||
abstract: true,
|
||||
onEnter: /* @ngInject */ function onEnter($async, $state, endpoint, EndpointProvider, Notifications, StateManager) {
|
||||
return $async(async () => {
|
||||
if (endpoint.Type !== 3) {
|
||||
$state.go('portainer.home');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
EndpointProvider.setEndpointID(endpoint.Id);
|
||||
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
|
||||
EndpointProvider.setOfflineModeFromStatus(endpoint.Status);
|
||||
await StateManager.updateEndpointState(endpoint, []);
|
||||
} catch (e) {
|
||||
Notifications.error('Failed loading environment', e);
|
||||
$state.go('portainer.home', {}, { reload: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
var containerInstance = {
|
||||
name: 'azure.containerinstances.container',
|
||||
url: '/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'containerInstanceDetails',
|
||||
var containerInstances = {
|
||||
name: 'azure.containerinstances',
|
||||
url: '/containerinstances',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/containerinstances/containerinstances.html',
|
||||
controller: 'AzureContainerInstancesController',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
var containerInstanceCreation = {
|
||||
name: 'azure.containerinstances.new',
|
||||
url: '/new/',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/containerinstances/create/createcontainerinstance.html',
|
||||
controller: 'AzureCreateContainerInstanceController',
|
||||
var containerInstance = {
|
||||
name: 'azure.containerinstances.container',
|
||||
url: '/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'containerInstanceDetails',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
var dashboard = {
|
||||
name: 'azure.dashboard',
|
||||
url: '/dashboard',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: './views/dashboard/dashboard.html',
|
||||
controller: 'AzureDashboardController',
|
||||
var containerInstanceCreation = {
|
||||
name: 'azure.containerinstances.new',
|
||||
url: '/new/',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createContainerInstanceView',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(azure);
|
||||
$stateRegistryProvider.register(containerInstances);
|
||||
$stateRegistryProvider.register(containerInstance);
|
||||
$stateRegistryProvider.register(containerInstanceCreation);
|
||||
$stateRegistryProvider.register(dashboard);
|
||||
},
|
||||
]);
|
||||
var dashboard = {
|
||||
name: 'azure.dashboard',
|
||||
url: '/dashboard',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'dashboardView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(azure);
|
||||
$stateRegistryProvider.register(containerInstances);
|
||||
$stateRegistryProvider.register(containerInstance);
|
||||
$stateRegistryProvider.register(containerInstanceCreation);
|
||||
$stateRegistryProvider.register(dashboard);
|
||||
},
|
||||
])
|
||||
.component('dashboardView', DashboardViewAngular).name;
|
||||
|
||||
@@ -48,48 +48,3 @@ export function ContainerGroupViewModel(data) {
|
||||
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
|
||||
}
|
||||
}
|
||||
|
||||
export function CreateContainerGroupRequest(model) {
|
||||
this.location = model.Location;
|
||||
|
||||
var containerPorts = [];
|
||||
var addressPorts = [];
|
||||
for (var i = 0; i < model.Ports.length; i++) {
|
||||
var binding = model.Ports[i];
|
||||
if (!binding.container || !binding.host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
containerPorts.push({
|
||||
port: binding.container,
|
||||
});
|
||||
|
||||
addressPorts.push({
|
||||
port: binding.host,
|
||||
protocol: binding.protocol,
|
||||
});
|
||||
}
|
||||
|
||||
this.properties = {
|
||||
osType: model.OSType,
|
||||
containers: [
|
||||
{
|
||||
name: model.Name,
|
||||
properties: {
|
||||
image: model.Image,
|
||||
ports: containerPorts,
|
||||
resources: {
|
||||
requests: {
|
||||
cpu: model.CPU,
|
||||
memoryInGB: model.Memory,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
ipAddress: {
|
||||
type: model.AllocatePublicIP ? 'Public' : 'Private',
|
||||
ports: addressPorts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
export function ContainerInstanceProviderViewModel(data) {
|
||||
this.Id = data.id;
|
||||
this.Namespace = data.namespace;
|
||||
|
||||
var containerGroupType = _.find(data.resourceTypes, { resourceType: 'containerGroups' });
|
||||
this.Locations = containerGroupType.locations;
|
||||
}
|
||||
21
app/azure/models/provider.ts
Normal file
21
app/azure/models/provider.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import _ from 'lodash-es';
|
||||
|
||||
import { ProviderResponse } from '../types';
|
||||
|
||||
export interface ProviderViewModel {
|
||||
id: string;
|
||||
namespace: string;
|
||||
locations: string[];
|
||||
}
|
||||
|
||||
export function parseViewModel({
|
||||
id,
|
||||
namespace,
|
||||
resourceTypes,
|
||||
}: ProviderResponse): ProviderViewModel {
|
||||
const containerGroupType = _.find(resourceTypes, {
|
||||
resourceType: 'containerGroups',
|
||||
});
|
||||
const { locations = [] } = containerGroupType || {};
|
||||
return { id, namespace, locations };
|
||||
}
|
||||
70
app/azure/queries.ts
Normal file
70
app/azure/queries.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import _ from 'lodash';
|
||||
import { useQueries, useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
|
||||
import { getResourceGroups } from './services/resource-groups.service';
|
||||
import { getSubscriptions } from './services/subscription.service';
|
||||
import { Subscription } from './types';
|
||||
|
||||
export function useSubscriptions(environmentId: EnvironmentId) {
|
||||
return useQuery(
|
||||
'azure.subscriptions',
|
||||
() => getSubscriptions(environmentId),
|
||||
{
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Unable to retrieve Azure subscriptions',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useResourceGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptions: Subscription[] = []
|
||||
) {
|
||||
const queries = useQueries(
|
||||
subscriptions.map((subscription) => ({
|
||||
queryKey: [
|
||||
'azure',
|
||||
environmentId,
|
||||
'subscriptions',
|
||||
subscription.subscriptionId,
|
||||
'resourceGroups',
|
||||
],
|
||||
queryFn: async () => {
|
||||
const groups = await getResourceGroups(
|
||||
environmentId,
|
||||
subscription.subscriptionId
|
||||
);
|
||||
return [subscription.subscriptionId, groups] as const;
|
||||
},
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Unable to retrieve Azure resource groups',
|
||||
},
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
return {
|
||||
resourceGroups: Object.fromEntries(
|
||||
_.compact(
|
||||
queries.map((q) => {
|
||||
if (q.data) {
|
||||
return q.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
)
|
||||
),
|
||||
isLoading: queries.some((q) => q.isLoading),
|
||||
isError: queries.some((q) => q.isError),
|
||||
error: queries.find((q) => q.error)?.error || null,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,6 @@ angular.module('portainer.azure').factory('Subscription', [
|
||||
'api-version': '2016-06-01',
|
||||
},
|
||||
{
|
||||
query: { method: 'GET' },
|
||||
get: { method: 'GET', params: { id: '@id' } },
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,72 +1,75 @@
|
||||
angular.module('portainer.azure').factory('AzureService', [
|
||||
'$q',
|
||||
'Azure',
|
||||
'SubscriptionService',
|
||||
'ResourceGroupService',
|
||||
'ContainerGroupService',
|
||||
'ProviderService',
|
||||
function AzureServiceFactory($q, Azure, SubscriptionService, ResourceGroupService, ContainerGroupService, ProviderService) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
import { ResourceGroupViewModel } from '../models/resource_group';
|
||||
import { SubscriptionViewModel } from '../models/subscription';
|
||||
import { getResourceGroups } from './resource-groups.service';
|
||||
import { getSubscriptions } from './subscription.service';
|
||||
|
||||
service.deleteContainerGroup = function (id) {
|
||||
return Azure.delete(id, '2018-04-01');
|
||||
};
|
||||
angular.module('portainer.azure').factory('AzureService', AzureService);
|
||||
|
||||
service.createContainerGroup = function (model, subscriptionId, resourceGroupName) {
|
||||
return ContainerGroupService.create(model, subscriptionId, resourceGroupName);
|
||||
};
|
||||
/* @ngInject */
|
||||
export function AzureService($q, Azure, $async, EndpointProvider, ContainerGroupService) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.subscriptions = function () {
|
||||
return SubscriptionService.subscriptions();
|
||||
};
|
||||
service.deleteContainerGroup = function (id) {
|
||||
return Azure.delete(id, '2018-04-01');
|
||||
};
|
||||
|
||||
service.containerInstanceProvider = function (subscriptions) {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, ProviderService.containerInstanceProvider);
|
||||
};
|
||||
service.subscriptions = async function subscriptions() {
|
||||
return $async(async () => {
|
||||
const environmentId = EndpointProvider.endpointID();
|
||||
const subscriptions = await getSubscriptions(environmentId);
|
||||
return subscriptions.map((s) => new SubscriptionViewModel(s));
|
||||
});
|
||||
};
|
||||
|
||||
service.resourceGroups = function (subscriptions) {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, ResourceGroupService.resourceGroups);
|
||||
};
|
||||
service.resourceGroups = function resourceGroups(subscriptions) {
|
||||
return $async(async () => {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, async (subscriptionId) => {
|
||||
const environmentId = EndpointProvider.endpointID();
|
||||
|
||||
service.containerGroups = function (subscriptions) {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups);
|
||||
};
|
||||
|
||||
service.aggregate = function (resourcesBySubcription) {
|
||||
var aggregatedResources = [];
|
||||
Object.keys(resourcesBySubcription).forEach(function (key) {
|
||||
aggregatedResources = aggregatedResources.concat(resourcesBySubcription[key]);
|
||||
const resourceGroups = await getResourceGroups(environmentId, subscriptionId);
|
||||
return resourceGroups.map((r) => new ResourceGroupViewModel(r, subscriptionId));
|
||||
});
|
||||
return aggregatedResources;
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) {
|
||||
var deferred = $q.defer();
|
||||
service.containerGroups = function (subscriptions) {
|
||||
return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups);
|
||||
};
|
||||
|
||||
var resources = {};
|
||||
service.aggregate = function (resourcesBySubscription) {
|
||||
var aggregatedResources = [];
|
||||
Object.keys(resourcesBySubscription).forEach(function (key) {
|
||||
aggregatedResources = aggregatedResources.concat(resourcesBySubscription[key]);
|
||||
});
|
||||
return aggregatedResources;
|
||||
};
|
||||
|
||||
var resourceQueries = [];
|
||||
for (var i = 0; i < subscriptions.length; i++) {
|
||||
var subscription = subscriptions[i];
|
||||
resourceQueries.push(resourceQuery(subscription.Id));
|
||||
}
|
||||
function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
$q.all(resourceQueries)
|
||||
.then(function success(data) {
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var result = data[i];
|
||||
resources[subscriptions[i].Id] = result;
|
||||
}
|
||||
deferred.resolve(resources);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve resources', err: err });
|
||||
});
|
||||
var resources = {};
|
||||
|
||||
return deferred.promise;
|
||||
var resourceQueries = [];
|
||||
for (var i = 0; i < subscriptions.length; i++) {
|
||||
var subscription = subscriptions[i];
|
||||
resourceQueries.push(resourceQuery(subscription.Id));
|
||||
}
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
||||
$q.all(resourceQueries)
|
||||
.then(function success(data) {
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
var result = data[i];
|
||||
resources[subscriptions[i].Id] = result;
|
||||
}
|
||||
deferred.resolve(resources);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve resources', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
78
app/azure/services/container-groups.service.ts
Normal file
78
app/azure/services/container-groups.service.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { ContainerGroup, ContainerInstanceFormValues } from '../types';
|
||||
|
||||
export async function createContainerGroup(
|
||||
model: ContainerInstanceFormValues,
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string
|
||||
) {
|
||||
const payload = transformToPayload(model);
|
||||
try {
|
||||
const { data } = await axios.put<ContainerGroup>(
|
||||
buildUrl(environmentId, subscriptionId, resourceGroupName, model.name),
|
||||
payload,
|
||||
{ params: { 'api-version': '2018-04-01' } }
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName: string,
|
||||
containerGroupName: string
|
||||
) {
|
||||
return `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.ContainerInstance/containerGroups/${containerGroupName}`;
|
||||
}
|
||||
|
||||
function transformToPayload(model: ContainerInstanceFormValues) {
|
||||
const containerPorts = [];
|
||||
const addressPorts = [];
|
||||
|
||||
const ports = model.ports.filter((p) => p.container && p.host);
|
||||
|
||||
for (let i = 0; i < ports.length; i += 1) {
|
||||
const binding = ports[i];
|
||||
|
||||
containerPorts.push({
|
||||
port: binding.container,
|
||||
});
|
||||
|
||||
addressPorts.push({
|
||||
port: binding.host,
|
||||
protocol: binding.protocol,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
location: model.location,
|
||||
properties: {
|
||||
osType: model.os,
|
||||
containers: [
|
||||
{
|
||||
name: model.name,
|
||||
properties: {
|
||||
image: model.image,
|
||||
ports: containerPorts,
|
||||
resources: {
|
||||
requests: {
|
||||
cpu: model.cpu,
|
||||
memoryInGB: model.memory,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
ipAddress: {
|
||||
type: model.allocatePublicIP ? 'Public' : 'Private',
|
||||
ports: addressPorts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ContainerGroupViewModel, CreateContainerGroupRequest } from '../models/container_group';
|
||||
import { ContainerGroupViewModel } from '../models/container_group';
|
||||
|
||||
angular.module('portainer.azure').factory('ContainerGroupService', [
|
||||
'$q',
|
||||
@@ -30,18 +30,6 @@ angular.module('portainer.azure').factory('ContainerGroupService', [
|
||||
return new ContainerGroupViewModel(containerGroup);
|
||||
}
|
||||
|
||||
service.create = function (model, subscriptionId, resourceGroupName) {
|
||||
var payload = new CreateContainerGroupRequest(model);
|
||||
return ContainerGroup.create(
|
||||
{
|
||||
subscriptionId: subscriptionId,
|
||||
resourceGroupName: resourceGroupName,
|
||||
containerGroupName: model.Name,
|
||||
},
|
||||
payload
|
||||
).$promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
||||
|
||||
29
app/azure/services/provider.service.ts
Normal file
29
app/azure/services/provider.service.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// import { ContainerInstanceProviderViewModel } from '../models/provider';
|
||||
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { parseViewModel } from '../models/provider';
|
||||
import { ProviderResponse } from '../types';
|
||||
|
||||
import { azureErrorParser } from './utils';
|
||||
|
||||
export async function getContainerInstanceProvider(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string
|
||||
) {
|
||||
try {
|
||||
const url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/providers/Microsoft.ContainerInstance`;
|
||||
const { data } = await axios.get<ProviderResponse>(url, {
|
||||
params: { 'api-version': '2018-02-01' },
|
||||
});
|
||||
|
||||
return parseViewModel(data);
|
||||
} catch (error) {
|
||||
throw parseAxiosError(
|
||||
error as Error,
|
||||
'Unable to retrieve provider',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ContainerInstanceProviderViewModel } from '../models/provider';
|
||||
|
||||
angular.module('portainer.azure').factory('ProviderService', [
|
||||
'$q',
|
||||
'Provider',
|
||||
function ProviderServiceFactory($q, Provider) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.containerInstanceProvider = function (subscriptionId) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
Provider.get({ subscriptionId: subscriptionId, providerNamespace: 'Microsoft.ContainerInstance' })
|
||||
.$promise.then(function success(data) {
|
||||
var provider = new ContainerInstanceProviderViewModel(data);
|
||||
deferred.resolve(provider);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve provider', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
||||
42
app/azure/services/resource-groups.service.ts
Normal file
42
app/azure/services/resource-groups.service.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { ResourceGroup } from '../types';
|
||||
|
||||
import { azureErrorParser } from './utils';
|
||||
|
||||
export async function getResourceGroups(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string
|
||||
) {
|
||||
try {
|
||||
const {
|
||||
data: { value },
|
||||
} = await axios.get<{ value: ResourceGroup[] }>(
|
||||
buildUrl(environmentId, subscriptionId),
|
||||
{ params: { 'api-version': '2018-02-01' } }
|
||||
);
|
||||
|
||||
return value;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(
|
||||
err as Error,
|
||||
'Unable to retrieve resource groups',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
environmentId: EnvironmentId,
|
||||
subscriptionId: string,
|
||||
resourceGroupName?: string
|
||||
) {
|
||||
let url = `/endpoints/${environmentId}/azure/subscriptions/${subscriptionId}/resourcegroups`;
|
||||
|
||||
if (resourceGroupName) {
|
||||
url += `/${resourceGroupName}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -7,23 +7,6 @@ angular.module('portainer.azure').factory('ResourceGroupService', [
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.resourceGroups = function (subscriptionId) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
ResourceGroup.query({ subscriptionId: subscriptionId })
|
||||
.$promise.then(function success(data) {
|
||||
var resourceGroups = data.value.map(function (item) {
|
||||
return new ResourceGroupViewModel(item, subscriptionId);
|
||||
});
|
||||
deferred.resolve(resourceGroups);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve resource groups', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.resourceGroup = resourceGroup;
|
||||
async function resourceGroup(subscriptionId, resourceGroupName) {
|
||||
const group = await ResourceGroup.get({ subscriptionId, resourceGroupName }).$promise;
|
||||
|
||||
30
app/azure/services/subscription.service.ts
Normal file
30
app/azure/services/subscription.service.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { Subscription } from '../types';
|
||||
|
||||
import { azureErrorParser } from './utils';
|
||||
|
||||
export async function getSubscriptions(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data } = await axios.get<{ value: Subscription[] }>(
|
||||
buildUrl(environmentId)
|
||||
);
|
||||
return data.value;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve subscriptions',
|
||||
azureErrorParser
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, id?: string) {
|
||||
let url = `/endpoints/${environmentId}/azure/subscriptions?api-version=2016-06-01`;
|
||||
if (id) {
|
||||
url += `/${id}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -4,32 +4,11 @@ angular.module('portainer.azure').factory('SubscriptionService', [
|
||||
'$q',
|
||||
'Subscription',
|
||||
function SubscriptionServiceFactory($q, Subscription) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
return { subscription };
|
||||
|
||||
service.subscriptions = function () {
|
||||
var deferred = $q.defer();
|
||||
|
||||
Subscription.query({})
|
||||
.$promise.then(function success(data) {
|
||||
var subscriptions = data.value.map(function (item) {
|
||||
return new SubscriptionViewModel(item);
|
||||
});
|
||||
deferred.resolve(subscriptions);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({ msg: 'Unable to retrieve subscriptions', err: err });
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.subscription = subscription;
|
||||
async function subscription(id) {
|
||||
const subscription = await Subscription.get({ id }).$promise;
|
||||
return new SubscriptionViewModel(subscription);
|
||||
}
|
||||
|
||||
return service;
|
||||
},
|
||||
]);
|
||||
|
||||
12
app/azure/services/utils.ts
Normal file
12
app/azure/services/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export function azureErrorParser(axiosError: AxiosError) {
|
||||
const message =
|
||||
(axiosError.response?.data?.error?.message as string) ||
|
||||
'Failed azure request';
|
||||
|
||||
return {
|
||||
error: new Error(message),
|
||||
details: message,
|
||||
};
|
||||
}
|
||||
83
app/azure/types.ts
Normal file
83
app/azure/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/model';
|
||||
import { ResourceControlResponse } from '@/portainer/models/resourceControl/resourceControl';
|
||||
|
||||
import { PortMapping } from './ContainerInstances/CreateContainerInstanceForm/PortsMappingField';
|
||||
|
||||
type OS = 'Linux' | 'Windows';
|
||||
|
||||
export interface ContainerInstanceFormValues {
|
||||
name: string;
|
||||
location?: string;
|
||||
subscription?: string;
|
||||
resourceGroup?: string;
|
||||
image: string;
|
||||
os: OS;
|
||||
memory: number;
|
||||
cpu: number;
|
||||
ports: PortMapping[];
|
||||
allocatePublicIP: boolean;
|
||||
accessControl: AccessControlFormData;
|
||||
}
|
||||
|
||||
interface PortainerMetadata {
|
||||
ResourceControl: ResourceControlResponse;
|
||||
}
|
||||
|
||||
interface Container {
|
||||
name: string;
|
||||
properties: {
|
||||
environmentVariables: unknown[];
|
||||
image: string;
|
||||
ports: { port: number }[];
|
||||
resources: {
|
||||
cpu: number;
|
||||
memoryInGB: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ContainerGroupProperties {
|
||||
containers: Container[];
|
||||
instanceView: {
|
||||
events: unknown[];
|
||||
state: 'pending' | string;
|
||||
};
|
||||
ipAddress: {
|
||||
dnsNameLabelReusePolicy: string;
|
||||
ports: { port: number; protocol: 'TCP' | 'UDP' }[];
|
||||
type: 'Public' | 'Private';
|
||||
};
|
||||
osType: OS;
|
||||
}
|
||||
|
||||
export interface ContainerGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
type: string;
|
||||
properties: ContainerGroupProperties;
|
||||
Portainer: PortainerMetadata;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
subscriptionId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface ResourceGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
interface ResourceType {
|
||||
resourceType: 'containerGroups' | string;
|
||||
locations: string[];
|
||||
}
|
||||
|
||||
export interface ProviderResponse {
|
||||
id: string;
|
||||
namespace: string;
|
||||
resourceTypes: ResourceType[];
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { ContainerGroupDefaultModel } from '../../../models/container_group';
|
||||
|
||||
angular.module('portainer.azure').controller('AzureCreateContainerInstanceController', [
|
||||
'$q',
|
||||
'$scope',
|
||||
'$state',
|
||||
'AzureService',
|
||||
'Notifications',
|
||||
'Authentication',
|
||||
'ResourceControlService',
|
||||
'FormValidator',
|
||||
function ($q, $scope, $state, AzureService, Notifications, Authentication, ResourceControlService, FormValidator) {
|
||||
var allResourceGroups = [];
|
||||
var allProviders = [];
|
||||
|
||||
$scope.state = {
|
||||
actionInProgress: false,
|
||||
selectedSubscription: null,
|
||||
selectedResourceGroup: null,
|
||||
formValidationError: '',
|
||||
};
|
||||
|
||||
$scope.changeSubscription = function () {
|
||||
var selectedSubscription = $scope.state.selectedSubscription;
|
||||
updateResourceGroupsAndLocations(selectedSubscription, allResourceGroups, allProviders);
|
||||
};
|
||||
|
||||
$scope.addPortBinding = function () {
|
||||
$scope.model.Ports.push({ host: '', container: '', protocol: 'TCP' });
|
||||
};
|
||||
|
||||
$scope.removePortBinding = function (index) {
|
||||
$scope.model.Ports.splice(index, 1);
|
||||
};
|
||||
|
||||
$scope.create = function () {
|
||||
var model = $scope.model;
|
||||
var subscriptionId = $scope.state.selectedSubscription.Id;
|
||||
var resourceGroupName = $scope.state.selectedResourceGroup.Name;
|
||||
|
||||
$scope.state.formValidationError = validateForm(model);
|
||||
if ($scope.state.formValidationError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
AzureService.createContainerGroup(model, subscriptionId, resourceGroupName)
|
||||
.then(applyResourceControl)
|
||||
.then(() => {
|
||||
Notifications.success('Container successfully created', model.Name);
|
||||
$state.go('azure.containerinstances');
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to create container');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
function applyResourceControl(newResourceGroup) {
|
||||
const userId = Authentication.getUserDetails().ID;
|
||||
const resourceControl = newResourceGroup.Portainer.ResourceControl;
|
||||
const accessControlData = $scope.model.AccessControlData;
|
||||
|
||||
return ResourceControlService.applyResourceControl(userId, accessControlData, resourceControl);
|
||||
}
|
||||
|
||||
function validateForm(model) {
|
||||
if (!model.Ports || !model.Ports.length || model.Ports.every((port) => !port.host || !port.container)) {
|
||||
return 'At least one port binding is required';
|
||||
}
|
||||
|
||||
const error = FormValidator.validateAccessControl(model.AccessControlData, Authentication.isAdmin());
|
||||
if (error !== '') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateResourceGroupsAndLocations(subscription, resourceGroups, providers) {
|
||||
$scope.state.selectedResourceGroup = resourceGroups[subscription.Id][0];
|
||||
$scope.resourceGroups = resourceGroups[subscription.Id];
|
||||
|
||||
var currentSubLocations = providers[subscription.Id].Locations;
|
||||
$scope.model.Location = currentSubLocations[0];
|
||||
$scope.locations = currentSubLocations;
|
||||
}
|
||||
|
||||
function initView() {
|
||||
$scope.model = new ContainerGroupDefaultModel();
|
||||
|
||||
AzureService.subscriptions()
|
||||
.then(function success(data) {
|
||||
var subscriptions = data;
|
||||
$scope.state.selectedSubscription = subscriptions[0];
|
||||
$scope.subscriptions = subscriptions;
|
||||
|
||||
return $q.all({
|
||||
resourceGroups: AzureService.resourceGroups(subscriptions),
|
||||
containerInstancesProviders: AzureService.containerInstanceProvider(subscriptions),
|
||||
});
|
||||
})
|
||||
.then(function success(data) {
|
||||
var resourceGroups = data.resourceGroups;
|
||||
allResourceGroups = resourceGroups;
|
||||
|
||||
var containerInstancesProviders = data.containerInstancesProviders;
|
||||
allProviders = containerInstancesProviders;
|
||||
|
||||
var selectedSubscription = $scope.state.selectedSubscription;
|
||||
updateResourceGroupsAndLocations(selectedSubscription, resourceGroups, containerInstancesProviders);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve Azure resources');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
||||
@@ -1,173 +0,0 @@
|
||||
<rd-header>
|
||||
<rd-header-title title-text="Create container instance"></rd-header-title>
|
||||
<rd-header-content> <a ui-sref="azure.containerinstances">Container instances</a> > Add container </rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" autocomplete="off" name="aciForm">
|
||||
<div class="col-sm-12 form-section-title"> Azure settings </div>
|
||||
<!-- subscription-input -->
|
||||
<div class="form-group">
|
||||
<label for="azure_subscription" class="col-sm-1 control-label text-left">Subscription</label>
|
||||
<div class="col-sm-11">
|
||||
<select
|
||||
class="form-control"
|
||||
name="azure_subscription"
|
||||
ng-model="state.selectedSubscription"
|
||||
ng-options="subscription.Name for subscription in subscriptions"
|
||||
ng-change="changeSubscription()"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !subscription-input -->
|
||||
<!-- resourcegroup-input -->
|
||||
<div class="form-group">
|
||||
<label for="azure_resourcegroup" class="col-sm-1 control-label text-left">Resource group</label>
|
||||
<div class="col-sm-11">
|
||||
<select
|
||||
class="form-control"
|
||||
name="azure_resourcegroup"
|
||||
ng-model="state.selectedResourceGroup"
|
||||
ng-options="resourceGroup.Name for resourceGroup in resourceGroups"
|
||||
></select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !resourcegroup-input -->
|
||||
<!-- location-input -->
|
||||
<div class="form-group">
|
||||
<label for="azure_location" class="col-sm-1 control-label text-left">Location</label>
|
||||
<div class="col-sm-11">
|
||||
<select class="form-control" name="azure_location" ng-model="model.Location" ng-options="location for location in locations"></select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !location-input -->
|
||||
<div class="col-sm-12 form-section-title"> Container configuration </div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_name" class="col-sm-1 control-label text-left">Name</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="text" class="form-control" ng-model="model.Name" name="container_name" placeholder="e.g. myContainer" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="aciForm.container_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="aciForm.container_name.$error">
|
||||
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Name is required. </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- image-input -->
|
||||
<div class="form-group">
|
||||
<label for="image_name" class="col-sm-1 control-label text-left">Image</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="text" class="form-control" ng-model="model.Image" name="image_name" placeholder="e.g. nginx:alpine" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="aciForm.image_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="aciForm.image_name.$error">
|
||||
<p ng-message="required"> <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Image is required. </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !image-input -->
|
||||
<!-- os-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_os" class="col-sm-1 control-label text-left">OS</label>
|
||||
<div class="col-sm-11">
|
||||
<select class="form-control" ng-model="model.OSType" name="container_os">
|
||||
<option value="Linux">Linux</option>
|
||||
<option value="Windows">Windows</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !os-input -->
|
||||
<!-- port-mapping -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Port mapping</label>
|
||||
<span class="label label-default interactive" style="margin-left: 10px" ng-click="addPortBinding()">
|
||||
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
|
||||
</span>
|
||||
</div>
|
||||
<!-- port-mapping-input-list -->
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px">
|
||||
<div ng-repeat="binding in model.Ports" style="margin-top: 2px">
|
||||
<!-- host-port -->
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">host</span>
|
||||
<input type="text" class="form-control" ng-model="binding.host" placeholder="e.g. 80" />
|
||||
</div>
|
||||
<!-- !host-port -->
|
||||
<span style="margin: 0 10px 0 10px">
|
||||
<i class="fa fa-long-arrow-alt-right" aria-hidden="true"></i>
|
||||
</span>
|
||||
<!-- container-port -->
|
||||
<div class="input-group col-sm-4 input-group-sm">
|
||||
<span class="input-group-addon">container</span>
|
||||
<input type="text" class="form-control" ng-model="binding.container" placeholder="e.g. 80" />
|
||||
</div>
|
||||
<!-- !container-port -->
|
||||
<!-- protocol-actions -->
|
||||
<div class="input-group col-sm-3 input-group-sm">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<label class="btn btn-primary" ng-model="binding.protocol" uib-btn-radio="'TCP'">TCP</label>
|
||||
<label class="btn btn-primary" ng-model="binding.protocol" uib-btn-radio="'UDP'">UDP</label>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removePortBinding($index)">
|
||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- !protocol-actions -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !port-mapping-input-list -->
|
||||
</div>
|
||||
<!-- !port-mapping -->
|
||||
<!-- public-ip -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">This will automatically deploy a container with a public IP address</div>
|
||||
</div>
|
||||
<!-- public-ip -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Container resources </div>
|
||||
<!-- cpu-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_cpu" class="col-sm-1 control-label text-left">CPU</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="number" class="form-control" ng-model="model.CPU" name="container_cpu" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-input -->
|
||||
<!-- memory-input -->
|
||||
<div class="form-group">
|
||||
<label for="container_memory" class="col-sm-1 control-label text-left">Memory</label>
|
||||
<div class="col-sm-11">
|
||||
<input type="number" class="form-control" ng-model="model.Memory" name="container_memory" placeholder="1" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !memory-input -->
|
||||
<!-- access-control -->
|
||||
<por-access-control-form form-data="model.AccessControlData"></por-access-control-form>
|
||||
<!-- !access-control -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress" ng-click="create()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Deploy the container</span>
|
||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px">{{ state.formValidationError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
<rd-header>
|
||||
<rd-header-title title-text="Home"></rd-header-title>
|
||||
<rd-header-content>Dashboard</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="subscriptions">
|
||||
<div class="col-sm-12 col-md-6">
|
||||
<a ui-sref="azure.subscriptions">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="widget-icon blue pull-left">
|
||||
<i class="fa fa-th-list"></i>
|
||||
</div>
|
||||
<div class="title">{{ subscriptions.length }}</div>
|
||||
<div class="comment">Subscriptions</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6" ng-if="resourceGroups">
|
||||
<a ui-sref="azure.resourceGroups">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="widget-icon blue pull-left">
|
||||
<i class="fa fa-th-list"></i>
|
||||
</div>
|
||||
<div class="title">{{ resourceGroups.length }}</div>
|
||||
<div class="comment">Resource groups</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
angular.module('portainer.azure').controller('AzureDashboardController', [
|
||||
'$scope',
|
||||
'AzureService',
|
||||
'Notifications',
|
||||
function ($scope, AzureService, Notifications) {
|
||||
function initView() {
|
||||
AzureService.subscriptions()
|
||||
.then(function success(data) {
|
||||
var subscriptions = data;
|
||||
$scope.subscriptions = subscriptions;
|
||||
return AzureService.resourceGroups(subscriptions);
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.resourceGroups = AzureService.aggregate(data);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to load dashboard data');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
||||
@@ -13,7 +13,7 @@ angular.module('portainer.docker', ['portainer.app', containersModule, component
|
||||
parent: 'endpoint',
|
||||
url: '/docker',
|
||||
abstract: true,
|
||||
onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, EndpointProvider, LegacyExtensionManager, Notifications, StateManager, SystemService) {
|
||||
onEnter: /* @ngInject */ function onEnter(endpoint, $async, $state, EndpointService, EndpointProvider, Notifications, StateManager, SystemService) {
|
||||
return $async(async () => {
|
||||
if (![1, 2, 4].includes(endpoint.Type)) {
|
||||
$state.go('portainer.home');
|
||||
@@ -40,8 +40,7 @@ angular.module('portainer.docker', ['portainer.app', containersModule, component
|
||||
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
|
||||
EndpointProvider.setOfflineModeFromStatus(endpoint.Status);
|
||||
|
||||
const extensions = await LegacyExtensionManager.initEndpointExtensions(endpoint);
|
||||
await StateManager.updateEndpointState(endpoint, extensions);
|
||||
await StateManager.updateEndpointState(endpoint);
|
||||
} catch (e) {
|
||||
Notifications.error('Failed loading environment', e);
|
||||
$state.go('portainer.home', {}, { reload: true });
|
||||
|
||||
@@ -3,6 +3,7 @@ import _ from 'lodash-es';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
|
||||
import * as envVarsUtils from '@/portainer/helpers/env-vars';
|
||||
import { FeatureId } from 'Portainer/feature-flags/enums';
|
||||
import { ContainerCapabilities, ContainerCapability } from '../../../models/containerCapabilities';
|
||||
import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { ContainerDetailsViewModel } from '../../../models/container';
|
||||
@@ -65,7 +66,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [
|
||||
$scope.create = create;
|
||||
$scope.update = update;
|
||||
$scope.endpoint = endpoint;
|
||||
|
||||
$scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK;
|
||||
$scope.formValues = {
|
||||
alwaysPull: true,
|
||||
Console: 'none',
|
||||
|
||||
@@ -65,6 +65,28 @@
|
||||
</por-image-registry>
|
||||
<!-- !image-and-registry -->
|
||||
</div>
|
||||
<!-- create-webhook -->
|
||||
<div ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
||||
<div class="col-sm-12 form-section-title"> Webhooks </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Create a container webhook
|
||||
<portainer-tooltip
|
||||
position="top"
|
||||
message="Create a webhook (or callback URI) to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container."
|
||||
></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch box-selector-item limited business" style="margin-left: 20px">
|
||||
<input type="checkbox" ng-model="formValues.EnableWebhook" disabled="disabled" ng-checked="true" />
|
||||
<i class="orange-icon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
</label>
|
||||
<be-feature-indicator feature="containerWebhookFeature"></be-feature-indicator>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !create-webhook -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Network ports configuration </div>
|
||||
<!-- publish-exposed-ports -->
|
||||
<div class="form-group">
|
||||
|
||||
@@ -110,6 +110,19 @@
|
||||
<td>Finished</td>
|
||||
<td>{{ container.State.FinishedAt | getisodate }}</td>
|
||||
</tr>
|
||||
<tr ng-if="isAdmin && displayRecreateButton && applicationState.endpoint.type !== 4">
|
||||
<td colspan="1">
|
||||
Container webhook
|
||||
<portainer-tooltip
|
||||
position="top"
|
||||
message="Webhook (or callback URI) used to automate the recreate this container. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and recreate this container."
|
||||
></portainer-tooltip>
|
||||
<label class="switch box-selector-item limited business" style="margin-left: 20px">
|
||||
<input disable-authorization="DockerContainerUpdate" type="checkbox" ng-model="WebhookExists" disabled="disabled" ng-checked="true" /><i></i>
|
||||
</label>
|
||||
<be-feature-indicator feature="containerWebhookFeature"></be-feature-indicator>
|
||||
</td>
|
||||
</tr>
|
||||
<tr authorization="DockerContainerLogs, DockerContainerInspect, DockerContainerStats, DockerExecStart">
|
||||
<td colspan="2">
|
||||
<div class="btn-group" role="group" aria-label="...">
|
||||
|
||||
@@ -2,6 +2,7 @@ import moment from 'moment';
|
||||
import _ from 'lodash-es';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { confirmContainerDeletion } from '@/portainer/services/modal.service/prompt';
|
||||
import { FeatureId } from 'Portainer/feature-flags/enums';
|
||||
|
||||
angular.module('portainer.docker').controller('ContainerController', [
|
||||
'$q',
|
||||
@@ -49,6 +50,7 @@ angular.module('portainer.docker').controller('ContainerController', [
|
||||
$scope.activityTime = 0;
|
||||
$scope.portBindings = [];
|
||||
$scope.displayRecreateButton = false;
|
||||
$scope.containerWebhookFeature = FeatureId.CONTAINER_WEBHOOK;
|
||||
|
||||
$scope.config = {
|
||||
RegistryModel: new PorImageRegistryModel(),
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
<!-- !port-mapping -->
|
||||
<!-- create-webhook -->
|
||||
<div ng-if="endpoint.Type !== 4">
|
||||
<div ng-if="endpoint.Type !== 4 && isAdmin">
|
||||
<div class="col-sm-12 form-section-title"> Webhooks </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
<td>Image</td>
|
||||
<td>{{ service.Image }}</td>
|
||||
</tr>
|
||||
<tr ng-if="applicationState.endpoint.type !== 4">
|
||||
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
||||
<td colspan="{{ webhookURL ? '1' : '2' }}">
|
||||
Service webhook
|
||||
<portainer-tooltip
|
||||
|
||||
@@ -86,11 +86,6 @@ angular.module('portainer.docker').controller('CreateVolumeController', [
|
||||
var name = $scope.formValues.Name;
|
||||
var driver = $scope.formValues.Driver;
|
||||
var driverOptions = $scope.formValues.DriverOptions;
|
||||
var storidgeProfile = $scope.formValues.StoridgeProfile;
|
||||
|
||||
if (driver === 'cio:latest' && storidgeProfile) {
|
||||
driverOptions.push({ name: 'profile', value: storidgeProfile.Name });
|
||||
}
|
||||
|
||||
if ($scope.formValues.NFSData.useNFS) {
|
||||
prepareNFSConfiguration(driverOptions);
|
||||
|
||||
@@ -83,12 +83,6 @@
|
||||
</div>
|
||||
<volumes-cifs-form data="formValues.CIFSData" ng-show="formValues.Driver === 'local'"></volumes-cifs-form>
|
||||
<!-- !cifs-management -->
|
||||
<!-- storidge -->
|
||||
<div ng-if="formValues.Driver === 'cio:latest'">
|
||||
<div class="col-sm-12 form-section-title"> Storidge </div>
|
||||
<storidge-profile-selector storidge-profile="formValues.StoridgeProfile"></storidge-profile-selector>
|
||||
</div>
|
||||
<!-- storidge -->
|
||||
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && formValues.Driver === 'local'">
|
||||
<div class="col-sm-12 form-section-title"> Deployment </div>
|
||||
<!-- node-selection -->
|
||||
|
||||
@@ -51,32 +51,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="isCioDriver">
|
||||
<div class="col-sm-12">
|
||||
<volume-storidge-info volume="storidgeVolume"> </volume-storidge-info>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="isCioDriver">
|
||||
<div class="col-sm-12">
|
||||
<storidge-snapshot-creation volume-id="storidgeVolume.Vdisk" ng-if="storidgeVolume.SnapshotEnabled"> </storidge-snapshot-creation>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="isCioDriver && storidgeVolume.SnapshotEnabled">
|
||||
<div class="col-sm-12">
|
||||
<storidge-snapshots-datatable
|
||||
title-text="Snapshots"
|
||||
title-icon="fa-camera"
|
||||
dataset="storidgeSnapshots"
|
||||
table-key="storidgeSnapshots"
|
||||
order-by="Id"
|
||||
remove-action="removeSnapshot"
|
||||
>
|
||||
</storidge-snapshots-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- access-control-panel -->
|
||||
<por-access-control-panel ng-if="volume" resource-id="volume.ResourceId" resource-control="volume.ResourceControl" resource-type="'volume'"> </por-access-control-panel>
|
||||
<!-- !access-control-panel -->
|
||||
|
||||
@@ -8,48 +8,7 @@ angular.module('portainer.docker').controller('VolumeController', [
|
||||
'ContainerService',
|
||||
'Notifications',
|
||||
'HttpRequestHelper',
|
||||
'StoridgeVolumeService',
|
||||
'StoridgeSnapshotService',
|
||||
function ($scope, $state, $transition$, $q, ModalService, VolumeService, ContainerService, Notifications, HttpRequestHelper, StoridgeVolumeService, StoridgeSnapshotService) {
|
||||
$scope.storidgeSnapshots = [];
|
||||
$scope.storidgeVolume = {};
|
||||
|
||||
$scope.removeSnapshot = function (selectedItems) {
|
||||
ModalService.confirm({
|
||||
title: 'Are you sure?',
|
||||
message: 'Do you want really want to remove this snapshot?',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Remove',
|
||||
className: 'btn-danger',
|
||||
},
|
||||
},
|
||||
callback: function onConfirm(confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
var actionCount = selectedItems.length;
|
||||
angular.forEach(selectedItems, function (item) {
|
||||
StoridgeSnapshotService.remove(item.Id)
|
||||
.then(function success() {
|
||||
Notifications.success('Snapshot successfully removed', item.Id);
|
||||
var index = $scope.storidgeSnapshots.indexOf(item);
|
||||
$scope.storidgeSnapshots.splice(index, 1);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove snapshot');
|
||||
})
|
||||
.finally(function final() {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
$state.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function ($scope, $state, $transition$, $q, ModalService, VolumeService, ContainerService, Notifications, HttpRequestHelper) {
|
||||
$scope.removeVolume = function removeVolume() {
|
||||
ModalService.confirmDeletion('Do you want to remove this volume?', (confirmed) => {
|
||||
if (confirmed) {
|
||||
@@ -80,15 +39,7 @@ angular.module('portainer.docker').controller('VolumeController', [
|
||||
$scope.volume = volume;
|
||||
var containerFilter = { volume: [volume.Id] };
|
||||
|
||||
$scope.isCioDriver = volume.Driver.includes('cio');
|
||||
if ($scope.isCioDriver) {
|
||||
return $q.all({
|
||||
containers: ContainerService.containers(1, containerFilter),
|
||||
storidgeVolume: StoridgeVolumeService.volume($transition$.params().id),
|
||||
});
|
||||
} else {
|
||||
return ContainerService.containers(1, containerFilter);
|
||||
}
|
||||
return ContainerService.containers(1, containerFilter);
|
||||
})
|
||||
.then(function success(data) {
|
||||
var dataContainers = $scope.isCioDriver ? data.containers : data;
|
||||
@@ -98,16 +49,6 @@ angular.module('portainer.docker').controller('VolumeController', [
|
||||
return container;
|
||||
});
|
||||
$scope.containersUsingVolume = containers;
|
||||
|
||||
if ($scope.isCioDriver) {
|
||||
$scope.storidgeVolume = data.storidgeVolume;
|
||||
if ($scope.storidgeVolume.SnapshotEnabled) {
|
||||
return StoridgeSnapshotService.snapshots(data.storidgeVolume.Vdisk);
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.storidgeSnapshots = data;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve volume details');
|
||||
|
||||
@@ -206,6 +206,7 @@ export function EdgeDevicesDatatable({
|
||||
<RowProvider
|
||||
key={key}
|
||||
disableTrustOnFirstConnect={disableTrustOnFirstConnect}
|
||||
isOpenAmtEnabled={isOpenAmtEnabled}
|
||||
>
|
||||
<TableRow<Environment>
|
||||
cells={row.cells}
|
||||
|
||||
@@ -2,21 +2,24 @@ import { createContext, useContext, useMemo, PropsWithChildren } from 'react';
|
||||
|
||||
interface RowContextState {
|
||||
disableTrustOnFirstConnect: boolean;
|
||||
isOpenAmtEnabled: boolean;
|
||||
}
|
||||
|
||||
const RowContext = createContext<RowContextState | null>(null);
|
||||
|
||||
export interface RowProviderProps {
|
||||
disableTrustOnFirstConnect: boolean;
|
||||
isOpenAmtEnabled: boolean;
|
||||
}
|
||||
|
||||
export function RowProvider({
|
||||
disableTrustOnFirstConnect,
|
||||
isOpenAmtEnabled,
|
||||
children,
|
||||
}: PropsWithChildren<RowProviderProps>) {
|
||||
const state = useMemo(
|
||||
() => ({ disableTrustOnFirstConnect }),
|
||||
[disableTrustOnFirstConnect]
|
||||
() => ({ disableTrustOnFirstConnect, isOpenAmtEnabled }),
|
||||
[disableTrustOnFirstConnect, isOpenAmtEnabled]
|
||||
);
|
||||
|
||||
return <RowContext.Provider value={state}>{children}</RowContext.Provider>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user