feat(stacks): add support for stack deploy (#1280)

pull/1287/head
Anthony Lapenna 2017-10-15 19:24:40 +02:00 committed by GitHub
parent 80827935da
commit 587e2fa673
77 changed files with 3219 additions and 702 deletions

View File

@ -25,6 +25,7 @@ type Store struct {
SettingsService *SettingsService SettingsService *SettingsService
RegistryService *RegistryService RegistryService *RegistryService
DockerHubService *DockerHubService DockerHubService *DockerHubService
StackService *StackService
db *bolt.DB db *bolt.DB
checkForDataMigration bool checkForDataMigration bool
@ -41,6 +42,7 @@ const (
settingsBucketName = "settings" settingsBucketName = "settings"
registryBucketName = "registries" registryBucketName = "registries"
dockerhubBucketName = "dockerhub" dockerhubBucketName = "dockerhub"
stackBucketName = "stacks"
) )
// NewStore initializes a new Store and the associated services // NewStore initializes a new Store and the associated services
@ -56,6 +58,7 @@ func NewStore(storePath string) (*Store, error) {
SettingsService: &SettingsService{}, SettingsService: &SettingsService{},
RegistryService: &RegistryService{}, RegistryService: &RegistryService{},
DockerHubService: &DockerHubService{}, DockerHubService: &DockerHubService{},
StackService: &StackService{},
} }
store.UserService.store = store store.UserService.store = store
store.TeamService.store = store store.TeamService.store = store
@ -66,6 +69,7 @@ func NewStore(storePath string) (*Store, error) {
store.SettingsService.store = store store.SettingsService.store = store
store.RegistryService.store = store store.RegistryService.store = store
store.DockerHubService.store = store store.DockerHubService.store = store
store.StackService.store = store
_, err := os.Stat(storePath + "/" + databaseFileName) _, err := os.Stat(storePath + "/" + databaseFileName)
if err != nil && os.IsNotExist(err) { if err != nil && os.IsNotExist(err) {
@ -91,7 +95,7 @@ func (store *Store) Open() error {
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName, bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
resourceControlBucketName, teamMembershipBucketName, settingsBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
registryBucketName, dockerhubBucketName} registryBucketName, dockerhubBucketName, stackBucketName}
return db.Update(func(tx *bolt.Tx) error { return db.Update(func(tx *bolt.Tx) error {

View File

@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
return json.Unmarshal(data, endpoint) return json.Unmarshal(data, endpoint)
} }
// MarshalStack encodes a stack to binary format.
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
return json.Marshal(stack)
}
// UnmarshalStack decodes a stack from a binary data.
func UnmarshalStack(data []byte, stack *portainer.Stack) error {
return json.Unmarshal(data, stack)
}
// MarshalRegistry encodes a registry to binary format. // MarshalRegistry encodes a registry to binary format.
func MarshalRegistry(registry *portainer.Registry) ([]byte, error) { func MarshalRegistry(registry *portainer.Registry) ([]byte, error) {
return json.Marshal(registry) return json.Marshal(registry)

138
api/bolt/stack_service.go Normal file
View File

@ -0,0 +1,138 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// StackService represents a service for managing stacks.
type StackService struct {
store *Store
}
// Stack returns a stack object by ID.
func (service *StackService) Stack(ID portainer.StackID) (*portainer.Stack, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
value := bucket.Get([]byte(ID))
if value == nil {
return portainer.ErrStackNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var stack portainer.Stack
err = internal.UnmarshalStack(data, &stack)
if err != nil {
return nil, err
}
return &stack, nil
}
// Stacks returns an array containing all the stacks.
func (service *StackService) Stacks() ([]portainer.Stack, error) {
var stacks = make([]portainer.Stack, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var stack portainer.Stack
err := internal.UnmarshalStack(v, &stack)
if err != nil {
return err
}
stacks = append(stacks, stack)
}
return nil
})
if err != nil {
return nil, err
}
return stacks, nil
}
// StacksBySwarmID return an array containing all the stacks related to the specified Swarm ID.
func (service *StackService) StacksBySwarmID(id string) ([]portainer.Stack, error) {
var stacks = make([]portainer.Stack, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var stack portainer.Stack
err := internal.UnmarshalStack(v, &stack)
if err != nil {
return err
}
if stack.SwarmID == id {
stacks = append(stacks, stack)
}
}
return nil
})
if err != nil {
return nil, err
}
return stacks, nil
}
// CreateStack creates a new stack.
func (service *StackService) CreateStack(stack *portainer.Stack) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
data, err := internal.MarshalStack(stack)
if err != nil {
return err
}
err = bucket.Put([]byte(stack.ID), data)
if err != nil {
return err
}
return nil
})
}
// UpdateStack updates an stack.
func (service *StackService) UpdateStack(ID portainer.StackID, stack *portainer.Stack) error {
data, err := internal.MarshalStack(stack)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
err = bucket.Put([]byte(ID), data)
if err != nil {
return err
}
return nil
})
}
// DeleteStack deletes an stack.
func (service *StackService) DeleteStack(ID portainer.StackID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(stackBucketName))
err := bucket.Delete([]byte(ID))
if err != nil {
return err
}
return nil
})
}

View File

@ -6,7 +6,9 @@ import (
"github.com/portainer/portainer/cli" "github.com/portainer/portainer/cli"
"github.com/portainer/portainer/cron" "github.com/portainer/portainer/cron"
"github.com/portainer/portainer/crypto" "github.com/portainer/portainer/crypto"
"github.com/portainer/portainer/exec"
"github.com/portainer/portainer/file" "github.com/portainer/portainer/file"
"github.com/portainer/portainer/git"
"github.com/portainer/portainer/http" "github.com/portainer/portainer/http"
"github.com/portainer/portainer/jwt" "github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap" "github.com/portainer/portainer/ldap"
@ -54,6 +56,10 @@ func initStore(dataStorePath string) *bolt.Store {
return store return store
} }
func initStackManager(assetsPath string) portainer.StackManager {
return exec.NewStackManager(assetsPath)
}
func initJWTService(authenticationEnabled bool) portainer.JWTService { func initJWTService(authenticationEnabled bool) portainer.JWTService {
if authenticationEnabled { if authenticationEnabled {
jwtService, err := jwt.NewService() jwtService, err := jwt.NewService()
@ -73,6 +79,10 @@ func initLDAPService() portainer.LDAPService {
return &ldap.Service{} return &ldap.Service{}
} }
func initGitService() portainer.GitService {
return &git.Service{}
}
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool { func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
authorizeEndpointMgmt := true authorizeEndpointMgmt := true
if externalEnpointFile != "" { if externalEnpointFile != "" {
@ -165,12 +175,16 @@ func main() {
store := initStore(*flags.Data) store := initStore(*flags.Data)
defer store.Close() defer store.Close()
stackManager := initStackManager(*flags.Assets)
jwtService := initJWTService(!*flags.NoAuth) jwtService := initJWTService(!*flags.NoAuth)
cryptoService := initCryptoService() cryptoService := initCryptoService()
ldapService := initLDAPService() ldapService := initLDAPService()
gitService := initGitService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
err := initSettings(store.SettingsService, flags) err := initSettings(store.SettingsService, flags)
@ -215,7 +229,7 @@ func main() {
adminPasswordHash := "" adminPasswordHash := ""
if *flags.AdminPasswordFile != "" { if *flags.AdminPasswordFile != "" {
content, err := file.GetStringFromFile(*flags.AdminPasswordFile) content, err := fileService.GetFileContent(*flags.AdminPasswordFile)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -263,10 +277,13 @@ func main() {
SettingsService: store.SettingsService, SettingsService: store.SettingsService,
RegistryService: store.RegistryService, RegistryService: store.RegistryService,
DockerHubService: store.DockerHubService, DockerHubService: store.DockerHubService,
StackService: store.StackService,
StackManager: stackManager,
CryptoService: cryptoService, CryptoService: cryptoService,
JWTService: jwtService, JWTService: jwtService,
FileService: fileService, FileService: fileService,
LDAPService: ldapService, LDAPService: ldapService,
GitService: gitService,
SSL: *flags.SSL, SSL: *flags.SSL,
SSLCert: *flags.SSLCert, SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey, SSLKey: *flags.SSLKey,

View File

@ -50,6 +50,13 @@ const (
ErrRegistryAlreadyExists = Error("A registry is already defined for this URL") ErrRegistryAlreadyExists = Error("A registry is already defined for this URL")
) )
// Stack errors
const (
ErrStackNotFound = Error("Stack not found")
ErrStackAlreadyExists = Error("A stack already exists with this name")
ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository")
)
// Version errors. // Version errors.
const ( const (
ErrDBVersionNotFound = Error("DB version not found") ErrDBVersionNotFound = Error("DB version not found")

76
api/exec/stack_manager.go Normal file
View File

@ -0,0 +1,76 @@
package exec
import (
"bytes"
"os/exec"
"path"
"runtime"
"github.com/portainer/portainer"
)
// StackManager represents a service for managing stacks.
type StackManager struct {
binaryPath string
}
// NewStackManager initializes a new StackManager service.
func NewStackManager(binaryPath string) *StackManager {
return &StackManager{
binaryPath: binaryPath,
}
}
// Deploy will execute the Docker stack deploy command
func (manager *StackManager) Deploy(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
args = append(args, "stack", "deploy", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
return runCommandAndCaptureStdErr(command, args)
}
// Remove will execute the Docker stack rm command
func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
args = append(args, "stack", "rm", stack.Name)
return runCommandAndCaptureStdErr(command, args)
}
func runCommandAndCaptureStdErr(command string, args []string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return portainer.Error(stderr.String())
}
return nil
}
func prepareDockerCommandAndArgs(binaryPath string, endpoint *portainer.Endpoint) (string, []string) {
// Assume Linux as a default
command := path.Join(binaryPath, "docker")
if runtime.GOOS == "windows" {
command = path.Join(binaryPath, "docker.exe")
}
args := make([]string, 0)
args = append(args, "-H", endpoint.URL)
if endpoint.TLSConfig.TLS {
args = append(args, "--tls")
if !endpoint.TLSConfig.TLSSkipVerify {
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
}
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath)
}
}
return command, args
}

View File

@ -1,6 +1,7 @@
package file package file
import ( import (
"bytes"
"io/ioutil" "io/ioutil"
"github.com/portainer/portainer" "github.com/portainer/portainer"
@ -21,6 +22,10 @@ const (
TLSCertFile = "cert.pem" TLSCertFile = "cert.pem"
// TLSKeyFile represents the name on disk for a TLS key file. // TLSKeyFile represents the name on disk for a TLS key file.
TLSKeyFile = "key.pem" TLSKeyFile = "key.pem"
// ComposeStorePath represents the subfolder where compose files are stored in the file store folder.
ComposeStorePath = "compose"
// ComposeFileDefaultName represents the default name of a compose file.
ComposeFileDefaultName = "docker-compose.yml"
) )
// Service represents a service for managing files and directories. // Service represents a service for managing files and directories.
@ -50,9 +55,65 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return nil, err return nil, err
} }
err = service.createDirectoryInStoreIfNotExist(ComposeStorePath)
if err != nil {
return nil, err
}
return service, nil return service, nil
} }
// RemoveDirectory removes a directory on the filesystem.
func (service *Service) RemoveDirectory(directoryPath string) error {
return os.RemoveAll(directoryPath)
}
// GetStackProjectPath returns the absolute path on the FS for a stack based
// on its identifier.
func (service *Service) GetStackProjectPath(stackIdentifier string) string {
return path.Join(service.fileStorePath, ComposeStorePath, stackIdentifier)
}
// StoreStackFileFromString creates a subfolder in the ComposeStorePath and stores a new file using the content from a string.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreStackFileFromString(stackIdentifier, stackFileContent string) (string, error) {
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
if err != nil {
return "", err
}
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
data := []byte(stackFileContent)
r := bytes.NewReader(data)
err = service.createFileInStore(composeFilePath, r)
if err != nil {
return "", err
}
return path.Join(service.fileStorePath, stackStorePath), nil
}
// StoreStackFileFromReader creates a subfolder in the ComposeStorePath and stores a new file using the content from an io.Reader.
// It returns the path to the folder where the file is stored.
func (service *Service) StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error) {
stackStorePath := path.Join(ComposeStorePath, stackIdentifier)
err := service.createDirectoryInStoreIfNotExist(stackStorePath)
if err != nil {
return "", err
}
composeFilePath := path.Join(stackStorePath, ComposeFileDefaultName)
err = service.createFileInStore(composeFilePath, r)
if err != nil {
return "", err
}
return path.Join(service.fileStorePath, stackStorePath), nil
}
// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r. // StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r.
func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error { func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error {
storePath := path.Join(TLSStorePath, folder) storePath := path.Join(TLSStorePath, folder)
@ -130,6 +191,16 @@ func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileT
return nil return nil
} }
// GetFileContent returns a string content from file.
func (service *Service) GetFileContent(filePath string) (string, error) {
content, err := ioutil.ReadFile(filePath)
if err != nil {
return "", err
}
return string(content), nil
}
// createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system. // createDirectoryInStoreIfNotExist creates a new directory in the file store if it doesn't exists on the file system.
func (service *Service) createDirectoryInStoreIfNotExist(name string) error { func (service *Service) createDirectoryInStoreIfNotExist(name string) error {
path := path.Join(service.fileStorePath, name) path := path.Join(service.fileStorePath, name)
@ -153,24 +224,17 @@ func createDirectoryIfNotExist(path string, mode uint32) error {
// createFile creates a new file in the file store with the content from r. // createFile creates a new file in the file store with the content from r.
func (service *Service) createFileInStore(filePath string, r io.Reader) error { func (service *Service) createFileInStore(filePath string, r io.Reader) error {
path := path.Join(service.fileStorePath, filePath) path := path.Join(service.fileStorePath, filePath)
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil { if err != nil {
return err return err
} }
defer out.Close() defer out.Close()
_, err = io.Copy(out, r) _, err = io.Copy(out, r)
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
// GetStringFromFile returns a string content from file.
func GetStringFromFile(filePath string) (string, error) {
content, err := ioutil.ReadFile(filePath)
if err != nil {
return "", err
}
return string(content), nil
}

25
api/git/git.go Normal file
View File

@ -0,0 +1,25 @@
package git
import (
"gopkg.in/src-d/go-git.v4"
)
// Service represents a service for managing Git.
type Service struct{}
// NewService initializes a new service.
func NewService(dataStorePath string) (*Service, error) {
service := &Service{}
return service, nil
}
// CloneRepository clones a git repository using the specified URL in the specified
// destination folder.
func (service *Service) CloneRepository(url, destination string) error {
_, err := git.PlainClone(destination, false, &git.CloneOptions{
URL: url,
})
return err
}

View File

@ -22,7 +22,7 @@ type DockerHubHandler struct {
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
} }
// NewDockerHubHandler returns a new instance of NewDockerHubHandler. // NewDockerHubHandler returns a new instance of DockerHubHandler.
func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler { func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler {
h := &DockerHubHandler{ h := &DockerHubHandler{
Router: mux.NewRouter(), Router: mux.NewRouter(),

View File

@ -20,6 +20,7 @@ type Handler struct {
RegistryHandler *RegistryHandler RegistryHandler *RegistryHandler
DockerHubHandler *DockerHubHandler DockerHubHandler *DockerHubHandler
ResourceHandler *ResourceHandler ResourceHandler *ResourceHandler
StackHandler *StackHandler
StatusHandler *StatusHandler StatusHandler *StatusHandler
SettingsHandler *SettingsHandler SettingsHandler *SettingsHandler
TemplatesHandler *TemplatesHandler TemplatesHandler *TemplatesHandler
@ -49,6 +50,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/endpoints"): case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
if strings.Contains(r.URL.Path, "/docker") { if strings.Contains(r.URL.Path, "/docker") {
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r) http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
} else if strings.Contains(r.URL.Path, "/stacks") {
http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r)
} else { } else {
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
} }

View File

@ -82,6 +82,8 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht
resourceControlType = portainer.NetworkResourceControl resourceControlType = portainer.NetworkResourceControl
case "secret": case "secret":
resourceControlType = portainer.SecretResourceControl resourceControlType = portainer.SecretResourceControl
case "stack":
resourceControlType = portainer.StackResourceControl
default: default:
httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger) httperror.WriteErrorResponse(w, portainer.ErrInvalidResourceControlType, http.StatusBadRequest, handler.Logger)
return return

609
api/http/handler/stack.go Normal file
View File

@ -0,0 +1,609 @@
package handler
import (
"encoding/json"
"path"
"strconv"
"strings"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
"github.com/portainer/portainer/file"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// StackHandler represents an HTTP API handler for managing Stack.
type StackHandler struct {
*mux.Router
Logger *log.Logger
FileService portainer.FileService
GitService portainer.GitService
StackService portainer.StackService
EndpointService portainer.EndpointService
ResourceControlService portainer.ResourceControlService
StackManager portainer.StackManager
}
// NewStackHandler returns a new instance of StackHandler.
func NewStackHandler(bouncer *security.RequestBouncer) *StackHandler {
h := &StackHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/{endpointId}/stacks",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostStacks))).Methods(http.MethodPost)
h.Handle("/{endpointId}/stacks",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStacks))).Methods(http.MethodGet)
h.Handle("/{endpointId}/stacks/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStack))).Methods(http.MethodGet)
h.Handle("/{endpointId}/stacks/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleDeleteStack))).Methods(http.MethodDelete)
h.Handle("/{endpointId}/stacks/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handlePutStack))).Methods(http.MethodPut)
h.Handle("/{endpointId}/stacks/{id}/stackfile",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetStackFile))).Methods(http.MethodGet)
return h
}
type (
postStacksRequest struct {
Name string `valid:"required"`
SwarmID string `valid:"required"`
StackFileContent string `valid:""`
GitRepository string `valid:""`
PathInRepository string `valid:""`
}
postStacksResponse struct {
ID string `json:"Id"`
}
getStackFileResponse struct {
StackFileContent string `json:"StackFileContent"`
}
putStackRequest struct {
StackFileContent string `valid:"required"`
}
)
// handlePostStacks handles POST requests on /:endpointId/stacks?method=<method>
func (handler *StackHandler) handlePostStacks(w http.ResponseWriter, r *http.Request) {
method := r.FormValue("method")
if method == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
if method == "string" {
handler.handlePostStacksStringMethod(w, r)
} else if method == "repository" {
handler.handlePostStacksRepositoryMethod(w, r)
} else if method == "file" {
handler.handlePostStacksFileMethod(w, r)
} else {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
}
func (handler *StackHandler) handlePostStacksStringMethod(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var req postStacksRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stackName := req.Name
if stackName == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stackFileContent := req.StackFileContent
if stackFileContent == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
swarmID := req.SwarmID
if swarmID == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stacks, err := handler.StackService.Stacks()
if err != nil && err != portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, stack := range stacks {
if strings.EqualFold(stack.Name, stackName) {
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
stack := &portainer.Stack{
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: file.ComposeFileDefaultName,
}
projectPath, err := handler.FileService.StoreStackFileFromString(string(stack.ID), stackFileContent)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack.ProjectPath = projectPath
err = handler.StackService.CreateStack(stack)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
}
func (handler *StackHandler) handlePostStacksRepositoryMethod(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var req postStacksRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stackName := req.Name
if stackName == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
swarmID := req.SwarmID
if swarmID == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if req.GitRepository == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if req.PathInRepository == "" {
req.PathInRepository = file.ComposeFileDefaultName
}
stacks, err := handler.StackService.Stacks()
if err != nil && err != portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, stack := range stacks {
if strings.EqualFold(stack.Name, stackName) {
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
stack := &portainer.Stack{
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: req.PathInRepository,
}
projectPath := handler.FileService.GetStackProjectPath(string(stack.ID))
stack.ProjectPath = projectPath
// Ensure projectPath is empty
err = handler.FileService.RemoveDirectory(projectPath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.GitService.CloneRepository(req.GitRepository, projectPath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackService.CreateStack(stack)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
}
func (handler *StackHandler) handlePostStacksFileMethod(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stackName := r.FormValue("Name")
if stackName == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
swarmID := r.FormValue("SwarmID")
if swarmID == "" {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
stackFile, _, err := r.FormFile("file")
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
defer stackFile.Close()
stacks, err := handler.StackService.Stacks()
if err != nil && err != portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, stack := range stacks {
if strings.EqualFold(stack.Name, stackName) {
httperror.WriteErrorResponse(w, portainer.ErrStackAlreadyExists, http.StatusConflict, handler.Logger)
return
}
}
stack := &portainer.Stack{
ID: portainer.StackID(stackName + "_" + swarmID),
Name: stackName,
SwarmID: swarmID,
EntryPoint: file.ComposeFileDefaultName,
}
projectPath, err := handler.FileService.StoreStackFileFromReader(string(stack.ID), stackFile)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack.ProjectPath = projectPath
err = handler.StackService.CreateStack(stack)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, &postStacksResponse{ID: string(stack.ID)}, handler.Logger)
}
// handleGetStacks handles GET requests on /:endpointId/stacks?swarmId=<swarmId>
func (handler *StackHandler) handleGetStacks(w http.ResponseWriter, r *http.Request) {
swarmID := r.FormValue("swarmId")
vars := mux.Vars(r)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
_, err = handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var stacks []portainer.Stack
if swarmID == "" {
stacks, err = handler.StackService.Stacks()
} else {
stacks, err = handler.StackService.StacksBySwarmID(swarmID)
}
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
resourceControls, err := handler.ResourceControlService.ResourceControls()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredStacks := proxy.FilterStacks(stacks, resourceControls, securityContext.IsAdmin,
securityContext.UserID, securityContext.UserMemberships)
encodeJSON(w, filteredStacks, handler.Logger)
}
// handleGetStack handles GET requests on /:endpointId/stacks/:id
func (handler *StackHandler) handleGetStack(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
stackID := vars["id"]
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpointID, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
if err == portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
resourceControl, err := handler.ResourceControlService.ResourceControlByResourceID(stack.Name)
if err != nil && err != portainer.ErrResourceControlNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
extendedStack := proxy.ExtendedStack{*stack, portainer.ResourceControl{}}
if resourceControl != nil {
if securityContext.IsAdmin || proxy.CanAccessStack(stack, resourceControl, securityContext.UserID, securityContext.UserMemberships) {
extendedStack.ResourceControl = *resourceControl
} else {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
}
encodeJSON(w, extendedStack, handler.Logger)
}
// handlePutStack handles PUT requests on /:endpointId/stacks/:id
func (handler *StackHandler) handlePutStack(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
stackID := vars["id"]
endpointID, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
if err == portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var req putStackRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.FileService.StoreStackFileFromString(string(stack.ID), req.StackFileContent)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Deploy(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handleGetStackFile handles GET requests on /:endpointId/stacks/:id/stackfile
func (handler *StackHandler) handleGetStackFile(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
stackID := vars["id"]
endpointID, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
_, err = handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
if err == portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
encodeJSON(w, &getStackFileResponse{StackFileContent: stackFileContent}, handler.Logger)
}
// handleDeleteStack handles DELETE requests on /:endpointId/stacks/:id
func (handler *StackHandler) handleDeleteStack(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
stackID := vars["id"]
endpointID, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
stack, err := handler.StackService.Stack(portainer.StackID(stackID))
if err == portainer.ErrStackNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackManager.Remove(stack, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.StackService.DeleteStack(portainer.StackID(stackID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@ -30,7 +30,7 @@ func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler {
return h return h
} }
// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder // handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=<folder>
func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) { func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
certificate := vars["certificate"] certificate := vars["certificate"]

View File

@ -69,7 +69,7 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
host = endpointURL.Path host = endpointURL.Path
} }
// Should not be managed here // TODO: Should not be managed here
var tlsConfig *tls.Config var tlsConfig *tls.Config
if endpoint.TLSConfig.TLS { if endpoint.TLSConfig.TLS {
tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig) tlsConfig, err = crypto.CreateTLSConfiguration(&endpoint.TLSConfig)

View File

@ -2,6 +2,83 @@ package proxy
import "github.com/portainer/portainer" import "github.com/portainer/portainer"
type (
// ExtendedStack represents a stack combined with its associated access control
ExtendedStack struct {
portainer.Stack
ResourceControl portainer.ResourceControl `json:"ResourceControl"`
}
)
// applyResourceAccessControl returns an optionally decorated object as the first return value and the
// access level for the user (granted or denied) as the second return value.
// It will retrieve an identifier from the labels object. If an identifier exists, it will check for
// an existing resource control associated to it.
// Returns a decorated object and authorized access (true) when a resource control is found and the user can access the resource.
// Returns the original object and authorized access (true) when no resource control is found.
// Returns the original object and denied access (false) when a resource control is found and the user cannot access the resource.
func applyResourceAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string,
context *restrictedOperationContext) (map[string]interface{}, bool) {
if labelsObject != nil && labelsObject[labelIdentifier] != nil {
resourceIdentifier := labelsObject[labelIdentifier].(string)
return applyResourceAccessControl(resourceObject, resourceIdentifier, context)
}
return resourceObject, true
}
// applyResourceAccessControl returns an optionally decorated object as the first return value and the
// access level for the user (granted or denied) as the second return value.
// Returns a decorated object and authorized access (true) when a resource control is found to the specified resource
// identifier and the user can access the resource.
// Returns the original object and authorized access (true) when no resource control is found for the specified
// resource identifier.
// Returns the original object and denied access (false) when a resource control is associated to the resource
// and the user cannot access the resource.
func applyResourceAccessControl(resourceObject map[string]interface{}, resourceIdentifier string,
context *restrictedOperationContext) (map[string]interface{}, bool) {
authorizedAccess := true
resourceControl := getResourceControlByResourceID(resourceIdentifier, context.resourceControls)
if resourceControl != nil {
if context.isAdmin || canUserAccessResource(context.userID, context.userTeamIDs, resourceControl) {
resourceObject = decorateObject(resourceObject, resourceControl)
} else {
authorizedAccess = false
}
}
return resourceObject, authorizedAccess
}
// decorateResourceWithAccessControlFromLabel will retrieve an identifier from the labels object. If an identifier exists,
// it will check for an existing resource control associated to it. If a resource control is found, the resource object will be
// decorated. If no identifier can be found in the labels or no resource control is associated to the identifier, the resource
// object will not be changed.
func decorateResourceWithAccessControlFromLabel(labelsObject, resourceObject map[string]interface{}, labelIdentifier string,
resourceControls []portainer.ResourceControl) map[string]interface{} {
if labelsObject != nil && labelsObject[labelIdentifier] != nil {
resourceIdentifier := labelsObject[labelIdentifier].(string)
resourceObject = decorateResourceWithAccessControl(resourceObject, resourceIdentifier, resourceControls)
}
return resourceObject
}
// decorateResourceWithAccessControl will check if a resource control is associated to the specified resource identifier.
// If a resource control is found, the resource object will be decorated, otherwise it will not be changed.
func decorateResourceWithAccessControl(resourceObject map[string]interface{}, resourceIdentifier string,
resourceControls []portainer.ResourceControl) map[string]interface{} {
resourceControl := getResourceControlByResourceID(resourceIdentifier, resourceControls)
if resourceControl != nil {
return decorateObject(resourceObject, resourceControl)
}
return resourceObject
}
func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool { func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.TeamID, resourceControl *portainer.ResourceControl) bool {
for _, authorizedUserAccess := range resourceControl.UserAccesses { for _, authorizedUserAccess := range resourceControl.UserAccesses {
if userID == authorizedUserAccess.UserID { if userID == authorizedUserAccess.UserID {
@ -19,3 +96,63 @@ func canUserAccessResource(userID portainer.UserID, userTeamIDs []portainer.Team
return false return false
} }
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["ResourceControl"] = resourceControl
object["Portainer"] = metadata
return object
}
func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
for _, resourceControl := range resourceControls {
if resourceID == resourceControl.ResourceID {
return &resourceControl
}
for _, subResourceID := range resourceControl.SubResourceIDs {
if resourceID == subResourceID {
return &resourceControl
}
}
}
return nil
}
// CanAccessStack checks if a user can access a stack
func CanAccessStack(stack *portainer.Stack, resourceControl *portainer.ResourceControl, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range memberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
if canUserAccessResource(userID, userTeamIDs, resourceControl) {
return true
}
return false
}
// FilterStacks filters stacks based on user role and resource controls.
func FilterStacks(stacks []portainer.Stack, resourceControls []portainer.ResourceControl, isAdmin bool,
userID portainer.UserID, memberships []portainer.TeamMembership) []ExtendedStack {
filteredStacks := make([]ExtendedStack, 0)
userTeamIDs := make([]portainer.TeamID, 0)
for _, membership := range memberships {
userTeamIDs = append(userTeamIDs, membership.TeamID)
}
for _, stack := range stacks {
extendedStack := ExtendedStack{stack, portainer.ResourceControl{}}
resourceControl := getResourceControlByResourceID(stack.Name, resourceControls)
if resourceControl == nil {
filteredStacks = append(filteredStacks, extendedStack)
} else if resourceControl != nil && (isAdmin || canUserAccessResource(userID, userTeamIDs, resourceControl)) {
extendedStack.ResourceControl = *resourceControl
filteredStacks = append(filteredStacks, extendedStack)
}
}
return filteredStacks
}

View File

@ -11,6 +11,7 @@ const (
ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found") ErrDockerContainerIdentifierNotFound = portainer.Error("Docker container identifier not found")
containerIdentifier = "Id" containerIdentifier = "Id"
containerLabelForServiceIdentifier = "com.docker.swarm.service.id" containerLabelForServiceIdentifier = "com.docker.swarm.service.id"
containerLabelForStackIdentifier = "com.docker.stack.namespace"
) )
// containerListOperation extracts the response as a JSON object, loop through the containers array // containerListOperation extracts the response as a JSON object, loop through the containers array
@ -27,8 +28,7 @@ func containerListOperation(request *http.Request, response *http.Response, exec
if executor.operationContext.isAdmin { if executor.operationContext.isAdmin {
responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls) responseArray, err = decorateContainerList(responseArray, executor.operationContext.resourceControls)
} else { } else {
responseArray, err = filterContainerList(responseArray, executor.operationContext.resourceControls, responseArray, err = filterContainerList(responseArray, executor.operationContext)
executor.operationContext.userID, executor.operationContext.userTeamIDs)
} }
if err != nil { if err != nil {
return err return err
@ -58,30 +58,22 @@ func containerInspectOperation(request *http.Request, response *http.Response, e
if responseObject[containerIdentifier] == nil { if responseObject[containerIdentifier] == nil {
return ErrDockerContainerIdentifierNotFound return ErrDockerContainerIdentifierNotFound
} }
containerID := responseObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, executor.operationContext.resourceControls) containerID := responseObject[containerIdentifier].(string)
if resourceControl != nil { responseObject, access := applyResourceAccessControl(responseObject, containerID, executor.operationContext)
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, if !access {
executor.operationContext.userTeamIDs, resourceControl) { return rewriteAccessDeniedResponse(response)
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
} }
containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject) containerLabels := extractContainerLabelsFromContainerInspectObject(responseObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil { responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForServiceIdentifier, executor.operationContext)
serviceID := containerLabels[containerLabelForServiceIdentifier].(string) if !access {
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls) return rewriteAccessDeniedResponse(response)
if resourceControl != nil { }
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID,
executor.operationContext.userTeamIDs, resourceControl) { responseObject, access = applyResourceAccessControlFromLabel(containerLabels, responseObject, containerLabelForStackIdentifier, executor.operationContext)
responseObject = decorateObject(responseObject, resourceControl) if !access {
} else { return rewriteAccessDeniedResponse(response)
return rewriteAccessDeniedResponse(response)
}
}
} }
return rewriteResponse(response, responseObject, http.StatusOK) return rewriteResponse(response, responseObject, http.StatusOK)
@ -106,3 +98,96 @@ func extractContainerLabelsFromContainerListObject(responseObject map[string]int
containerLabelsObject := extractJSONField(responseObject, "Labels") containerLabelsObject := extractJSONField(responseObject, "Labels")
return containerLabelsObject return containerLabelsObject
} }
// decorateContainerList loops through all containers and decorates any container with an existing resource control.
// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label).
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
containerObject = decorateResourceWithAccessControl(containerObject, containerID, resourceControls)
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, resourceControls)
containerObject = decorateResourceWithAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, resourceControls)
decoratedContainerData = append(decoratedContainerData, containerObject)
}
return decoratedContainerData, nil
}
// filterContainerList loops through all containers and filters public containers (no associated resource control)
// as well as authorized containers (access granted to the user based on existing resource control).
// Authorized containers are decorated during the process.
// Resource controls checks are based on: resource identifier, service identifier (from label), stack identifier (from label).
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func filterContainerList(containerData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
containerObject, access := applyResourceAccessControl(containerObject, containerID, context)
if access {
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForServiceIdentifier, context)
if access {
containerObject, access = applyResourceAccessControlFromLabel(containerLabels, containerObject, containerLabelForStackIdentifier, context)
if access {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
}
}
return filteredContainerData, nil
}
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
// any labels in the labels black list.
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil {
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
for key, value := range containerLabels {
labelName := key
labelValue := value.(string)
for _, blackListedLabel := range labelBlackList {
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
return true
}
}
}
return false
}

View File

@ -1,138 +0,0 @@
package proxy
import "github.com/portainer/portainer"
// decorateVolumeList loops through all volumes and will decorate any volume with an existing resource control.
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
if resourceControl != nil {
volumeObject = decorateObject(volumeObject, resourceControl)
}
decoratedVolumeData = append(decoratedVolumeData, volumeObject)
}
return decoratedVolumeData, nil
}
// decorateContainerList loops through all containers and will decorate any container with an existing resource control.
// Check is based on the container ID and optional Swarm service ID.
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func decorateContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
if resourceControl != nil {
containerObject = decorateObject(containerObject, resourceControl)
}
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl != nil {
containerObject = decorateObject(containerObject, resourceControl)
}
}
decoratedContainerData = append(decoratedContainerData, containerObject)
}
return decoratedContainerData, nil
}
// decorateServiceList loops through all services and will decorate any service with an existing resource control.
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl != nil {
serviceObject = decorateObject(serviceObject, resourceControl)
}
decoratedServiceData = append(decoratedServiceData, serviceObject)
}
return decoratedServiceData, nil
}
// decorateNetworkList loops through all networks and will decorate any network with an existing resource control.
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, resourceControls)
if resourceControl != nil {
networkObject = decorateObject(networkObject, resourceControl)
}
decoratedNetworkData = append(decoratedNetworkData, networkObject)
}
return decoratedNetworkData, nil
}
// decorateSecretList loops through all secrets and will decorate any secret with an existing resource control.
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, resourceControls)
if resourceControl != nil {
secretObject = decorateObject(secretObject, resourceControl)
}
decoratedSecretData = append(decoratedSecretData, secretObject)
}
return decoratedSecretData, nil
}
func decorateObject(object map[string]interface{}, resourceControl *portainer.ResourceControl) map[string]interface{} {
metadata := make(map[string]interface{})
metadata["ResourceControl"] = resourceControl
object["Portainer"] = metadata
return object
}

View File

@ -1,6 +1,7 @@
package proxy package proxy
import ( import (
"net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@ -56,3 +57,15 @@ func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReversePro
proxy.Transport = transport proxy.Transport = transport
return proxy return proxy
} }
func newSocketTransport(socketPath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
}
}
func newHTTPTransport() *http.Transport {
return &http.Transport{}
}

View File

@ -1,185 +0,0 @@
package proxy
import "github.com/portainer/portainer"
// filterVolumeList loops through all volumes, filters volumes without any resource control (public resources) or with
// any resource control giving access to the user (these volumes will be decorated).
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func filterVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, resourceControls)
if resourceControl == nil {
filteredVolumeData = append(filteredVolumeData, volumeObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
volumeObject = decorateObject(volumeObject, resourceControl)
filteredVolumeData = append(filteredVolumeData, volumeObject)
}
}
return filteredVolumeData, nil
}
// filterContainerList loops through all containers, filters containers without any resource control (public resources) or with
// any resource control giving access to the user (check on container ID and optional Swarm service ID, these containers will be decorated).
// Container object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ContainerList
func filterContainerList(containerData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
if containerObject[containerIdentifier] == nil {
return nil, ErrDockerContainerIdentifierNotFound
}
containerID := containerObject[containerIdentifier].(string)
resourceControl := getResourceControlByResourceID(containerID, resourceControls)
if resourceControl == nil {
// check if container is part of a Swarm service
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil && containerLabels[containerLabelForServiceIdentifier] != nil {
serviceID := containerLabels[containerLabelForServiceIdentifier].(string)
serviceResourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if serviceResourceControl == nil {
filteredContainerData = append(filteredContainerData, containerObject)
} else if serviceResourceControl != nil && canUserAccessResource(userID, userTeamIDs, serviceResourceControl) {
containerObject = decorateObject(containerObject, serviceResourceControl)
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
containerObject = decorateObject(containerObject, resourceControl)
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
// filterContainersWithLabels loops through a list of containers, and filters containers that do not contains
// any labels in the labels black list.
func filterContainersWithBlackListedLabels(containerData []interface{}, labelBlackList []portainer.Pair) ([]interface{}, error) {
filteredContainerData := make([]interface{}, 0)
for _, container := range containerData {
containerObject := container.(map[string]interface{})
containerLabels := extractContainerLabelsFromContainerListObject(containerObject)
if containerLabels != nil {
if !containerHasBlackListedLabel(containerLabels, labelBlackList) {
filteredContainerData = append(filteredContainerData, containerObject)
}
} else {
filteredContainerData = append(filteredContainerData, containerObject)
}
}
return filteredContainerData, nil
}
// filterServiceList loops through all services, filters services without any resource control (public resources) or with
// any resource control giving access to the user (these services will be decorated).
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func filterServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl == nil {
filteredServiceData = append(filteredServiceData, serviceObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
serviceObject = decorateObject(serviceObject, resourceControl)
filteredServiceData = append(filteredServiceData, serviceObject)
}
}
return filteredServiceData, nil
}
// filterNetworkList loops through all networks, filters networks without any resource control (public resources) or with
// any resource control giving access to the user (these networks will be decorated).
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func filterNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, resourceControls)
if resourceControl == nil {
filteredNetworkData = append(filteredNetworkData, networkObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
networkObject = decorateObject(networkObject, resourceControl)
filteredNetworkData = append(filteredNetworkData, networkObject)
}
}
return filteredNetworkData, nil
}
// filterSecretList loops through all secrets, filters secrets without any resource control (public resources) or with
// any resource control giving access to the user (these secrets will be decorated).
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func filterSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, resourceControls)
if resourceControl == nil {
filteredSecretData = append(filteredSecretData, secretObject)
} else if resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl) {
secretObject = decorateObject(secretObject, resourceControl)
filteredSecretData = append(filteredSecretData, secretObject)
}
}
return filteredSecretData, nil
}
// filterTaskList loops through all tasks, filters tasks without any resource control (public resources) or with
// any resource control giving access to the user based on the associated service identifier.
// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
func filterTaskList(taskData []interface{}, resourceControls []portainer.ResourceControl, userID portainer.UserID, userTeamIDs []portainer.TeamID) ([]interface{}, error) {
filteredTaskData := make([]interface{}, 0)
for _, task := range taskData {
taskObject := task.(map[string]interface{})
if taskObject[taskServiceIdentifier] == nil {
return nil, ErrDockerTaskServiceIdentifierNotFound
}
serviceID := taskObject[taskServiceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, resourceControls)
if resourceControl == nil || (resourceControl != nil && canUserAccessResource(userID, userTeamIDs, resourceControl)) {
filteredTaskData = append(filteredTaskData, taskObject)
}
}
return filteredTaskData, nil
}

View File

@ -10,6 +10,7 @@ const (
// ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier // ErrDockerNetworkIdentifierNotFound defines an error raised when Portainer is unable to find a network identifier
ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found") ErrDockerNetworkIdentifierNotFound = portainer.Error("Docker network identifier not found")
networkIdentifier = "Id" networkIdentifier = "Id"
networkLabelForStackIdentifier = "com.docker.stack.namespace"
) )
// networkListOperation extracts the response as a JSON object, loop through the networks array // networkListOperation extracts the response as a JSON object, loop through the networks array
@ -26,8 +27,7 @@ func networkListOperation(request *http.Request, response *http.Response, execut
if executor.operationContext.isAdmin { if executor.operationContext.isAdmin {
responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls) responseArray, err = decorateNetworkList(responseArray, executor.operationContext.resourceControls)
} else { } else {
responseArray, err = filterNetworkList(responseArray, executor.operationContext.resourceControls, responseArray, err = filterNetworkList(responseArray, executor.operationContext)
executor.operationContext.userID, executor.operationContext.userTeamIDs)
} }
if err != nil { if err != nil {
return err return err
@ -50,17 +50,85 @@ func networkInspectOperation(request *http.Request, response *http.Response, exe
if responseObject[networkIdentifier] == nil { if responseObject[networkIdentifier] == nil {
return ErrDockerNetworkIdentifierNotFound return ErrDockerNetworkIdentifierNotFound
} }
networkID := responseObject[networkIdentifier].(string)
resourceControl := getResourceControlByResourceID(networkID, executor.operationContext.resourceControls) networkID := responseObject[networkIdentifier].(string)
if resourceControl != nil { responseObject, access := applyResourceAccessControl(responseObject, networkID, executor.operationContext)
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, if !access {
executor.operationContext.userTeamIDs, resourceControl) { return rewriteAccessDeniedResponse(response)
responseObject = decorateObject(responseObject, resourceControl) }
} else {
return rewriteAccessDeniedResponse(response) networkLabels := extractNetworkLabelsFromNetworkInspectObject(responseObject)
} responseObject, access = applyResourceAccessControlFromLabel(networkLabels, responseObject, networkLabelForStackIdentifier, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
} }
return rewriteResponse(response, responseObject, http.StatusOK) return rewriteResponse(response, responseObject, http.StatusOK)
} }
// extractNetworkLabelsFromNetworkInspectObject retrieve the Labels of the network if present.
// Container schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkInspect
func extractNetworkLabelsFromNetworkInspectObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
return extractJSONField(responseObject, "Labels")
}
// extractNetworkLabelsFromNetworkListObject retrieve the Labels of the network if present.
// Network schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func extractNetworkLabelsFromNetworkListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
return extractJSONField(responseObject, "Labels")
}
// decorateNetworkList loops through all networks and decorates any network with an existing resource control.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func decorateNetworkList(networkData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
networkObject = decorateResourceWithAccessControl(networkObject, networkID, resourceControls)
networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject)
networkObject = decorateResourceWithAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, resourceControls)
decoratedNetworkData = append(decoratedNetworkData, networkObject)
}
return decoratedNetworkData, nil
}
// filterNetworkList loops through all networks and filters public networks (no associated resource control)
// as well as authorized networks (access granted to the user based on existing resource control).
// Authorized networks are decorated during the process.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Network object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/NetworkList
func filterNetworkList(networkData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredNetworkData := make([]interface{}, 0)
for _, network := range networkData {
networkObject := network.(map[string]interface{})
if networkObject[networkIdentifier] == nil {
return nil, ErrDockerNetworkIdentifierNotFound
}
networkID := networkObject[networkIdentifier].(string)
networkObject, access := applyResourceAccessControl(networkObject, networkID, context)
if access {
networkLabels := extractNetworkLabelsFromNetworkListObject(networkObject)
networkObject, access = applyResourceAccessControlFromLabel(networkLabels, networkObject, networkLabelForStackIdentifier, context)
if access {
filteredNetworkData = append(filteredNetworkData, networkObject)
}
}
}
return filteredNetworkData, nil
}

View File

@ -27,8 +27,7 @@ func secretListOperation(request *http.Request, response *http.Response, executo
if executor.operationContext.isAdmin { if executor.operationContext.isAdmin {
responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls) responseArray, err = decorateSecretList(responseArray, executor.operationContext.resourceControls)
} else { } else {
responseArray, err = filterSecretList(responseArray, executor.operationContext.resourceControls, responseArray, err = filterSecretList(responseArray, executor.operationContext)
executor.operationContext.userID, executor.operationContext.userTeamIDs)
} }
if err != nil { if err != nil {
return err return err
@ -51,17 +50,58 @@ func secretInspectOperation(request *http.Request, response *http.Response, exec
if responseObject[secretIdentifier] == nil { if responseObject[secretIdentifier] == nil {
return ErrDockerSecretIdentifierNotFound return ErrDockerSecretIdentifierNotFound
} }
secretID := responseObject[secretIdentifier].(string)
resourceControl := getResourceControlByResourceID(secretID, executor.operationContext.resourceControls) secretID := responseObject[secretIdentifier].(string)
if resourceControl != nil { responseObject, access := applyResourceAccessControl(responseObject, secretID, executor.operationContext)
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, if !access {
executor.operationContext.userTeamIDs, resourceControl) { return rewriteAccessDeniedResponse(response)
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
} }
return rewriteResponse(response, responseObject, http.StatusOK) return rewriteResponse(response, responseObject, http.StatusOK)
} }
// decorateSecretList loops through all secrets and decorates any secret with an existing resource control.
// Resource controls checks are based on: resource identifier.
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func decorateSecretList(secretData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
secretObject = decorateResourceWithAccessControl(secretObject, secretID, resourceControls)
decoratedSecretData = append(decoratedSecretData, secretObject)
}
return decoratedSecretData, nil
}
// filterSecretList loops through all secrets and filters public secrets (no associated resource control)
// as well as authorized secrets (access granted to the user based on existing resource control).
// Authorized secrets are decorated during the process.
// Resource controls checks are based on: resource identifier.
// Secret object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/SecretList
func filterSecretList(secretData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredSecretData := make([]interface{}, 0)
for _, secret := range secretData {
secretObject := secret.(map[string]interface{})
if secretObject[secretIdentifier] == nil {
return nil, ErrDockerSecretIdentifierNotFound
}
secretID := secretObject[secretIdentifier].(string)
secretObject, access := applyResourceAccessControl(secretObject, secretID, context)
if access {
filteredSecretData = append(filteredSecretData, secretObject)
}
}
return filteredSecretData, nil
}

View File

@ -1,64 +0,0 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier
ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found")
serviceIdentifier = "ID"
)
// serviceListOperation extracts the response as a JSON array, loop through the service array
// decorate and/or filter the services based on resource controls before rewriting the response
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterServiceList(responseArray, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs)
}
if err != nil {
return err
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// serviceInspectOperation extracts the response as a JSON object, verify that the user
// has access to the service based on resource control and either rewrite an access denied response
// or a decorated service.
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ServiceInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[serviceIdentifier] == nil {
return ErrDockerServiceIdentifierNotFound
}
serviceID := responseObject[serviceIdentifier].(string)
resourceControl := getResourceControlByResourceID(serviceID, executor.operationContext.resourceControls)
if resourceControl != nil {
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) {
responseObject = decorateObject(responseObject, resourceControl)
} else {
return rewriteAccessDeniedResponse(response)
}
}
return rewriteResponse(response, responseObject, http.StatusOK)
}

142
api/http/proxy/services.go Normal file
View File

@ -0,0 +1,142 @@
package proxy
import (
"net/http"
"github.com/portainer/portainer"
)
const (
// ErrDockerServiceIdentifierNotFound defines an error raised when Portainer is unable to find a service identifier
ErrDockerServiceIdentifierNotFound = portainer.Error("Docker service identifier not found")
serviceIdentifier = "ID"
serviceLabelForStackIdentifier = "com.docker.stack.namespace"
)
// serviceListOperation extracts the response as a JSON array, loop through the service array
// decorate and/or filter the services based on resource controls before rewriting the response
func serviceListOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
var err error
// ServiceList response is a JSON array
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
responseArray, err := getResponseAsJSONArray(response)
if err != nil {
return err
}
if executor.operationContext.isAdmin {
responseArray, err = decorateServiceList(responseArray, executor.operationContext.resourceControls)
} else {
responseArray, err = filterServiceList(responseArray, executor.operationContext)
}
if err != nil {
return err
}
return rewriteResponse(response, responseArray, http.StatusOK)
}
// serviceInspectOperation extracts the response as a JSON object, verify that the user
// has access to the service based on resource control and either rewrite an access denied response
// or a decorated service.
func serviceInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// ServiceInspect response is a JSON object
// https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
responseObject, err := getResponseAsJSONOBject(response)
if err != nil {
return err
}
if responseObject[serviceIdentifier] == nil {
return ErrDockerServiceIdentifierNotFound
}
serviceID := responseObject[serviceIdentifier].(string)
responseObject, access := applyResourceAccessControl(responseObject, serviceID, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
}
serviceLabels := extractServiceLabelsFromServiceInspectObject(responseObject)
responseObject, access = applyResourceAccessControlFromLabel(serviceLabels, responseObject, serviceLabelForStackIdentifier, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
}
return rewriteResponse(response, responseObject, http.StatusOK)
}
// extractServiceLabelsFromServiceInspectObject retrieve the Labels of the service if present.
// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceInspect
func extractServiceLabelsFromServiceInspectObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Spec.Labels
serviceSpecObject := extractJSONField(responseObject, "Spec")
if serviceSpecObject != nil {
return extractJSONField(serviceSpecObject, "Labels")
}
return nil
}
// extractServiceLabelsFromServiceListObject retrieve the Labels of the service if present.
// Service schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func extractServiceLabelsFromServiceListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Spec.Labels
serviceSpecObject := extractJSONField(responseObject, "Spec")
if serviceSpecObject != nil {
return extractJSONField(serviceSpecObject, "Labels")
}
return nil
}
// decorateServiceList loops through all services and decorates any service with an existing resource control.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func decorateServiceList(serviceData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
serviceObject = decorateResourceWithAccessControl(serviceObject, serviceID, resourceControls)
serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject)
serviceObject = decorateResourceWithAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, resourceControls)
decoratedServiceData = append(decoratedServiceData, serviceObject)
}
return decoratedServiceData, nil
}
// filterServiceList loops through all services and filters public services (no associated resource control)
// as well as authorized services (access granted to the user based on existing resource control).
// Authorized services are decorated during the process.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Service object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/ServiceList
func filterServiceList(serviceData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredServiceData := make([]interface{}, 0)
for _, service := range serviceData {
serviceObject := service.(map[string]interface{})
if serviceObject[serviceIdentifier] == nil {
return nil, ErrDockerServiceIdentifierNotFound
}
serviceID := serviceObject[serviceIdentifier].(string)
serviceObject, access := applyResourceAccessControl(serviceObject, serviceID, context)
if access {
serviceLabels := extractServiceLabelsFromServiceListObject(serviceObject)
serviceObject, access = applyResourceAccessControlFromLabel(serviceLabels, serviceObject, serviceLabelForStackIdentifier, context)
if access {
filteredServiceData = append(filteredServiceData, serviceObject)
}
}
}
return filteredServiceData, nil
}

View File

@ -10,6 +10,7 @@ const (
// ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task // ErrDockerTaskServiceIdentifierNotFound defines an error raised when Portainer is unable to find the service identifier associated to a task
ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found") ErrDockerTaskServiceIdentifierNotFound = portainer.Error("Docker task service identifier not found")
taskServiceIdentifier = "ServiceID" taskServiceIdentifier = "ServiceID"
taskLabelForStackIdentifier = "com.docker.stack.namespace"
) )
// taskListOperation extracts the response as a JSON object, loop through the tasks array // taskListOperation extracts the response as a JSON object, loop through the tasks array
@ -25,8 +26,7 @@ func taskListOperation(request *http.Request, response *http.Response, executor
} }
if !executor.operationContext.isAdmin { if !executor.operationContext.isAdmin {
responseArray, err = filterTaskList(responseArray, executor.operationContext.resourceControls, responseArray, err = filterTaskList(responseArray, executor.operationContext)
executor.operationContext.userID, executor.operationContext.userTeamIDs)
if err != nil { if err != nil {
return err return err
} }
@ -34,3 +34,45 @@ func taskListOperation(request *http.Request, response *http.Response, executor
return rewriteResponse(response, responseArray, http.StatusOK) return rewriteResponse(response, responseArray, http.StatusOK)
} }
// extractTaskLabelsFromTaskListObject retrieve the Labels of the task if present.
// Task schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
func extractTaskLabelsFromTaskListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Spec.ContainerSpec.Labels
taskSpecObject := extractJSONField(responseObject, "Spec")
if taskSpecObject != nil {
containerSpecObject := extractJSONField(taskSpecObject, "ContainerSpec")
if containerSpecObject != nil {
return extractJSONField(containerSpecObject, "Labels")
}
}
return nil
}
// filterTaskList loops through all tasks and filters public tasks (no associated resource control)
// as well as authorized tasks (access granted to the user based on existing resource control).
// Resource controls checks are based on: service identifier, stack identifier (from label).
// Task object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/TaskList
// any resource control giving access to the user based on the associated service identifier.
func filterTaskList(taskData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredTaskData := make([]interface{}, 0)
for _, task := range taskData {
taskObject := task.(map[string]interface{})
if taskObject[taskServiceIdentifier] == nil {
return nil, ErrDockerTaskServiceIdentifierNotFound
}
serviceID := taskObject[taskServiceIdentifier].(string)
taskObject, access := applyResourceAccessControl(taskObject, serviceID, context)
if access {
taskLabels := extractTaskLabelsFromTaskListObject(taskObject)
taskObject, access = applyResourceAccessControlFromLabel(taskLabels, taskObject, taskLabelForStackIdentifier, context)
if access {
filteredTaskData = append(filteredTaskData, taskObject)
}
}
}
return filteredTaskData, nil
}

View File

@ -1,7 +1,6 @@
package proxy package proxy
import ( import (
"net"
"net/http" "net/http"
"path" "path"
"strings" "strings"
@ -30,18 +29,6 @@ type (
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
) )
func newSocketTransport(socketPath string) *http.Transport {
return &http.Transport{
Dial: func(proto, addr string) (conn net.Conn, err error) {
return net.Dial("unix", socketPath)
},
}
}
func newHTTPTransport() *http.Transport {
return &http.Transport{}
}
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) { func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
return p.proxyDockerRequest(request) return p.proxyDockerRequest(request)
} }
@ -202,7 +189,13 @@ func (p *proxyTransport) proxyNodeRequest(request *http.Request) (*http.Response
} }
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) { func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
return p.administratorOperation(request) switch requestPath := request.URL.Path; requestPath {
case "/swarm":
return p.executeDockerRequest(request)
default:
// assume /swarm/{action}
return p.administratorOperation(request)
}
} }
func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) { func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response, error) {

View File

@ -1,32 +0,0 @@
package proxy
import "github.com/portainer/portainer"
func getResourceControlByResourceID(resourceID string, resourceControls []portainer.ResourceControl) *portainer.ResourceControl {
for _, resourceControl := range resourceControls {
if resourceID == resourceControl.ResourceID {
return &resourceControl
}
for _, subResourceID := range resourceControl.SubResourceIDs {
if resourceID == subResourceID {
return &resourceControl
}
}
}
return nil
}
func containerHasBlackListedLabel(containerLabels map[string]interface{}, labelBlackList []portainer.Pair) bool {
for key, value := range containerLabels {
labelName := key
labelValue := value.(string)
for _, blackListedLabel := range labelBlackList {
if blackListedLabel.Name == labelName && blackListedLabel.Value == labelValue {
return true
}
}
}
return false
}

View File

@ -10,6 +10,7 @@ const (
// ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier // ErrDockerVolumeIdentifierNotFound defines an error raised when Portainer is unable to find a volume identifier
ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found") ErrDockerVolumeIdentifierNotFound = portainer.Error("Docker volume identifier not found")
volumeIdentifier = "Name" volumeIdentifier = "Name"
volumeLabelForStackIdentifier = "com.docker.stack.namespace"
) )
// volumeListOperation extracts the response as a JSON object, loop through the volume array // volumeListOperation extracts the response as a JSON object, loop through the volume array
@ -31,7 +32,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
if executor.operationContext.isAdmin { if executor.operationContext.isAdmin {
volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls) volumeData, err = decorateVolumeList(volumeData, executor.operationContext.resourceControls)
} else { } else {
volumeData, err = filterVolumeList(volumeData, executor.operationContext.resourceControls, executor.operationContext.userID, executor.operationContext.userTeamIDs) volumeData, err = filterVolumeList(volumeData, executor.operationContext)
} }
if err != nil { if err != nil {
return err return err
@ -45,7 +46,7 @@ func volumeListOperation(request *http.Request, response *http.Response, executo
} }
// volumeInspectOperation extracts the response as a JSON object, verify that the user // volumeInspectOperation extracts the response as a JSON object, verify that the user
// has access to the volume based on resource control and either rewrite an access denied response // has access to the volume based on any existing resource control and either rewrite an access denied response
// or a decorated volume. // or a decorated volume.
func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error { func volumeInspectOperation(request *http.Request, response *http.Response, executor *operationExecutor) error {
// VolumeInspect response is a JSON object // VolumeInspect response is a JSON object
@ -58,16 +59,85 @@ func volumeInspectOperation(request *http.Request, response *http.Response, exec
if responseObject[volumeIdentifier] == nil { if responseObject[volumeIdentifier] == nil {
return ErrDockerVolumeIdentifierNotFound return ErrDockerVolumeIdentifierNotFound
} }
volumeID := responseObject[volumeIdentifier].(string)
resourceControl := getResourceControlByResourceID(volumeID, executor.operationContext.resourceControls) volumeID := responseObject[volumeIdentifier].(string)
if resourceControl != nil { responseObject, access := applyResourceAccessControl(responseObject, volumeID, executor.operationContext)
if executor.operationContext.isAdmin || canUserAccessResource(executor.operationContext.userID, executor.operationContext.userTeamIDs, resourceControl) { if !access {
responseObject = decorateObject(responseObject, resourceControl) return rewriteAccessDeniedResponse(response)
} else { }
return rewriteAccessDeniedResponse(response)
} volumeLabels := extractVolumeLabelsFromVolumeInspectObject(responseObject)
responseObject, access = applyResourceAccessControlFromLabel(volumeLabels, responseObject, volumeLabelForStackIdentifier, executor.operationContext)
if !access {
return rewriteAccessDeniedResponse(response)
} }
return rewriteResponse(response, responseObject, http.StatusOK) return rewriteResponse(response, responseObject, http.StatusOK)
} }
// extractVolumeLabelsFromVolumeInspectObject retrieve the Labels of the volume if present.
// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeInspect
func extractVolumeLabelsFromVolumeInspectObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
return extractJSONField(responseObject, "Labels")
}
// extractVolumeLabelsFromVolumeListObject retrieve the Labels of the volume if present.
// Volume schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func extractVolumeLabelsFromVolumeListObject(responseObject map[string]interface{}) map[string]interface{} {
// Labels are stored under Labels
return extractJSONField(responseObject, "Labels")
}
// decorateVolumeList loops through all volumes and decorates any volume with an existing resource control.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func decorateVolumeList(volumeData []interface{}, resourceControls []portainer.ResourceControl) ([]interface{}, error) {
decoratedVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
volumeObject = decorateResourceWithAccessControl(volumeObject, volumeID, resourceControls)
volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject)
volumeObject = decorateResourceWithAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, resourceControls)
decoratedVolumeData = append(decoratedVolumeData, volumeObject)
}
return decoratedVolumeData, nil
}
// filterVolumeList loops through all volumes and filters public volumes (no associated resource control)
// as well as authorized volumes (access granted to the user based on existing resource control).
// Authorized volumes are decorated during the process.
// Resource controls checks are based on: resource identifier, stack identifier (from label).
// Volume object schema reference: https://docs.docker.com/engine/api/v1.28/#operation/VolumeList
func filterVolumeList(volumeData []interface{}, context *restrictedOperationContext) ([]interface{}, error) {
filteredVolumeData := make([]interface{}, 0)
for _, volume := range volumeData {
volumeObject := volume.(map[string]interface{})
if volumeObject[volumeIdentifier] == nil {
return nil, ErrDockerVolumeIdentifierNotFound
}
volumeID := volumeObject[volumeIdentifier].(string)
volumeObject, access := applyResourceAccessControl(volumeObject, volumeID, context)
if access {
volumeLabels := extractVolumeLabelsFromVolumeListObject(volumeObject)
volumeObject, access = applyResourceAccessControlFromLabel(volumeLabels, volumeObject, volumeLabelForStackIdentifier, context)
if access {
filteredVolumeData = append(filteredVolumeData, volumeObject)
}
}
}
return filteredVolumeData, nil
}

View File

@ -27,7 +27,10 @@ type Server struct {
FileService portainer.FileService FileService portainer.FileService
RegistryService portainer.RegistryService RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
StackService portainer.StackService
StackManager portainer.StackManager
LDAPService portainer.LDAPService LDAPService portainer.LDAPService
GitService portainer.GitService
Handler *handler.Handler Handler *handler.Handler
SSL bool SSL bool
SSLCert string SSLCert string
@ -39,6 +42,7 @@ func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled) requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService) proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
var fileHandler = handler.NewFileHandler(server.AssetsPath)
var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled) var authHandler = handler.NewAuthHandler(requestBouncer, server.AuthDisabled)
authHandler.UserService = server.UserService authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService authHandler.CryptoService = server.CryptoService
@ -82,7 +86,13 @@ func (server *Server) Start() error {
resourceHandler.ResourceControlService = server.ResourceControlService resourceHandler.ResourceControlService = server.ResourceControlService
var uploadHandler = handler.NewUploadHandler(requestBouncer) var uploadHandler = handler.NewUploadHandler(requestBouncer)
uploadHandler.FileService = server.FileService uploadHandler.FileService = server.FileService
var fileHandler = handler.NewFileHandler(server.AssetsPath) var stackHandler = handler.NewStackHandler(requestBouncer)
stackHandler.FileService = server.FileService
stackHandler.StackService = server.StackService
stackHandler.EndpointService = server.EndpointService
stackHandler.ResourceControlService = server.ResourceControlService
stackHandler.StackManager = server.StackManager
stackHandler.GitService = server.GitService
server.Handler = &handler.Handler{ server.Handler = &handler.Handler{
AuthHandler: authHandler, AuthHandler: authHandler,
@ -95,6 +105,7 @@ func (server *Server) Start() error {
ResourceHandler: resourceHandler, ResourceHandler: resourceHandler,
SettingsHandler: settingsHandler, SettingsHandler: settingsHandler,
StatusHandler: statusHandler, StatusHandler: statusHandler,
StackHandler: stackHandler,
TemplatesHandler: templatesHandler, TemplatesHandler: templatesHandler,
DockerHandler: dockerHandler, DockerHandler: dockerHandler,
WebSocketHandler: websocketHandler, WebSocketHandler: websocketHandler,

View File

@ -128,6 +128,18 @@ type (
Role UserRole Role UserRole
} }
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier).
StackID string
// Stack represents a Docker stack created via docker stack deploy.
Stack struct {
ID StackID `json:"Id"`
Name string `json:"Name"`
EntryPoint string `json:"EntryPoint"`
SwarmID string `json:"SwarmId"`
ProjectPath string
}
// RegistryID represents a registry identifier. // RegistryID represents a registry identifier.
RegistryID int RegistryID int
@ -193,7 +205,7 @@ type (
AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"` AccessLevel ResourceAccessLevel `json:"AccessLevel,omitempty"`
} }
// ResourceControlType represents the type of resource associated to the resource control (volume, container, service). // ResourceControlType represents the type of resource associated to the resource control (volume, container, service...).
ResourceControlType int ResourceControlType int
// UserResourceAccess represents the level of control on a resource for a specific user. // UserResourceAccess represents the level of control on a resource for a specific user.
@ -286,6 +298,16 @@ type (
DeleteRegistry(ID RegistryID) error DeleteRegistry(ID RegistryID) error
} }
// StackService represents a service for managing stack data.
StackService interface {
Stack(ID StackID) (*Stack, error)
Stacks() ([]Stack, error)
StacksBySwarmID(ID string) ([]Stack, error)
CreateStack(stack *Stack) error
UpdateStack(ID StackID, stack *Stack) error
DeleteStack(ID StackID) error
}
// DockerHubService represents a service for managing the DockerHub object. // DockerHubService represents a service for managing the DockerHub object.
DockerHubService interface { DockerHubService interface {
DockerHub() (*DockerHub, error) DockerHub() (*DockerHub, error)
@ -328,10 +350,20 @@ type (
// FileService represents a service for managing files. // FileService represents a service for managing files.
FileService interface { FileService interface {
GetFileContent(filePath string) (string, error)
RemoveDirectory(directoryPath string) error
StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error
GetPathForTLSFile(folder string, fileType TLSFileType) (string, error) GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
DeleteTLSFile(folder string, fileType TLSFileType) error DeleteTLSFile(folder string, fileType TLSFileType) error
DeleteTLSFiles(folder string) error DeleteTLSFiles(folder string) error
GetStackProjectPath(stackIdentifier string) string
StoreStackFileFromString(stackIdentifier string, stackFileContent string) (string, error)
StoreStackFileFromReader(stackIdentifier string, r io.Reader) (string, error)
}
// GitService represents a service for managing Git.
GitService interface {
CloneRepository(url, destination string) error
} }
// EndpointWatcher represents a service to synchronize the endpoints via an external source. // EndpointWatcher represents a service to synchronize the endpoints via an external source.
@ -344,6 +376,12 @@ type (
AuthenticateUser(username, password string, settings *LDAPSettings) error AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error TestConnectivity(settings *LDAPSettings) error
} }
// StackManager represents a service to manage stacks.
StackManager interface {
Deploy(stack *Stack, endpoint *Endpoint) error
Remove(stack *Stack, endpoint *Endpoint) error
}
) )
const ( const (
@ -406,4 +444,6 @@ const (
NetworkResourceControl NetworkResourceControl
// SecretResourceControl represents a resource control associated to a Docker secret // SecretResourceControl represents a resource control associated to a Docker secret
SecretResourceControl SecretResourceControl
// StackResourceControl represents a resource control associated to a stack composed of Docker services
StackResourceControl
) )

View File

@ -28,6 +28,7 @@ angular.module('portainer', [
'createSecret', 'createSecret',
'createService', 'createService',
'createVolume', 'createVolume',
'createStack',
'engine', 'engine',
'endpoint', 'endpoint',
'endpointAccess', 'endpointAccess',
@ -51,6 +52,8 @@ angular.module('portainer', [
'settings', 'settings',
'settingsAuthentication', 'settingsAuthentication',
'sidebar', 'sidebar',
'stack',
'stacks',
'swarm', 'swarm',
'swarmVisualizer', 'swarmVisualizer',
'task', 'task',

View File

@ -49,52 +49,59 @@
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" /> <input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th> </th>
<th> <th>
<a ui-sref="containers" ng-click="order('Status')"> <a ng-click="order('Status')">
State State
<span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Status' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Status' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="containers" ng-click="order('Names')"> <a ng-click="order('Names')">
Name Name
<span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Names' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Names' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
<a data-toggle="tooltip" title="More" ng-click="truncateMore();" ng-show="showMore"> <a data-toggle="tooltip" title="More" ng-click="truncateMore();" ng-show="showMore">
<i class="fa fa-plus-square" aria-hidden="true"></i> <i class="fa fa-plus-square" aria-hidden="true"></i>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="containers" ng-click="order('Image')"> <a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Image')">
Image Image
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th ng-if="state.displayIP"> <th ng-if="state.displayIP">
<a ui-sref="containers" ng-click="order('IP')"> <a ng-click="order('IP')">
IP Address IP Address
<span ng-show="sortType == 'IP' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'IP' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'IP' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"> <th ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
<a ui-sref="containers" ng-click="order('Host')"> <a ng-click="order('Host')">
Host IP Host IP
<span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Host' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Host' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Host' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="containers" ng-click="order('Ports')"> <a ng-click="order('Ports')">
Published Ports Published Ports
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th ng-if="applicationState.application.authentication"> <th ng-if="applicationState.application.authentication">
<a ui-sref="containers" ng-click="order('ResourceControl.Ownership')"> <a ng-click="order('ResourceControl.Ownership')">
Ownership Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
@ -111,6 +118,7 @@
</td> </td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: truncate_size}}</a></td> <td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|swarmcontainername|truncate: truncate_size}}</a></td>
<td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: truncate_size}}</a></td> <td ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM'"><a ui-sref="container({id: container.Id})">{{ container|containername|truncate: truncate_size}}</a></td>
<td>{{ container.StackName ? container.StackName : '-' }}</td>
<td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td> <td><a ui-sref="image({id: container.Image})">{{ container.Image | hideshasum }}</a></td>
<td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td> <td ng-if="state.displayIP">{{ container.IP ? container.IP : '-' }}</td>
<td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td> <td ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">{{ container.hostIP }}</td>

View File

@ -0,0 +1,119 @@
angular.module('createStack', [])
.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService',
function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService) {
// Store the editor content when switching builder methods
var editorContent = '';
var editorEnabled = true;
$scope.formValues = {
Name: '',
StackFileContent: '# Define or paste the content of your docker-compose file here',
StackFile: null,
RepositoryURL: '',
RepositoryPath: 'docker-compose.yml',
AccessControlData: new AccessControlFormData()
};
$scope.state = {
Method: 'editor',
formValidationError: ''
};
function validateForm(accessControlData, isAdmin) {
$scope.state.formValidationError = '';
var error = '';
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
$scope.state.formValidationError = error;
return false;
}
return true;
}
function createStack(name) {
var method = $scope.state.Method;
if (method === 'editor') {
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var stackFileContent = $scope.editor.getValue();
return StackService.createStackFromFileContent(name, stackFileContent);
} else if (method === 'upload') {
var stackFile = $scope.formValues.StackFile;
return StackService.createStackFromFileUpload(name, stackFile);
} else if (method === 'repository') {
var gitRepository = $scope.formValues.RepositoryURL;
var pathInRepository = $scope.formValues.RepositoryPath;
return StackService.createStackFromGitRepository(name, gitRepository, pathInRepository);
}
}
$scope.deployStack = function () {
$('#createResourceSpinner').show();
var name = $scope.formValues.Name;
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
var userId = userDetails.ID;
if (!validateForm(accessControlData, isAdmin)) {
$('#createResourceSpinner').hide();
return;
}
createStack(name)
.then(function success(data) {
Notifications.success('Stack successfully deployed');
})
.catch(function error(err) {
Notifications.warning('Deployment error', err.err.data.err);
})
.then(function success(data) {
return ResourceControlService.applyResourceControl('stack', name, userId, accessControlData, []);
})
.then(function success() {
$state.go('stacks');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to apply resource control on the stack');
})
.finally(function final() {
$('#createResourceSpinner').hide();
});
};
function enableEditor(value) {
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
if (value) {
$scope.editor.setValue(value);
}
}
});
}
$scope.toggleEditor = function() {
if (!editorEnabled) {
enableEditor(editorContent);
editorEnabled = true;
}
};
$scope.saveEditorContent = function() {
editorContent = $scope.editor.getValue();
editorEnabled = false;
};
function initView() {
enableEditor();
}
initView();
}]);

View File

@ -0,0 +1,156 @@
<rd-header>
<rd-header-title title="Create stack">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="display: none;"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="stacks">Stacks</a> > Add stack
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="stack_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="formValues.Name" id="stack_name" placeholder="e.g. myStack" auto-focus>
</div>
</div>
<!-- !name-input -->
<div class="form-group">
<span class="col-sm-12 text-muted small">
This stack will be deployed using the equivalent of the <code>docker stack deploy</code> command.
</span>
</div>
<!-- build-method -->
<div class="col-sm-12 form-section-title">
Build method
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.Method" value="editor" ng-click="toggleEditor(state.Method)">
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="state.Method" value="upload" ng-click="saveEditorContent()">
<label for="method_upload">
<div class="boxselector_header">
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
<div>
<input type="radio" id="method_repository" ng-model="state.Method" value="repository" ng-click="saveEditorContent()">
<label for="method_repository">
<div class="boxselector_header">
<i class="fa fa-git" aria-hidden="true" style="margin-right: 2px;"></i>
Repository
</div>
<p>Use a git repository</p>
</label>
</div>
</div>
</div>
<!-- !build-method -->
<!-- web-editor -->
<div ng-if="state.Method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<textarea id="web-editor" class="form-control" ng-model="formValues.StackFileContent" placeholder='version: "3"'></textarea>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-if="state.Method === 'upload'">
<div class="col-sm-12 form-section-title">
Upload
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can upload a Compose file from your computer.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.StackFile">Select file</button>
<span style="margin-left: 5px;">
{{ formValues.StackFile.name }}
<i class="fa fa-times red-icon" ng-if="!formValues.StackFile" aria-hidden="true"></i>
</span>
</div>
</div>
</div>
<!-- !upload -->
<!-- repository -->
<div ng-if="state.Method === 'repository'">
<div class="col-sm-12 form-section-title">
Git repository
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can use the URL of a public git repository.
</span>
</div>
<div class="form-group">
<label for="stack_repository_url" class="col-sm-2 control-label text-left">Repository URL</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.RepositoryURL" id="stack_repository_url" placeholder="https://github.com/portainer/portainer-compose">
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Indicate the path to the Compose file from the root of your repository.
</span>
</div>
<div class="form-group">
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.RepositoryPath" id="stack_repository_path" placeholder="docker-compose.yml">
</div>
</div>
</div>
<!-- !repository -->
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- 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.Method === 'editor' && !formValues.StackFileContent)
|| (state.Method === 'upload' && !formValues.StackFile)
|| (state.Method === 'repository' && (!formValues.RepositoryURL || !formValues.RepositoryPath))
|| !formValues.Name" ng-click="deployStack()">Deploy the stack</button>
<a type="button" class="btn btn-default btn-sm" ui-sref="stacks">Cancel</a>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<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>

View File

@ -85,6 +85,32 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<a ui-sref="stacks">
<rd-widget>
<rd-widget-body>
<div class="widget-icon blue pull-left">
<i class="fa fa-th-list"></i>
</div>
<div class="title">{{ stackCount }}</div>
<div class="comment">Stacks</div>
</rd-widget-body>
</rd-widget>
</a>
</div>
<div class="col-xs-12 col-md-6" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<a ui-sref="services">
<rd-widget>
<rd-widget-body>
<div class="widget-icon blue pull-left">
<i class="fa fa-list-alt"></i>
</div>
<div class="title">{{ serviceCount }}</div>
<div class="comment">Services</div>
</rd-widget-body>
</rd-widget>
</a>
</div>
<div class="col-xs-12 col-md-6"> <div class="col-xs-12 col-md-6">
<a ui-sref="containers"> <a ui-sref="containers">
<rd-widget> <rd-widget>

View File

@ -1,6 +1,6 @@
angular.module('dashboard', []) angular.module('dashboard', [])
.controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'Notifications', .controller('DashboardController', ['$scope', '$q', 'Container', 'ContainerHelper', 'Image', 'Network', 'Volume', 'SystemService', 'ServiceService', 'StackService', 'Notifications',
function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, Notifications) { function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, SystemService, ServiceService, StackService, Notifications) {
$scope.containerData = { $scope.containerData = {
total: 0 total: 0
@ -15,6 +15,9 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
total: 0 total: 0
}; };
$scope.serviceCount = 0;
$scope.stackCount = 0;
function prepareContainerData(d) { function prepareContainerData(d) {
var running = 0; var running = 0;
var stopped = 0; var stopped = 0;
@ -63,18 +66,25 @@ function ($scope, $q, Container, ContainerHelper, Image, Network, Volume, System
function initView() { function initView() {
$('#loadingViewSpinner').show(); $('#loadingViewSpinner').show();
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
$q.all([ $q.all([
Container.query({all: 1}).$promise, Container.query({all: 1}).$promise,
Image.query({}).$promise, Image.query({}).$promise,
Volume.query({}).$promise, Volume.query({}).$promise,
Network.query({}).$promise, Network.query({}).$promise,
SystemService.info() SystemService.info(),
endpointProvider === 'DOCKER_SWARM_MODE' ? ServiceService.services() : [],
endpointProvider === 'DOCKER_SWARM_MODE' ? StackService.stacks(true) : []
]).then(function (d) { ]).then(function (d) {
prepareContainerData(d[0]); prepareContainerData(d[0]);
prepareImageData(d[1]); prepareImageData(d[1]);
prepareVolumeData(d[2]); prepareVolumeData(d[2]);
prepareNetworkData(d[3]); prepareNetworkData(d[3]);
prepareInfoData(d[4]); prepareInfoData(d[4]);
$scope.serviceCount = d[5].length;
$scope.stackCount = d[6].length;
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();
}, function(e) { }, function(e) {
$('#loadingViewSpinner').hide(); $('#loadingViewSpinner').hide();

View File

@ -48,10 +48,10 @@
</a> </a>
</th> </th>
<th> <th>
<a ng-click="order('Id')"> <a ng-click="order('StackName')">
Id Stack
<span ng-show="sortType == 'Id' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
@ -101,8 +101,8 @@
<tbody> <tbody>
<tr dir-paginate="network in ( state.filteredNetworks = (networks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))"> <tr dir-paginate="network in ( state.filteredNetworks = (networks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="network.Checked" ng-change="selectItem(network)"/></td> <td><input type="checkbox" ng-model="network.Checked" ng-change="selectItem(network)"/></td>
<td><a ui-sref="network({id: network.Id})">{{ network.Name|truncate:40}}</a></td> <td><a ui-sref="network({id: network.Id})">{{ network.Name | truncate:40 }}</a></td>
<td class="monospaced">{{ network.Id|truncate:20 }}</td> <td>{{ network.StackName ? network.StackName : '-' }}</td>
<td>{{ network.Scope }}</td> <td>{{ network.Scope }}</td>
<td>{{ network.Driver }}</td> <td>{{ network.Driver }}</td>
<td>{{ network.IPAM.Driver }}</td> <td>{{ network.IPAM.Driver }}</td>

View File

@ -321,7 +321,7 @@ function ($q, $scope, $transition$, $state, $location, $timeout, $anchorScroll,
originalService = angular.copy(service); originalService = angular.copy(service);
return $q.all({ return $q.all({
tasks: TaskService.serviceTasks(service.Name), tasks: TaskService.tasks({ service: [service.Name] }),
nodes: NodeService.nodes(), nodes: NodeService.nodes(),
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [] secrets: apiVersion >= 1.25 ? SecretService.secrets() : []
}); });

View File

@ -38,42 +38,49 @@
<thead> <thead>
<th></th> <th></th>
<th> <th>
<a ui-sref="services" ng-click="order('Name')"> <a ng-click="order('Name')">
Name Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="services" ng-click="order('Image')"> <a ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('Image')">
Image Image
<span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Image' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Image' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="services" ng-click="order('Mode')"> <a ng-click="order('Mode')">
Scheduling mode Scheduling mode
<span ng-show="sortType == 'Mode' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Mode' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Mode' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Mode' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="services" ng-click="order('Ports')"> <a ng-click="order('Ports')">
Published Ports Published Ports
<span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'Ports' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Ports' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th> <th>
<a ui-sref="services" ng-click="order('UpdatedAt')"> <a ng-click="order('UpdatedAt')">
Updated at Updated at
<span ng-show="sortType == 'UpdatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'UpdatedAt' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'UpdatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'UpdatedAt' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th ng-if="applicationState.application.authentication"> <th ng-if="applicationState.application.authentication">
<a ui-sref="services" ng-click="order('ResourceControl.Ownership')"> <a ng-click="order('ResourceControl.Ownership')">
Ownership Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span> <span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
@ -84,6 +91,7 @@
<tr dir-paginate="service in (state.filteredServices = ( services | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))"> <tr dir-paginate="service in (state.filteredServices = ( services | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="service.Checked" ng-change="selectItem(service)"/></td> <td><input type="checkbox" ng-model="service.Checked" ng-change="selectItem(service)"/></td>
<td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td> <td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td>
<td>{{ service.StackName ? service.StackName : '-' }}</td>
<td>{{ service.Image | hideshasum }}</td> <td>{{ service.Image | hideshasum }}</td>
<td> <td>
{{ service.Mode }} {{ service.Mode }}

View File

@ -25,6 +25,9 @@
<a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a> <a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a>
</div> </div>
</li> </li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"> <li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt"></span></a> <a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt"></span></a>
</li> </li>

View File

@ -0,0 +1,54 @@
<rd-header>
<rd-header-title title="Stack details">
<a data-toggle="tooltip" title="Refresh" ui-sref="stack({id: stack.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="stacks">Stacks</a> > <a ui-sref="stack({id: stack.Id})">{{ stack.Name }}</a>
</rd-header-content>
</rd-header>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="stack && applicationState.application.authentication"
resource-id="stack.Name"
resource-control="stack.ResourceControl"
resource-type="'stack'">
</por-access-control-panel>
<!-- !access-control-panel -->
<por-service-list services="services" nodes="nodes"></por-service-list>
<por-task-list tasks="tasks" nodes="nodes"></por-task-list>
<div class="row" ng-if="stackFileContent">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-pencil" title="Stack editor"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can get more information about Compose file format in the <a href="https://docs.docker.com/compose/compose-file/" target="_blank">official documentation</a>.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<textarea id="web-editor" class="form-control" ng-model="stackFileContent" placeholder='version: "3"'></textarea>
</div>
</div>
<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-sm btn-primary" ng-click="deployStack()">Update stack</button>
<i id="createResourceSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,78 @@
angular.module('stack', [])
.controller('StackController', ['$q', '$scope', '$state', '$stateParams', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications',
function ($q, $scope, $state, $stateParams, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications) {
$scope.deployStack = function () {
$('#createResourceSpinner').show();
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var stackFile = $scope.editor.getValue();
StackService.updateStack($scope.stack.Id, stackFile)
.then(function success(data) {
Notifications.success('Stack successfully deployed');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create stack');
})
.finally(function final() {
$('#createResourceSpinner').hide();
});
};
function initView() {
$('#loadingViewSpinner').show();
var stackId = $stateParams.id;
StackService.stack(stackId)
.then(function success(data) {
var stack = data;
$scope.stack = stack;
var serviceFilters = {
label: ['com.docker.stack.namespace=' + stack.Name]
};
return $q.all({
stackFile: StackService.getStackFile(stackId),
services: ServiceService.services(serviceFilters),
tasks: TaskService.tasks(serviceFilters),
nodes: NodeService.nodes()
});
})
.then(function success(data) {
$scope.stackFileContent = data.stackFile;
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement);
}
});
$scope.nodes = data.nodes;
var services = data.services;
var tasks = data.tasks;
$scope.tasks = tasks;
for (var i = 0; i < services.length; i++) {
var service = services[i];
ServiceHelper.associateTasksToService(service, tasks);
}
$scope.services = services;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve tasks details');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -0,0 +1,120 @@
<rd-header>
<rd-header-title title="Stacks list">
<a data-toggle="tooltip" title="Refresh" ui-sref="stacks" ui-sref-opts="{reload: true}">
<i class="fa fa-refresh" aria-hidden="true"></i>
</a>
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>Stacks</rd-header-content>
</rd-header>
<div class="row" ng-if="state.DisplayInformationPanel">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-info-circle" title="Information"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<span class="col-sm-12 text-muted small">
Stacks marked with the <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true"></i> icon are external stacks that were created outside of Portainer. You'll not be able to execute any actions against these stacks.
</span>
</div>
<div class="col-sm-12 form-section-title">
Filters
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">
Display external stacks
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="state.DisplayExternalStacks"><i></i>
</label>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-th-list" title="Stacks">
<div class="pull-right">
Items per page:
<select ng-model="state.pagination_count" ng-change="changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-lg-12 col-md-12 col-xs-12">
<div class="pull-left">
<button type="button" class="btn btn-danger" ng-click="removeAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<a class="btn btn-primary" type="button" ui-sref="actions.create.stack"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add stack</a>
</div>
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<th>
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="applicationState.application.authentication">
<a ng-click="order('ResourceControl.Ownership')">
Ownership
<span ng-show="sortType == 'ResourceControl.Ownership' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'ResourceControl.Ownership' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</thead>
<tbody>
<tr dir-paginate="stack in (state.filteredStacks = ( stacks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))" ng-if="state.DisplayExternalStacks || (!state.DisplayExternalStacks && !stack.External)">
<td><input type="checkbox" ng-model="stack.Checked" ng-change="selectItem(stack)" ng-disabled="!stack.Id"/></td>
<td>
<span ng-if="stack.Id">
<a ui-sref="stack({ id: stack.Id })">{{ stack.Name }}</a>
</span>
<span ng-if="!stack.Id">
{{ stack.Name }} <i class="fa fa-exclamation-circle orange-icon" aria-hidden="true"></i>
</span>
</td>
<td ng-if="applicationState.application.authentication">
<span>
<i ng-class="stack.ResourceControl.Ownership | ownershipicon" aria-hidden="true"></i>
{{ stack.ResourceControl.Ownership ? stack.ResourceControl.Ownership : stack.ResourceControl.Ownership = 'public' }}
</span>
</td>
</tr>
<tr ng-if="!stacks">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="stacks.length === 0">
<td colspan="3" class="text-center text-muted">No stacks available.</td>
</tr>
</tbody>
</table>
<div ng-if="stacks" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>

View File

@ -0,0 +1,103 @@
angular.module('stacks', [])
.controller('StacksController', ['$scope', 'Notifications', 'Pagination', 'StackService', 'ModalService',
function ($scope, Notifications, Pagination, StackService, ModalService) {
$scope.state = {};
$scope.state.selectedItemCount = 0;
$scope.state.pagination_count = Pagination.getPaginationCount('stacks');
$scope.sortType = 'Name';
$scope.sortReverse = false;
$scope.state.DisplayInformationPanel = false;
$scope.state.DisplayExternalStacks = true;
$scope.changePaginationCount = function() {
Pagination.setPaginationCount('stacks', $scope.state.pagination_count);
};
$scope.order = function (sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.selectItems = function (allSelected) {
angular.forEach($scope.state.filteredStacks, function (stack) {
if (stack.Id && stack.Checked !== allSelected) {
stack.Checked = allSelected;
$scope.selectItem(stack);
}
});
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
};
$scope.removeAction = function () {
ModalService.confirmDeletion(
'Do you want to remove the selected stack(s)? Associated services will be removed as well.',
function onConfirm(confirmed) {
if(!confirmed) { return; }
deleteSelectedStacks();
}
);
};
function deleteSelectedStacks() {
$('#loadingViewSpinner').show();
var counter = 0;
var complete = function () {
counter = counter - 1;
if (counter === 0) {
$('#loadingViewSpinner').hide();
}
};
angular.forEach($scope.stacks, function (stack) {
if (stack.Checked) {
counter = counter + 1;
StackService.remove(stack)
.then(function success() {
Notifications.success('Stack deleted', stack.Name);
var index = $scope.stacks.indexOf(stack);
$scope.stacks.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove stack ' + stack.Name);
})
.finally(function final() {
complete();
});
}
});
}
function initView() {
$('#loadingViewSpinner').show();
StackService.stacks(true)
.then(function success(data) {
var stacks = data;
for (var i = 0; i < stacks.length; i++) {
var stack = stacks[i];
if (stack.External) {
$scope.state.DisplayInformationPanel = true;
break;
}
}
$scope.stacks = stacks;
})
.catch(function error(err) {
$scope.stacks = [];
Notifications.error('Failure', err, 'Unable to retrieve stacks');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);

View File

@ -57,6 +57,13 @@
<span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span> <span ng-show="sortType == 'Id' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a> </a>
</th> </th>
<th>
<a ui-sref="volumes" ng-click="order('StackName')">
Stack
<span ng-show="sortType == 'StackName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'StackName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th> <th>
<a ui-sref="volumes" ng-click="order('Driver')"> <a ui-sref="volumes" ng-click="order('Driver')">
Driver Driver
@ -87,6 +94,7 @@
<a ui-sref="volume({id: volume.Id})" class="monospaced">{{ volume.Id|truncate:25 }}</a> <a ui-sref="volume({id: volume.Id})" class="monospaced">{{ volume.Id|truncate:25 }}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="volume.dangling">Unused</span></td> <span style="margin-left: 10px;" class="label label-warning image-tag" ng-if="volume.dangling">Unused</span></td>
</td> </td>
<td>{{ volume.StackName ? volume.StackName : '-' }}</td>
<td>{{ volume.Driver }}</td> <td>{{ volume.Driver }}</td>
<td>{{ volume.Mountpoint | truncatelr }}</td> <td>{{ volume.Mountpoint | truncatelr }}</td>
<td ng-if="applicationState.application.authentication"> <td ng-if="applicationState.application.authentication">

View File

@ -37,6 +37,13 @@
<portainer-tooltip message="Access control applied on a container created using a template is also applied on each volume associated to the container." position="bottom" style="margin-left: 2px;"></portainer-tooltip> <portainer-tooltip message="Access control applied on a container created using a template is also applied on each volume associated to the container." position="bottom" style="margin-left: 2px;"></portainer-tooltip>
</td> </td>
</tr> </tr>
<tr ng-if="$ctrl.resourceControl.Type === 6 && $ctrl.resourceType !== 'stack'">
<td colspan="2">
<i class="fa fa-info-circle" aria-hidden="true" style="margin-right: 2px;"></i>
Access control on this resource is inherited from the following stack: {{ $ctrl.resourceControl.ResourceId }}
<portainer-tooltip message="Access control applied on a stack is also applied on each resource in the stack." position="bottom" style="margin-left: 2px;"></portainer-tooltip>
</td>
</tr>
<!-- authorized-users --> <!-- authorized-users -->
<tr ng-if="$ctrl.resourceControl.UserAccesses.length > 0"> <tr ng-if="$ctrl.resourceControl.UserAccesses.length > 0">
<td>Authorized users</td> <td>Authorized users</td>
@ -54,7 +61,11 @@
</tr> </tr>
<!-- !authorized-teams --> <!-- !authorized-teams -->
<!-- edit-ownership --> <!-- edit-ownership -->
<tr ng-if="!($ctrl.resourceControl.Type === 1 && $ctrl.resourceType === 'volume') && !($ctrl.resourceControl.Type === 2 && $ctrl.resourceType === 'container') && !$ctrl.state.editOwnership && ($ctrl.isAdmin || $ctrl.state.canEditOwnership)"> <tr ng-if="!($ctrl.resourceControl.Type === 1 && $ctrl.resourceType === 'volume')
&& !($ctrl.resourceControl.Type === 2 && $ctrl.resourceType === 'container')
&& !($ctrl.resourceControl.Type === 6 && $ctrl.resourceType !== 'stack')
&& !$ctrl.state.editOwnership
&& ($ctrl.isAdmin || $ctrl.state.canEditOwnership)">
<td colspan="2"> <td colspan="2">
<a class="btn-outline-secondary" ng-click="$ctrl.state.editOwnership = true"><i class="fa fa-edit space-right" aria-hidden="true"></i>Change ownership</a> <a class="btn-outline-secondary" ng-click="$ctrl.state.editOwnership = true"><i class="fa fa-edit space-right" aria-hidden="true"></i>Change ownership</a>
</td> </td>

View File

@ -0,0 +1,8 @@
angular.module('portainer').component('porServiceList', {
templateUrl: 'app/directives/serviceList/porServiceList.html',
controller: 'porServiceListController',
bindings: {
'services': '<',
'nodes': '<'
}
});

View File

@ -0,0 +1,98 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-list-alt" title="Associated services">
<div class="pull-right">
Items per page:
<select ng-model="$ctrl.state.pagination_count" ng-change="$ctrl.changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12">
<div class="pull-right">
<input type="text" id="filter" ng-model="$ctrl.state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ng-click="$ctrl.order('Name')">
Name
<span ng-show="$ctrl.sortType === 'Name' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Name' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Image')">
Image
<span ng-show="$ctrl.sortType === 'Image' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Image' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Mode')">
Scheduling mode
<span ng-show="$ctrl.sortType === 'Mode' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Mode' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Ports')">
Published Ports
<span ng-show="$ctrl.sortType === 'Ports' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Ports' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('UpdatedAt')">
Updated at
<span ng-show="$ctrl.sortType === 'UpdatedAt' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'UpdatedAt' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="service in $ctrl.services | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage:$ctrl.state.pagination_count" pagination-id="services_list">
<td><a ui-sref="service({id: service.Id})">{{ service.Name }}</a></td>
<td>{{ service.Image | hideshasum }}</td>
<td>
{{ service.Mode }}
<code data-toggle="tooltip" title="Replicas">{{ service.Tasks | runningtaskscount }}</code>
/
<code data-toggle="tooltip" title="Replicas">{{ service.Mode === 'replicated' ? service.Replicas : ($ctrl.nodes | availablenodecount) }}</code>
</td>
<td>
<a ng-if="service.Ports && service.Ports.length > 0" ng-repeat="p in service.Ports" class="image-tag" ng-href="http://{{$ctrl.state.publicURL}}:{{p.PublishedPort}}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{ p.PublishedPort }}:{{ p.TargetPort }}
</a>
<span ng-if="!service.Ports || service.Ports.length === 0" >-</span>
</td>
<td>
{{ service.UpdatedAt|getisodate }}
</td>
</tr>
<tr ng-if="!$ctrl.services">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="($ctrl.services | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage: $ctrl.state.pagination_count).length === 0">
<td colspan="5" class="text-center text-muted">No services available.</td>
</tr>
</tbody>
</table>
<div ng-if="$ctrl.services" class="pull-left pagination-controls">
<dir-pagination-controls pagination-id="services_list"></dir-pagination-controls >
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>

View File

@ -0,0 +1,21 @@
angular.module('portainer')
.controller('porServiceListController', ['EndpointProvider', 'Pagination',
function (EndpointProvider, Pagination) {
var ctrl = this;
ctrl.state = {
pagination_count: Pagination.getPaginationCount('services_list'),
publicURL: EndpointProvider.endpointPublicURL()
};
ctrl.sortType = 'Name';
ctrl.sortReverse = false;
ctrl.order = function(sortType) {
ctrl.sortReverse = (ctrl.sortType === sortType) ? !ctrl.sortReverse : false;
ctrl.sortType = sortType;
};
ctrl.changePaginationCount = function() {
Pagination.setPaginationCount('services_list', ctrl.state.pagination_count);
};
}]);

View File

@ -0,0 +1,8 @@
angular.module('portainer').component('porTaskList', {
templateUrl: 'app/directives/taskList/porTaskList.html',
controller: 'porTaskListController',
bindings: {
'tasks': '<',
'nodes': '<'
}
});

View File

@ -0,0 +1,78 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Associated tasks">
<div class="pull-right">
Items per page:
<select ng-model="$ctrl.state.pagination_count" ng-change="$ctrl.changePaginationCount()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12">
<div class="pull-right">
<input type="text" id="filter" ng-model="$ctrl.state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Id</th>
<th>
<a ng-click="$ctrl.order('Status.State')">
Status
<span ng-show="$ctrl.sortType === 'Status.State' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Status.State' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="service.Mode !== 'global'">
<a ng-click="$ctrl.order('Slot')">
Slot
<span ng-show="$ctrl.sortType === 'Slot' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Slot' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('NodeId')">
Node
<span ng-show="$ctrl.sortType === 'NodeId' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'NodeId' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.order('Updated')">
Last update
<span ng-show="$ctrl.sortType === 'Updated' && !$ctrl.sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.sortType === 'Updated' && $ctrl.sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="task in $ctrl.tasks | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage:$ctrl.state.pagination_count">
<td><a ui-sref="task({ id: task.Id })">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status.State | taskstatusbadge }}">{{ task.Status.State }}</span></td>
<td>{{ task.Slot ? task.Slot : '-' }}</td>
<td>{{ task.NodeId | tasknodename: $ctrl.nodes }}</td>
<td>{{ task.Updated | getisodate }}</td>
</tr>
<tr ng-if="!$ctrl.tasks">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="($ctrl.tasks | filter:$ctrl.state.filter | orderBy:$ctrl.sortType:$ctrl.sortReverse | itemsPerPage: $ctrl.state.pagination_count).length === 0">
<td colspan="5" class="text-center text-muted">No tasks available.</td>
</tr>
</tbody>
</table>
<div class="pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,19 @@
angular.module('portainer')
.controller('porTaskListController', ['Pagination',
function (Pagination) {
var ctrl = this;
ctrl.state = {
pagination_count: Pagination.getPaginationCount('tasks_list')
};
ctrl.sortType = 'Updated';
ctrl.sortReverse = true;
ctrl.order = function(sortType) {
ctrl.sortReverse = (ctrl.sortType === sortType) ? !ctrl.sortReverse : false;
ctrl.sortType = sortType;
};
ctrl.changePaginationCount = function() {
Pagination.setPaginationCount('tasks_list', ctrl.state.pagination_count);
};
}]);

View File

@ -75,7 +75,7 @@ angular.module('portainer.filters', [])
return 'warning'; return 'warning';
} else if (includeString(status, ['created'])) { } else if (includeString(status, ['created'])) {
return 'info'; return 'info';
} else if (includeString(status, ['stopped', 'unhealthy', 'dead'])) { } else if (includeString(status, ['stopped', 'unhealthy', 'dead', 'exited'])) {
return 'danger'; return 'danger';
} }
return 'success'; return 'success';
@ -298,6 +298,32 @@ angular.module('portainer.filters', [])
} }
}; };
}) })
.filter('availablenodecount', function () {
'use strict';
return function (nodes) {
var availableNodes = 0;
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (node.Availability === 'active' && node.Status === 'ready') {
availableNodes++;
}
}
return availableNodes;
};
})
.filter('runningtaskscount', function () {
'use strict';
return function (tasks) {
var runningTasks = 0;
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];
if (task.Status.State === 'running') {
runningTasks++;
}
}
return runningTasks;
};
})
.filter('tasknodename', function () { .filter('tasknodename', function () {
'use strict'; 'use strict';
return function (nodeId, nodes) { return function (nodeId, nodes) {

View File

@ -1,123 +1,145 @@
angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHelperFactory() { angular.module('portainer.helpers').factory('ServiceHelper', [function ServiceHelperFactory() {
'use strict'; 'use strict';
return {
serviceToConfig: function(service) { var helper = {};
return {
Name: service.Spec.Name, helper.associateTasksToService = function(service, tasks) {
Labels: service.Spec.Labels, service.Tasks = [];
TaskTemplate: service.Spec.TaskTemplate, var otherServicesTasks = [];
Mode: service.Spec.Mode, for (var i = 0; i < tasks.length; i++) {
UpdateConfig: service.Spec.UpdateConfig, var task = tasks[i];
Networks: service.Spec.Networks, if (task.ServiceId === service.Id) {
EndpointSpec: service.Spec.EndpointSpec service.Tasks.push(task);
}; } else {
}, otherServicesTasks.push(task);
translateKeyValueToPlacementPreferences: function(keyValuePreferences) { }
if (keyValuePreferences) { }
var preferences = []; tasks = otherServicesTasks;
keyValuePreferences.forEach(function(preference) { };
if (preference.strategy && preference.strategy !== '' && preference.value && preference.value !== '') {
switch (preference.strategy.toLowerCase()) { helper.serviceToConfig = function(service) {
case 'spread': return {
preferences.push({ Name: service.Spec.Name,
'Spread': { Labels: service.Spec.Labels,
'SpreadDescriptor': preference.value TaskTemplate: service.Spec.TaskTemplate,
} Mode: service.Spec.Mode,
}); UpdateConfig: service.Spec.UpdateConfig,
break; Networks: service.Spec.Networks,
} EndpointSpec: service.Spec.EndpointSpec
};
};
helper.translateKeyValueToPlacementPreferences = function(keyValuePreferences) {
if (keyValuePreferences) {
var preferences = [];
keyValuePreferences.forEach(function(preference) {
if (preference.strategy && preference.strategy !== '' && preference.value && preference.value !== '') {
switch (preference.strategy.toLowerCase()) {
case 'spread':
preferences.push({
'Spread': {
'SpreadDescriptor': preference.value
}
});
break;
} }
});
return preferences;
}
return [];
},
translateKeyValueToPlacementConstraints: function(keyValueConstraints) {
if (keyValueConstraints) {
var constraints = [];
keyValueConstraints.forEach(function(constraint) {
if (constraint.key && constraint.key !== '' && constraint.value && constraint.value !== '') {
constraints.push(constraint.key + constraint.operator + constraint.value);
}
});
return constraints;
}
return [];
},
translateEnvironmentVariables: function(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
var idx = variable.indexOf('=');
var keyValue = [variable.slice(0, idx), variable.slice(idx + 1)];
var originalValue = (keyValue.length > 1) ? keyValue[1] : '';
variables.push({
key: keyValue[0],
value: originalValue,
originalKey: keyValue[0],
originalValue: originalValue,
added: true
});
});
return variables;
}
return [];
},
translateEnvironmentVariablesToEnv: function(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
if (variable.key && variable.key !== '') {
variables.push(variable.key + '=' + variable.value);
}
});
return variables;
}
return [];
},
translatePreferencesToKeyValue: function(preferences) {
if (preferences) {
var keyValuePreferences = [];
preferences.forEach(function(preference) {
if (preference.Spread) {
keyValuePreferences.push({
strategy: 'Spread',
value: preference.Spread.SpreadDescriptor
});
}
});
return keyValuePreferences;
}
return [];
},
translateConstraintsToKeyValue: function(constraints) {
function getOperator(constraint) {
var indexEquals = constraint.indexOf('==');
if (indexEquals >= 0) {
return [indexEquals, '=='];
} }
return [constraint.indexOf('!='), '!=']; });
} return preferences;
if (constraints) { }
var keyValueConstraints = []; return [];
constraints.forEach(function(constraint) { };
var operatorIndices = getOperator(constraint);
var key = constraint.slice(0, operatorIndices[0]); helper.translateKeyValueToPlacementConstraints = function(keyValueConstraints) {
var operator = operatorIndices[1]; if (keyValueConstraints) {
var value = constraint.slice(operatorIndices[0] + 2); var constraints = [];
keyValueConstraints.forEach(function(constraint) {
if (constraint.key && constraint.key !== '' && constraint.value && constraint.value !== '') {
constraints.push(constraint.key + constraint.operator + constraint.value);
}
});
return constraints;
}
return [];
};
keyValueConstraints.push({ helper.translateEnvironmentVariables = function(env) {
key: key, if (env) {
value: value, var variables = [];
operator: operator, env.forEach(function(variable) {
originalKey: key, var idx = variable.indexOf('=');
originalValue: value var keyValue = [variable.slice(0, idx), variable.slice(idx + 1)];
}); var originalValue = (keyValue.length > 1) ? keyValue[1] : '';
variables.push({
key: keyValue[0],
value: originalValue,
originalKey: keyValue[0],
originalValue: originalValue,
added: true
}); });
return keyValueConstraints; });
return variables;
}
return [];
};
helper.translateEnvironmentVariablesToEnv = function(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
if (variable.key && variable.key !== '') {
variables.push(variable.key + '=' + variable.value);
}
});
return variables;
}
return [];
};
helper.translatePreferencesToKeyValue = function(preferences) {
if (preferences) {
var keyValuePreferences = [];
preferences.forEach(function(preference) {
if (preference.Spread) {
keyValuePreferences.push({
strategy: 'Spread',
value: preference.Spread.SpreadDescriptor
});
}
});
return keyValuePreferences;
}
return [];
};
helper.translateConstraintsToKeyValue = function(constraints) {
function getOperator(constraint) {
var indexEquals = constraint.indexOf('==');
if (indexEquals >= 0) {
return [indexEquals, '=='];
} }
return []; return [constraint.indexOf('!='), '!='];
}
if (constraints) {
var keyValueConstraints = [];
constraints.forEach(function(constraint) {
var operatorIndices = getOperator(constraint);
var key = constraint.slice(0, operatorIndices[0]);
var operator = operatorIndices[1];
var value = constraint.slice(operatorIndices[0] + 2);
keyValueConstraints.push({
key: key,
value: value,
operator: operator,
originalKey: key,
originalValue: value
});
});
return keyValueConstraints;
} }
}; };
return helper;
}]); }]);

View File

@ -0,0 +1,21 @@
angular.module('portainer.helpers')
.factory('StackHelper', [function StackHelperFactory() {
'use strict';
var helper = {};
helper.getExternalStackNamesFromServices = function(services) {
var stackNames = [];
for (var i = 0; i < services.length; i++) {
var service = services[i];
if (!service.Labels || !service.Labels['com.docker.stack.namespace']) continue;
var stackName = service.Labels['com.docker.stack.namespace'];
stackNames.push(stackName);
}
return _.uniq(stackNames);
};
return helper;
}]);

9
app/models/api/stack.js Normal file
View File

@ -0,0 +1,9 @@
function StackViewModel(data) {
this.Id = data.Id;
this.Name = data.Name;
this.Checked = false;
if (data.ResourceControl && data.ResourceControl.Id !== 0) {
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
}
this.External = data.External;
}

View File

@ -8,9 +8,15 @@ function ContainerViewModel(data) {
this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress;
} }
this.Image = data.Image; this.Image = data.Image;
this.ImageID = data.ImageID;
this.Command = data.Command; this.Command = data.Command;
this.Checked = false; this.Checked = false;
this.Labels = data.Labels; this.Labels = data.Labels;
if (this.Labels && this.Labels['com.docker.compose.project']) {
this.StackName = this.Labels['com.docker.compose.project'];
} else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
this.Mounts = data.Mounts; this.Mounts = data.Mounts;
this.Ports = []; this.Ports = [];

View File

@ -8,6 +8,13 @@ function NetworkViewModel(data) {
this.Containers = data.Containers; this.Containers = data.Containers;
this.Options = data.Options; this.Options = data.Options;
this.Labels = data.Labels;
if (this.Labels && this.Labels['com.docker.compose.project']) {
this.StackName = this.Labels['com.docker.compose.project'];
} else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
if (data.Portainer) { if (data.Portainer) {
if (data.Portainer.ResourceControl) { if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl); this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);

View File

@ -1,6 +1,7 @@
function ServiceViewModel(data, runningTasks, nodes) { function ServiceViewModel(data, runningTasks, nodes) {
this.Model = data; this.Model = data;
this.Id = data.ID; this.Id = data.ID;
this.Tasks = [];
this.Name = data.Spec.Name; this.Name = data.Spec.Name;
this.CreatedAt = data.CreatedAt; this.CreatedAt = data.CreatedAt;
this.UpdatedAt = data.UpdatedAt; this.UpdatedAt = data.UpdatedAt;
@ -44,6 +45,9 @@ function ServiceViewModel(data, runningTasks, nodes) {
this.Preferences = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Preferences || [] : []; this.Preferences = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Preferences || [] : [];
this.Platforms = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Platforms || [] : []; this.Platforms = data.Spec.TaskTemplate.Placement ? data.Spec.TaskTemplate.Placement.Platforms || [] : [];
this.Labels = data.Spec.Labels; this.Labels = data.Spec.Labels;
if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
var containerSpec = data.Spec.TaskTemplate.ContainerSpec; var containerSpec = data.Spec.TaskTemplate.ContainerSpec;
if (containerSpec) { if (containerSpec) {

View File

@ -0,0 +1,3 @@
function SwarmViewModel(data) {
this.Id = data.ID;
}

View File

@ -3,6 +3,11 @@ function VolumeViewModel(data) {
this.Driver = data.Driver; this.Driver = data.Driver;
this.Options = data.Options; this.Options = data.Options;
this.Labels = data.Labels; this.Labels = data.Labels;
if (this.Labels && this.Labels['com.docker.compose.project']) {
this.StackName = this.Labels['com.docker.compose.project'];
} else if (this.Labels && this.Labels['com.docker.stack.namespace']) {
this.StackName = this.Labels['com.docker.stack.namespace'];
}
this.Mountpoint = data.Mountpoint; this.Mountpoint = data.Mountpoint;
if (data.Portainer) { if (data.Portainer) {

15
app/rest/api/stack.js Normal file
View File

@ -0,0 +1,15 @@
angular.module('portainer.rest')
.factory('Stack', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function StackFactory($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/stacks/:id/:action', {
endpointId: EndpointProvider.endpointID
},
{
get: { method: 'GET', params: { id: '@id' } },
query: { method: 'GET', isArray: true },
create: { method: 'POST' },
update: { method: 'PUT', params: { id: '@id' } },
remove: { method: 'DELETE', params: { id: '@id'} },
getStackFile: { method: 'GET', params: { id : '@id', action: 'stackfile' } }
});
}]);

View File

@ -6,7 +6,7 @@ angular.module('portainer.rest')
}, },
{ {
get: { method: 'GET', params: {id: '@id'} }, get: { method: 'GET', params: {id: '@id'} },
query: { method: 'GET', isArray: true }, query: { method: 'GET', isArray: true, params: {filters: '@filters'} },
create: { create: {
method: 'POST', params: {action: 'create'}, method: 'POST', params: {action: 'create'},
headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader } headers: { 'X-Registry-Auth': HttpRequestHelper.registryAuthenticationHeader }

View File

@ -661,5 +661,44 @@ function configureRoutes($stateProvider) {
controller: 'SidebarController' controller: 'SidebarController'
} }
} }
})
.state('actions.create.stack', {
url: '/stack',
views: {
'content@': {
templateUrl: 'app/components/createStack/createstack.html',
controller: 'CreateStackController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('stacks', {
url: '/stacks/',
views: {
'content@': {
templateUrl: 'app/components/stacks/stacks.html',
controller: 'StacksController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('stack', {
url: '^/stacks/:id/',
views: {
'content@': {
templateUrl: 'app/components/stack/stack.html',
controller: 'StackController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
}); });
} }

View File

@ -0,0 +1,162 @@
angular.module('portainer.services')
.factory('StackService', ['$q', 'Stack', 'ResourceControlService', 'FileUploadService', 'StackHelper', 'ServiceService', 'SwarmService',
function StackServiceFactory($q, Stack, ResourceControlService, FileUploadService, StackHelper, ServiceService, SwarmService) {
'use strict';
var service = {};
service.stack = function(id) {
var deferred = $q.defer();
Stack.get({ id: id }).$promise
.then(function success(data) {
var stack = new StackViewModel(data);
deferred.resolve(stack);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve stack details', err: err });
});
return deferred.promise;
};
service.getStackFile = function(id) {
var deferred = $q.defer();
Stack.getStackFile({ id: id }).$promise
.then(function success(data) {
deferred.resolve(data.StackFileContent);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve stack content', err: err });
});
return deferred.promise;
};
service.externalStacks = function() {
var deferred = $q.defer();
ServiceService.services()
.then(function success(data) {
var services = data;
var stackNames = StackHelper.getExternalStackNamesFromServices(services);
var stacks = stackNames.map(function (item) {
return new StackViewModel({ Name: item, External: true });
});
deferred.resolve(stacks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve external stacks', err: err });
});
return deferred.promise;
};
service.stacks = function(includeExternalStacks) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return $q.all({
stacks: Stack.query({ swarmId: swarm.Id }).$promise,
externalStacks: includeExternalStacks ? service.externalStacks() : []
});
})
.then(function success(data) {
var stacks = data.stacks.map(function (item) {
item.External = false;
return new StackViewModel(item);
});
var externalStacks = data.externalStacks;
var result = _.unionWith(stacks, externalStacks, function(a, b) { return a.Name === b.Name; });
deferred.resolve(result);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve stacks', err: err });
});
return deferred.promise;
};
service.remove = function(stack) {
var deferred = $q.defer();
Stack.remove({ id: stack.Id }).$promise
.then(function success(data) {
if (stack.ResourceControl && stack.ResourceControl.Id) {
return ResourceControlService.deleteResourceControl(stack.ResourceControl.Id);
}
})
.then(function success() {
deferred.resolve();
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to remove the stack', err: err });
});
return deferred.promise;
};
service.createStackFromFileContent = function(name, stackFileContent) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return Stack.create({ method: 'string' }, { Name: name, SwarmID: swarm.Id, StackFileContent: stackFileContent }).$promise;
})
.then(function success(data) {
deferred.resolve(data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create the stack', err: err });
});
return deferred.promise;
};
service.createStackFromGitRepository = function(name, gitRepository, pathInRepository) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return Stack.create({ method: 'repository' }, { Name: name, SwarmID: swarm.Id, GitRepository: gitRepository, PathInRepository: pathInRepository }).$promise;
})
.then(function success(data) {
deferred.resolve(data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create the stack', err: err });
});
return deferred.promise;
};
service.createStackFromFileUpload = function(name, stackFile) {
var deferred = $q.defer();
SwarmService.swarm()
.then(function success(data) {
var swarm = data;
return FileUploadService.createStack(name, swarm.Id, stackFile);
})
.then(function success(data) {
deferred.resolve(data.data);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to create the stack', err: err });
});
return deferred.promise;
};
service.updateStack = function(id, stackFile) {
return Stack.update({ id: id, StackFileContent: stackFile }).$promise;
};
return service;
}]);

View File

@ -0,0 +1,21 @@
angular.module('portainer.services')
.factory('CodeMirrorService', function CodeMirrorService() {
'use strict';
var codeMirrorOptions = {
lineNumbers: true,
mode: 'text/x-yaml',
gutters: ['CodeMirror-lint-markers'],
lint: true
};
var service = {};
service.applyCodeMirrorOnElement = function(element) {
var cm = CodeMirror.fromTextArea(element, codeMirrorOptions);
cm.setSize('100%', 500);
return cm;
};
return service;
});

View File

@ -18,16 +18,20 @@ angular.module('portainer.services')
return deferred.promise; return deferred.promise;
}; };
service.containers = function(all) { service.containers = function(all, filters) {
var deferred = $q.defer(); var deferred = $q.defer();
Container.query({ all: all }).$promise
Container.query({ all: all, filters: filters ? filters : {} }).$promise
.then(function success(data) { .then(function success(data) {
var containers = data; var containers = data.map(function (item) {
return new ContainerViewModel(item);
});
deferred.resolve(containers); deferred.resolve(containers);
}) })
.catch(function error(err) { .catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve containers', err: err }); deferred.reject({ msg: 'Unable to retrieve containers', err: err });
}); });
return deferred.promise; return deferred.promise;
}; };

View File

@ -1,12 +1,12 @@
angular.module('portainer.services') angular.module('portainer.services')
.factory('ServiceService', ['$q', 'Service', 'ResourceControlService', function ServiceServiceFactory($q, Service, ResourceControlService) { .factory('ServiceService', ['$q', 'Service', 'ServiceHelper', 'TaskService', 'ResourceControlService', function ServiceServiceFactory($q, Service, ServiceHelper, TaskService, ResourceControlService) {
'use strict'; 'use strict';
var service = {}; var service = {};
service.services = function() { service.services = function(filters) {
var deferred = $q.defer(); var deferred = $q.defer();
Service.query().$promise Service.query({ filters: filters ? filters : {} }).$promise
.then(function success(data) { .then(function success(data) {
var services = data.map(function (item) { var services = data.map(function (item) {
return new ServiceViewModel(item); return new ServiceViewModel(item);

View File

@ -0,0 +1,22 @@
angular.module('portainer.services')
.factory('SwarmService', ['$q', 'Swarm', function SwarmServiceFactory($q, Swarm) {
'use strict';
var service = {};
service.swarm = function() {
var deferred = $q.defer();
Swarm.get().$promise
.then(function success(data) {
var swarm = new SwarmViewModel(data);
deferred.resolve(swarm);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve Swarm details', err: err });
});
return deferred.promise;
};
return service;
}]);

View File

@ -3,23 +3,6 @@ angular.module('portainer.services')
'use strict'; 'use strict';
var service = {}; var service = {};
service.tasks = function() {
var deferred = $q.defer();
Task.query().$promise
.then(function success(data) {
var tasks = data.map(function (item) {
return new TaskViewModel(item);
});
deferred.resolve(tasks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve tasks', err: err });
});
return deferred.promise;
};
service.task = function(id) { service.task = function(id) {
var deferred = $q.defer(); var deferred = $q.defer();
@ -35,10 +18,10 @@ angular.module('portainer.services')
return deferred.promise; return deferred.promise;
}; };
service.serviceTasks = function(serviceName) { service.tasks = function(filters) {
var deferred = $q.defer(); var deferred = $q.defer();
Task.query({ filters: { service: [serviceName] } }).$promise Task.query({ filters: filters ? filters : {} }).$promise
.then(function success(data) { .then(function success(data) {
var tasks = data.map(function (item) { var tasks = data.map(function (item) {
return new TaskViewModel(item); return new TaskViewModel(item);
@ -46,7 +29,7 @@ angular.module('portainer.services')
deferred.resolve(tasks); deferred.resolve(tasks);
}) })
.catch(function error(err) { .catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve tasks associated to the service', err: err }); deferred.reject({ msg: 'Unable to retrieve tasks', err: err });
}); });
return deferred.promise; return deferred.promise;

View File

@ -1,5 +1,5 @@
angular.module('portainer.services') angular.module('portainer.services')
.factory('FileUploadService', ['$q', 'Upload', function FileUploadFactory($q, Upload) { .factory('FileUploadService', ['$q', 'Upload', 'EndpointProvider', function FileUploadFactory($q, Upload, EndpointProvider) {
'use strict'; 'use strict';
var service = {}; var service = {};
@ -8,6 +8,11 @@ angular.module('portainer.services')
return Upload.upload({ url: url, data: { file: file }}); return Upload.upload({ url: url, data: { file: file }});
} }
service.createStack = function(stackName, swarmId, file) {
var endpointID = EndpointProvider.endpointID();
return Upload.upload({ url: 'api/endpoints/' + endpointID + '/stacks?method=file', data: { file: file, Name: stackName, SwarmID: swarmId } });
};
service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) {
var queue = []; var queue = [];

View File

@ -7,6 +7,10 @@ angular.module('portainer.services')
toastr.success($sanitize(text), $sanitize(title)); toastr.success($sanitize(text), $sanitize(title));
}; };
service.warning = function(title, text) {
toastr.warning($sanitize(text), $sanitize(title), {timeOut: 6000});
};
service.error = function(title, e, fallbackText) { service.error = function(title, e, fallbackText) {
var msg = fallbackText; var msg = fallbackText;
if (e.data && e.data.message) { if (e.data && e.data.message) {
@ -17,13 +21,14 @@ angular.module('portainer.services')
msg = e.err.data.message; msg = e.err.data.message;
} else if (e.data && e.data.length > 0 && e.data[0].message) { } else if (e.data && e.data.length > 0 && e.data[0].message) {
msg = e.data[0].message; msg = e.data[0].message;
} else if (e.err && e.err.data && e.err.data.length > 0 && e.err.data[0].message) { } else if (e.err && e.err.data && e.err.data.err) {
msg = e.err.data[0].message; msg = e.err.data.err;
} else if (e.msg) {
msg = e.msg;
} else if (e.data && e.data.err) { } else if (e.data && e.data.err) {
msg = e.data.err; msg = e.data.err;
} else if (e.msg) {
msg = e.msg;
} }
if (msg !== 'Invalid JWT token') { if (msg !== 'Invalid JWT token') {
toastr.error($sanitize(msg), $sanitize(title), {timeOut: 6000}); toastr.error($sanitize(msg), $sanitize(title), {timeOut: 6000});
} }

View File

@ -50,7 +50,9 @@
"xterm.js": "~2.8.1", "xterm.js": "~2.8.1",
"chart.js": "~2.6.0", "chart.js": "~2.6.0",
"angularjs-slider": "^6.4.0", "angularjs-slider": "^6.4.0",
"angular-ui-router": "~1.0.6" "angular-ui-router": "~1.0.6",
"codemirror": "~5.30.0",
"js-yaml": "~3.10.0"
}, },
"resolutions": { "resolutions": {
"angular": "1.5.11" "angular": "1.5.11"

View File

@ -42,8 +42,7 @@ else
if [ `echo "$@" | cut -c1-4` == 'echo' ]; then if [ `echo "$@" | cut -c1-4` == 'echo' ]; then
bash -c "$@"; bash -c "$@";
else else
build_all 'linux-amd64 linux-386 linux-arm linux-arm64 linux-ppc64le darwin-amd64 windows-amd64' build_all 'linux-amd64 linux-arm linux-arm64 linux-ppc64le darwin-amd64 windows-amd64'
exit 0 exit 0
fi fi
fi fi

22
build/download_docker_binary.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
PLATFORM=$1
ARCH=$2
DOCKER_VERSION=$3
DOWNLOAD_FOLDER=".tmp/download"
rm -rf "${DOWNLOAD_FOLDER}"
mkdir -pv "${DOWNLOAD_FOLDER}"
if [ "${PLATFORM}" == 'win' ]; then
wget -O "${DOWNLOAD_FOLDER}/docker-binaries.zip" "https://download.docker.com/${PLATFORM}/static/stable/${ARCH}/docker-${DOCKER_VERSION}.zip"
unzip "${DOWNLOAD_FOLDER}/docker-binaries.zip" -d "${DOWNLOAD_FOLDER}"
mv "${DOWNLOAD_FOLDER}/docker/docker.exe" dist/
else
wget -O "${DOWNLOAD_FOLDER}/docker-binaries.tgz" "https://download.docker.com/${PLATFORM}/static/stable/${ARCH}/docker-${DOCKER_VERSION}.tgz"
tar -xf "${DOWNLOAD_FOLDER}/docker-binaries.tgz" -C "${DOWNLOAD_FOLDER}"
mv "${DOWNLOAD_FOLDER}/docker/docker" dist/
fi
exit 0

View File

@ -1,11 +0,0 @@
FROM microsoft/windowsservercore
COPY dist /
VOLUME C:\\data
WORKDIR /
EXPOSE 9000
ENTRYPOINT ["/portainer.exe"]

View File

@ -34,6 +34,7 @@ module.exports = function (grunt) {
'config:dev', 'config:dev',
'clean:app', 'clean:app',
'shell:buildBinary:linux:amd64', 'shell:buildBinary:linux:amd64',
'shell:downloadDockerBinary:linux:amd64',
'vendor:regular', 'vendor:regular',
'html2js', 'html2js',
'useminPrepare:dev', 'useminPrepare:dev',
@ -44,7 +45,7 @@ module.exports = function (grunt) {
'after-copy' 'after-copy'
]); ]);
grunt.task.registerTask('release', 'release:<platform>:<arch>', function(p, a) { grunt.task.registerTask('release', 'release:<platform>:<arch>', function(p, a) {
grunt.task.run(['config:prod', 'clean:all', 'shell:buildBinary:'+p+':'+a, 'before-copy', 'copy:assets', 'after-copy' ]); grunt.task.run(['config:prod', 'clean:all', 'shell:buildBinary:'+p+':'+a, 'shell:downloadDockerBinary:'+p+':'+a, 'before-copy', 'copy:assets', 'after-copy' ]);
}); });
grunt.registerTask('lint', ['eslint']); grunt.registerTask('lint', ['eslint']);
grunt.registerTask('run-dev', ['build', 'shell:run', 'watch:build']); grunt.registerTask('run-dev', ['build', 'shell:run', 'watch:build']);
@ -53,6 +54,7 @@ module.exports = function (grunt) {
// Project configuration. // Project configuration.
grunt.initConfig({ grunt.initConfig({
distdir: 'dist', distdir: 'dist',
shippedDockerVersion: '17.09.0-ce',
pkg: grunt.file.readJSON('package.json'), pkg: grunt.file.readJSON('package.json'),
config: { config: {
dev: { options: { variables: { 'environment': 'development' }}}, dev: { options: { variables: { 'environment': 'development' }}},
@ -67,7 +69,7 @@ module.exports = function (grunt) {
}, },
clean: { clean: {
all: ['<%= distdir %>/*'], all: ['<%= distdir %>/*'],
app: ['<%= distdir %>/*', '!<%= distdir %>/portainer*'], app: ['<%= distdir %>/*', '!<%= distdir %>/portainer*', '!<%= distdir %>/docker*'],
tmpl: ['<%= distdir %>/templates'], tmpl: ['<%= distdir %>/templates'],
tmp: ['<%= distdir %>/js/*', '!<%= distdir %>/js/app.*.js', '<%= distdir %>/css/*', '!<%= distdir %>/css/app.*.css'] tmp: ['<%= distdir %>/js/*', '!<%= distdir %>/js/app.*.js', '<%= distdir %>/css/*', '!<%= distdir %>/css/app.*.css']
}, },
@ -170,19 +172,33 @@ module.exports = function (grunt) {
shell: { shell: {
buildBinary: { buildBinary: {
command: function (p, a) { command: function (p, a) {
var binfile = 'dist/portainer-'+p+'-'+a; var binfile = 'dist/portainer-'+p+'-'+a;
if (grunt.file.isFile( ( p === 'windows' ) ? binfile+'.exe' : binfile )) { if (grunt.file.isFile( ( p === 'windows' ) ? binfile+'.exe' : binfile )) {
return 'echo \'BinaryExists\''; return 'echo "Portainer binary exists"';
} else { } else {
return 'build/build_in_container.sh ' + p + ' ' + a; return 'build/build_in_container.sh ' + p + ' ' + a;
} }
} }
}, },
run: { run: {
command: [ command: [
'docker rm -f portainer', 'docker rm -f portainer',
'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-amd64 --no-analytics -a /app' 'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-amd64 --no-analytics -a /app'
].join(';') ].join(';')
},
downloadDockerBinary: {
command: function(p, a) {
if (p === 'windows') p = 'win';
if (p === 'darwin') p = 'mac';
if (a === 'amd64') a = 'x86_64';
if (a === 'arm') a = 'armhf';
if (a === 'arm64') a = 'aarch64';
if (grunt.file.isFile( ( p === 'win' ) ? 'dist/docker.exe' : 'dist/docker' )) {
return 'echo "Docker binary exists"';
} else {
return 'build/download_docker_binary.sh ' + p + ' ' + a + ' <%= shippedDockerVersion %>';
}
}
} }
}, },
replace: { replace: {

View File

@ -12,6 +12,11 @@ js:
- bower_components/toastr/toastr.js - bower_components/toastr/toastr.js
- bower_components/xterm.js/dist/xterm.js - bower_components/xterm.js/dist/xterm.js
- bower_components/xterm.js/dist/addons/fit/fit.js - bower_components/xterm.js/dist/addons/fit/fit.js
- bower_components/js-yaml/dist/js-yaml.js
- bower_components/codemirror/lib/codemirror.js
- bower_components/codemirror/mode/yaml/yaml.js
- bower_components/codemirror/addon/lint/lint.js
- bower_components/codemirror/addon/lint/yaml-lint.js
minified: minified:
- bower_components/jquery/dist/jquery.min.js - bower_components/jquery/dist/jquery.min.js
- bower_components/bootstrap/dist/js/bootstrap.min.js - bower_components/bootstrap/dist/js/bootstrap.min.js
@ -25,6 +30,11 @@ js:
- bower_components/toastr/toastr.min.js - bower_components/toastr/toastr.min.js
- bower_components/xterm.js/dist/xterm.js - bower_components/xterm.js/dist/xterm.js
- bower_components/xterm.js/dist/addons/fit/fit.js - bower_components/xterm.js/dist/addons/fit/fit.js
- bower_components/js-yaml/dist/js-yaml.min.js
- bower_components/codemirror/lib/codemirror.js
- bower_components/codemirror/mode/yaml/yaml.js
- bower_components/codemirror/addon/lint/lint.js
- bower_components/codemirror/addon/lint/yaml-lint.js
css: css:
regular: regular:
- bower_components/bootstrap/dist/css/bootstrap.css - bower_components/bootstrap/dist/css/bootstrap.css
@ -35,6 +45,8 @@ css:
- bower_components/toastr/toastr.css - bower_components/toastr/toastr.css
- bower_components/xterm.js/dist/xterm.css - bower_components/xterm.js/dist/xterm.css
- bower_components/angularjs-slider/dist/rzslider.css - bower_components/angularjs-slider/dist/rzslider.css
- bower_components/codemirror/lib/codemirror.css
- bower_components/codemirror/addon/lint/lint.css
minified: minified:
- bower_components/bootstrap/dist/css/bootstrap.min.css - bower_components/bootstrap/dist/css/bootstrap.min.css
- bower_components/rdash-ui/dist/css/rdash.min.css - bower_components/rdash-ui/dist/css/rdash.min.css
@ -44,6 +56,8 @@ css:
- bower_components/toastr/toastr.min.css - bower_components/toastr/toastr.min.css
- bower_components/xterm.js/dist/xterm.css - bower_components/xterm.js/dist/xterm.css
- bower_components/angularjs-slider/dist/rzslider.min.css - bower_components/angularjs-slider/dist/rzslider.min.css
- bower_components/codemirror/lib/codemirror.css
- bower_components/codemirror/addon/lint/lint.css
angular: angular:
regular: regular:
- bower_components/angular/angular.js - bower_components/angular/angular.js