From 9ad9cc5e2db4968817eedbfc5674e1227a3f7a45 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 28 May 2018 16:40:33 +0200 Subject: [PATCH] feat(azure): add experimental Azure endpoint support (#1936) --- api/errors.go | 5 + api/http/client/client.go | 53 ++++++ api/http/handler/azure.go | 102 +++++++++++ api/http/handler/endpoint.go | 75 +++++++- api/http/handler/handler.go | 3 + api/http/proxy/azure_transport.go | 81 +++++++++ .../{transport.go => docker_transport.go} | 0 api/http/proxy/factory.go | 16 +- api/http/proxy/manager.go | 46 ++--- api/http/proxy/reverse_proxy.go | 2 +- api/http/server.go | 6 + api/portainer.go | 31 ++-- app/__module.js | 1 + app/azure/_module.js | 48 ++++++ .../azure-sidebar-content.js | 3 + .../azureSidebarContent.html | 6 + .../containerGroupsDatatable.html | 104 ++++++++++++ .../containerGroupsDatatable.js | 14 ++ app/azure/models/container_group.js | 66 ++++++++ app/azure/models/location.js | 6 + app/azure/models/provider.js | 7 + app/azure/models/resource_group.js | 6 + app/azure/models/subscription.js | 4 + app/azure/rest/azure.js | 17 ++ app/azure/rest/container_group.js | 41 +++++ app/azure/rest/location.js | 12 ++ app/azure/rest/provider.js | 12 ++ app/azure/rest/resource_group.js | 12 ++ app/azure/rest/subscription.js | 12 ++ app/azure/services/azureService.js | 66 ++++++++ app/azure/services/containerGroupService.js | 33 ++++ app/azure/services/locationService.js | 24 +++ app/azure/services/providerService.js | 22 +++ app/azure/services/resourceGroupService.js | 24 +++ app/azure/services/subscriptionService.js | 24 +++ .../containerInstancesController.js | 41 +++++ .../containerinstances.html | 19 +++ .../createContainerInstanceController.js | 87 ++++++++++ .../create/createcontainerinstance.html | 160 ++++++++++++++++++ app/azure/views/dashboard/dashboard.html | 33 ++++ .../views/dashboard/dashboardController.js | 21 +++ .../containers/edit/containerController.js | 2 +- app/portainer/services/api/endpointService.js | 20 ++- app/portainer/services/fileUpload.js | 17 +- app/portainer/services/stateManager.js | 8 + app/portainer/views/auth/authController.js | 30 +++- .../create/createEndpointController.js | 39 ++++- .../endpoints/create/createendpoint.html | 129 +++++++++++--- .../views/init/endpoint/initEndpoint.html | 84 ++++++++- .../init/endpoint/initEndpointController.js | 41 ++++- app/portainer/views/sidebar/sidebar.html | 4 +- .../views/sidebar/sidebarController.js | 25 ++- 52 files changed, 1665 insertions(+), 79 deletions(-) create mode 100644 api/http/handler/azure.go create mode 100644 api/http/proxy/azure_transport.go rename api/http/proxy/{transport.go => docker_transport.go} (100%) create mode 100644 app/azure/_module.js create mode 100644 app/azure/components/azure-sidebar-content/azure-sidebar-content.js create mode 100644 app/azure/components/azure-sidebar-content/azureSidebarContent.html create mode 100644 app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html create mode 100644 app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js create mode 100644 app/azure/models/container_group.js create mode 100644 app/azure/models/location.js create mode 100644 app/azure/models/provider.js create mode 100644 app/azure/models/resource_group.js create mode 100644 app/azure/models/subscription.js create mode 100644 app/azure/rest/azure.js create mode 100644 app/azure/rest/container_group.js create mode 100644 app/azure/rest/location.js create mode 100644 app/azure/rest/provider.js create mode 100644 app/azure/rest/resource_group.js create mode 100644 app/azure/rest/subscription.js create mode 100644 app/azure/services/azureService.js create mode 100644 app/azure/services/containerGroupService.js create mode 100644 app/azure/services/locationService.js create mode 100644 app/azure/services/providerService.js create mode 100644 app/azure/services/resourceGroupService.js create mode 100644 app/azure/services/subscriptionService.js create mode 100644 app/azure/views/containerinstances/containerInstancesController.js create mode 100644 app/azure/views/containerinstances/containerinstances.html create mode 100644 app/azure/views/containerinstances/create/createContainerInstanceController.js create mode 100644 app/azure/views/containerinstances/create/createcontainerinstance.html create mode 100644 app/azure/views/dashboard/dashboard.html create mode 100644 app/azure/views/dashboard/dashboardController.js diff --git a/api/errors.go b/api/errors.go index 7cd39f445..709bafb92 100644 --- a/api/errors.go +++ b/api/errors.go @@ -44,6 +44,11 @@ const ( ErrEndpointAccessDenied = Error("Access denied to endpoint") ) +// Azure environment errors +const ( + ErrAzureInvalidCredentials = Error("Invalid Azure credentials") +) + // Endpoint group errors. const ( ErrEndpointGroupNotFound = Error("Endpoint group not found") diff --git a/api/http/client/client.go b/api/http/client/client.go index 438be12ad..338aed15d 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -2,15 +2,68 @@ package client import ( "crypto/tls" + "encoding/json" + "fmt" "net/http" + "net/url" "strings" "time" "github.com/portainer/portainer" ) +// HTTPClient represents a client to send HTTP requests. +type HTTPClient struct { + *http.Client +} + +// NewHTTPClient is used to build a new HTTPClient. +func NewHTTPClient() *HTTPClient { + return &HTTPClient{ + &http.Client{ + Timeout: time.Second * 5, + }, + } +} + +// AzureAuthenticationResponse represents an Azure API authentication response. +type AzureAuthenticationResponse struct { + AccessToken string `json:"access_token"` + ExpiresOn string `json:"expires_on"` +} + +// ExecuteAzureAuthenticationRequest is used to execute an authentication request +// against the Azure API. It re-uses the same http.Client. +func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portainer.AzureCredentials) (*AzureAuthenticationResponse, error) { + loginURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/token", credentials.TenantID) + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {credentials.ApplicationID}, + "client_secret": {credentials.AuthenticationKey}, + "resource": {"https://management.azure.com/"}, + } + + response, err := client.PostForm(loginURL, params) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + return nil, portainer.ErrAzureInvalidCredentials + } + + var token AzureAuthenticationResponse + err = json.NewDecoder(response.Body).Decode(&token) + if err != nil { + return nil, err + } + + return &token, nil +} + // ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment // using the specified host and optional TLS configuration. +// It uses a new Http.Client for each operation. func ExecutePingOperation(host string, tlsConfig *tls.Config) (bool, error) { transport := &http.Transport{} diff --git a/api/http/handler/azure.go b/api/http/handler/azure.go new file mode 100644 index 000000000..a372244a1 --- /dev/null +++ b/api/http/handler/azure.go @@ -0,0 +1,102 @@ +package handler + +import ( + "strconv" + + "github.com/portainer/portainer" + 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" +) + +// AzureHandler represents an HTTP API handler for proxying requests to the Azure API. +type AzureHandler struct { + *mux.Router + Logger *log.Logger + EndpointService portainer.EndpointService + EndpointGroupService portainer.EndpointGroupService + TeamMembershipService portainer.TeamMembershipService + ProxyManager *proxy.Manager +} + +// NewAzureHandler returns a new instance of AzureHandler. +func NewAzureHandler(bouncer *security.RequestBouncer) *AzureHandler { + h := &AzureHandler{ + Router: mux.NewRouter(), + Logger: log.New(os.Stderr, "", log.LstdFlags), + } + h.PathPrefix("/{id}/azure").Handler( + bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToAzureAPI))) + return h +} + +func (handler *AzureHandler) checkEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID) error { + memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(userID) + if err != nil { + return err + } + + group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID) + if err != nil { + return err + } + + if !security.AuthorizedEndpointAccess(endpoint, group, userID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} + +func (handler *AzureHandler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id := vars["id"] + + parsedID, err := strconv.Atoi(id) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger) + return + } + + endpointID := portainer.EndpointID(parsedID) + endpoint, err := handler.EndpointService.Endpoint(endpointID) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + tokenData, err := security.RetrieveTokenData(r) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + + if tokenData.Role != portainer.AdministratorRole { + err = handler.checkEndpointAccess(endpoint, tokenData.ID) + if err != nil && err == portainer.ErrEndpointAccessDenied { + httperror.WriteErrorResponse(w, err, http.StatusForbidden, handler.Logger) + return + } else if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetProxy(string(endpointID)) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger) + return + } + } + + http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) +} diff --git a/api/http/handler/endpoint.go b/api/http/handler/endpoint.go index 23c867303..972f766cf 100644 --- a/api/http/handler/endpoint.go +++ b/api/http/handler/endpoint.go @@ -80,6 +80,7 @@ type ( postEndpointPayload struct { name string url string + endpointType int publicURL string groupID int useTLS bool @@ -88,6 +89,9 @@ type ( caCert []byte cert []byte key []byte + azureApplicationID string + azureTenantID string + azureAuthenticationKey string } ) @@ -117,9 +121,46 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt return } + for i := range filteredEndpoints { + filteredEndpoints[i].AzureCredentials = portainer.AzureCredentials{} + } + encodeJSON(w, filteredEndpoints, handler.Logger) } +func (handler *EndpointHandler) createAzureEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { + credentials := portainer.AzureCredentials{ + ApplicationID: payload.azureApplicationID, + TenantID: payload.azureTenantID, + AuthenticationKey: payload.azureAuthenticationKey, + } + + httpClient := client.NewHTTPClient() + _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if err != nil { + return nil, err + } + + endpoint := &portainer.Endpoint{ + Name: payload.name, + URL: payload.url, + Type: portainer.AzureEnvironment, + GroupID: portainer.EndpointGroupID(payload.groupID), + PublicURL: payload.publicURL, + AuthorizedUsers: []portainer.UserID{}, + AuthorizedTeams: []portainer.TeamID{}, + Extensions: []portainer.EndpointExtension{}, + AzureCredentials: credentials, + } + + err = handler.EndpointService.CreateEndpoint(endpoint) + if err != nil { + return nil, err + } + + return endpoint, nil +} + func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { tlsConfig, err := crypto.CreateTLSConfigurationFromBytes(payload.caCert, payload.cert, payload.key, payload.skipTLSClientVerification, payload.skipTLSServerVerification) if err != nil { @@ -236,6 +277,10 @@ func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPay } func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*portainer.Endpoint, error) { + if portainer.EndpointType(payload.endpointType) == portainer.AzureEnvironment { + return handler.createAzureEndpoint(payload) + } + if payload.useTLS { return handler.createTLSSecuredEndpoint(payload) } @@ -245,11 +290,35 @@ func (handler *EndpointHandler) createEndpoint(payload *postEndpointPayload) (*p func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload, error) { payload := &postEndpointPayload{} payload.name = r.FormValue("Name") + + endpointType := r.FormValue("EndpointType") + + if payload.name == "" || endpointType == "" { + return nil, ErrInvalidRequestFormat + } + + parsedType, err := strconv.Atoi(endpointType) + if err != nil { + return nil, err + } + payload.url = r.FormValue("URL") + payload.endpointType = parsedType + + if portainer.EndpointType(payload.endpointType) != portainer.AzureEnvironment && payload.url == "" { + return nil, ErrInvalidRequestFormat + } + payload.publicURL = r.FormValue("PublicURL") - if payload.name == "" || payload.url == "" { - return nil, ErrInvalidRequestFormat + if portainer.EndpointType(payload.endpointType) == portainer.AzureEnvironment { + payload.azureApplicationID = r.FormValue("AzureApplicationID") + payload.azureTenantID = r.FormValue("AzureTenantID") + payload.azureAuthenticationKey = r.FormValue("AzureAuthenticationKey") + + if payload.azureApplicationID == "" || payload.azureTenantID == "" || payload.azureAuthenticationKey == "" { + return nil, ErrInvalidRequestFormat + } } rawGroupID := r.FormValue("GroupID") @@ -336,6 +405,8 @@ func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http return } + endpoint.AzureCredentials = portainer.AzureCredentials{} + encodeJSON(w, endpoint, handler.Logger) } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 77c6ce478..70e8f4e8b 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -30,6 +30,7 @@ type Handler struct { SettingsHandler *SettingsHandler TemplatesHandler *TemplatesHandler DockerHandler *DockerHandler + AzureHandler *AzureHandler WebSocketHandler *WebSocketHandler UploadHandler *UploadHandler FileHandler *FileHandler @@ -64,6 +65,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api/endpoints", h.StoridgeHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/extensions"): http.StripPrefix("/api/endpoints", h.ExtensionHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/azure/"): + http.StripPrefix("/api/endpoints", h.AzureHandler).ServeHTTP(w, r) default: http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r) } diff --git a/api/http/proxy/azure_transport.go b/api/http/proxy/azure_transport.go new file mode 100644 index 000000000..dccf451b5 --- /dev/null +++ b/api/http/proxy/azure_transport.go @@ -0,0 +1,81 @@ +package proxy + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" +) + +type ( + azureAPIToken struct { + value string + expirationTime time.Time + } + + // AzureTransport represents a transport used when executing HTTP requests + // against the Azure API. + AzureTransport struct { + credentials *portainer.AzureCredentials + client *client.HTTPClient + token *azureAPIToken + mutex sync.Mutex + } +) + +// NewAzureTransport returns a pointer to an AzureTransport instance. +func NewAzureTransport(credentials *portainer.AzureCredentials) *AzureTransport { + return &AzureTransport{ + credentials: credentials, + client: client.NewHTTPClient(), + } +} + +func (transport *AzureTransport) authenticate() error { + token, err := transport.client.ExecuteAzureAuthenticationRequest(transport.credentials) + if err != nil { + return err + } + + expiresOn, err := strconv.ParseInt(token.ExpiresOn, 10, 64) + if err != nil { + return err + } + + transport.token = &azureAPIToken{ + value: token.AccessToken, + expirationTime: time.Unix(expiresOn, 0), + } + + return nil +} + +func (transport *AzureTransport) retrieveAuthenticationToken() error { + transport.mutex.Lock() + defer transport.mutex.Unlock() + + if transport.token == nil { + return transport.authenticate() + } + + timeLimit := time.Now().Add(-5 * time.Minute) + if timeLimit.After(transport.token.expirationTime) { + return transport.authenticate() + } + + return nil +} + +// RoundTrip is the implementation of the Transport interface. +func (transport *AzureTransport) RoundTrip(request *http.Request) (*http.Response, error) { + err := transport.retrieveAuthenticationToken() + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+transport.token.value) + return http.DefaultTransport.RoundTrip(request) +} diff --git a/api/http/proxy/transport.go b/api/http/proxy/docker_transport.go similarity index 100% rename from api/http/proxy/transport.go rename to api/http/proxy/docker_transport.go diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 8f952f2dc..70ba4543d 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -10,6 +10,8 @@ import ( "github.com/portainer/portainer/crypto" ) +const azureAPIBaseURL = "https://management.azure.com" + // proxyFactory is a factory to create reverse proxies to Docker endpoints type proxyFactory struct { ResourceControlService portainer.ResourceControlService @@ -20,11 +22,23 @@ type proxyFactory struct { SignatureService portainer.DigitalSignatureService } -func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler { +func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { u.Scheme = "http" return newSingleHostReverseProxyWithHostHeader(u) } +func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { + url, err := url.Parse(azureAPIBaseURL) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(url) + proxy.Transport = NewAzureTransport(credentials) + + return proxy, nil +} + func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool) (http.Handler, error) { u.Scheme = "https" diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 2a9018102..f2a691634 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -44,33 +44,39 @@ func NewManager(parameters *ManagerParams) *Manager { } } -// CreateAndRegisterProxy creates a new HTTP reverse proxy and adds it to the registered proxies. -// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. -func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - var proxy http.Handler +func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration) (http.Handler, error) { + if endpointURL.Scheme == "tcp" { + if tlsConfig.TLS || tlsConfig.TLSSkipVerify { + return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, tlsConfig, false) + } + return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false), nil + } + // Assume unix:// scheme + return manager.proxyFactory.newDockerSocketProxy(endpointURL.Path), nil +} +func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) { endpointURL, err := url.Parse(endpoint.URL) if err != nil { return nil, err } - enableSignature := false - if endpoint.Type == portainer.AgentOnDockerEnvironment { - enableSignature = true + switch endpoint.Type { + case portainer.AgentOnDockerEnvironment: + return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, true) + case portainer.AzureEnvironment: + return newAzureProxy(&endpoint.AzureCredentials) + default: + return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig) } +} - if endpointURL.Scheme == "tcp" { - if endpoint.TLSConfig.TLS { - proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, enableSignature) - if err != nil { - return nil, err - } - } else { - proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL, enableSignature) - } - } else { - // Assume unix:// scheme - proxy = manager.proxyFactory.newDockerSocketProxy(endpointURL.Path) +// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + proxy, err := manager.createProxy(endpoint) + if err != nil { + return nil, err } manager.proxies.Set(string(endpoint.ID), proxy) @@ -99,7 +105,7 @@ func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL str return nil, err } - proxy := manager.proxyFactory.newExtensionHTTPPRoxy(extensionURL) + proxy := manager.proxyFactory.newHTTPProxy(extensionURL) manager.extensionProxies.Set(key, proxy) return proxy, nil } diff --git a/api/http/proxy/reverse_proxy.go b/api/http/proxy/reverse_proxy.go index 4862de9a9..47e71b63e 100644 --- a/api/http/proxy/reverse_proxy.go +++ b/api/http/proxy/reverse_proxy.go @@ -7,7 +7,7 @@ import ( "strings" ) -// NewSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy +// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy // from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host // HTTP header, which NewSingleHostReverseProxy deliberately preserves. func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy { diff --git a/api/http/server.go b/api/http/server.go index 5e85f8efa..c32374e20 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -88,6 +88,11 @@ func (server *Server) Start() error { dockerHandler.EndpointGroupService = server.EndpointGroupService dockerHandler.TeamMembershipService = server.TeamMembershipService dockerHandler.ProxyManager = proxyManager + var azureHandler = handler.NewAzureHandler(requestBouncer) + azureHandler.EndpointService = server.EndpointService + azureHandler.EndpointGroupService = server.EndpointGroupService + azureHandler.TeamMembershipService = server.TeamMembershipService + azureHandler.ProxyManager = proxyManager var websocketHandler = handler.NewWebSocketHandler() websocketHandler.EndpointService = server.EndpointService websocketHandler.SignatureService = server.SignatureService @@ -140,6 +145,7 @@ func (server *Server) Start() error { StackHandler: stackHandler, TemplatesHandler: templatesHandler, DockerHandler: dockerHandler, + AzureHandler: azureHandler, WebSocketHandler: websocketHandler, FileHandler: fileHandler, UploadHandler: uploadHandler, diff --git a/api/portainer.go b/api/portainer.go index 1f2caeb9b..413b37d39 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -175,16 +175,17 @@ type ( // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { - ID EndpointID `json:"Id"` - Name string `json:"Name"` - Type EndpointType `json:"Type"` - URL string `json:"URL"` - GroupID EndpointGroupID `json:"GroupId"` - PublicURL string `json:"PublicURL"` - TLSConfig TLSConfiguration `json:"TLSConfig"` - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` - Extensions []EndpointExtension `json:"Extensions"` + ID EndpointID `json:"Id"` + Name string `json:"Name"` + Type EndpointType `json:"Type"` + URL string `json:"URL"` + GroupID EndpointGroupID `json:"GroupId"` + PublicURL string `json:"PublicURL"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + Extensions []EndpointExtension `json:"Extensions"` + AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` // Deprecated fields // Deprecated in DBVersion == 4 @@ -194,6 +195,14 @@ type ( TLSKeyPath string `json:"TLSKey,omitempty"` } + // AzureCredentials represents the credentials used to connect to an Azure + // environment. + AzureCredentials struct { + ApplicationID string `json:"ApplicationID"` + TenantID string `json:"TenantID"` + AuthenticationKey string `json:"AuthenticationKey"` + } + // EndpointGroupID represents an endpoint group identifier. EndpointGroupID int @@ -530,4 +539,6 @@ const ( DockerEnvironment // AgentOnDockerEnvironment represents an endpoint connected to a Portainer agent deployed on a Docker environment AgentOnDockerEnvironment + // AzureEnvironment represents an endpoint connected to an Azure environment + AzureEnvironment ) diff --git a/app/__module.js b/app/__module.js index f5ebe25a2..a1c438489 100644 --- a/app/__module.js +++ b/app/__module.js @@ -18,6 +18,7 @@ angular.module('portainer', [ 'portainer.templates', 'portainer.app', 'portainer.agent', + 'portainer.azure', 'portainer.docker', 'extension.storidge', 'rzModule']); diff --git a/app/azure/_module.js b/app/azure/_module.js new file mode 100644 index 000000000..0879e9a54 --- /dev/null +++ b/app/azure/_module.js @@ -0,0 +1,48 @@ +angular.module('portainer.azure', ['portainer.app']) +.config(['$stateRegistryProvider', function ($stateRegistryProvider) { + 'use strict'; + + var azure = { + name: 'azure', + parent: 'root', + abstract: true + }; + + var containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + templateUrl: 'app/azure/views/containerinstances/containerinstances.html', + controller: 'AzureContainerInstancesController' + } + } + }; + + var containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + templateUrl: 'app/azure/views/containerinstances/create/createcontainerinstance.html', + controller: 'AzureCreateContainerInstanceController' + } + } + }; + + var dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + templateUrl: 'app/azure/views/dashboard/dashboard.html', + controller: 'AzureDashboardController' + } + } + }; + + $stateRegistryProvider.register(azure); + $stateRegistryProvider.register(containerInstances); + $stateRegistryProvider.register(containerInstanceCreation); + $stateRegistryProvider.register(dashboard); +}]); diff --git a/app/azure/components/azure-sidebar-content/azure-sidebar-content.js b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js new file mode 100644 index 000000000..d1f9230f4 --- /dev/null +++ b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js @@ -0,0 +1,3 @@ +angular.module('portainer.azure').component('azureSidebarContent', { + templateUrl: 'app/azure/components/azure-sidebar-content/azureSidebarContent.html' +}); diff --git a/app/azure/components/azure-sidebar-content/azureSidebarContent.html b/app/azure/components/azure-sidebar-content/azureSidebarContent.html new file mode 100644 index 000000000..01986e8e7 --- /dev/null +++ b/app/azure/components/azure-sidebar-content/azureSidebarContent.html @@ -0,0 +1,6 @@ + + diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html new file mode 100644 index 000000000..c87f74735 --- /dev/null +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -0,0 +1,104 @@ +
+ + +
+
+ {{ $ctrl.title }} +
+
+ + Search + +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + + + Location + + + + + Published Ports +
+ + + + + {{ item.Name | truncate:50 }} + {{ item.Location }} + + :{{ p.port }} + + - +
Loading...
No container available.
+
+ +
+
+
diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js new file mode 100644 index 000000000..c83550ca5 --- /dev/null +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js @@ -0,0 +1,14 @@ +angular.module('portainer.azure').component('containergroupsDatatable', { + templateUrl: 'app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + title: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + showTextFilter: '<', + removeAction: '<' + } +}); diff --git a/app/azure/models/container_group.js b/app/azure/models/container_group.js new file mode 100644 index 000000000..0ba06a690 --- /dev/null +++ b/app/azure/models/container_group.js @@ -0,0 +1,66 @@ +function ContainerGroupDefaultModel() { + this.Location = ''; + this.OSType = 'Linux'; + this.Name = ''; + this.Image = ''; + this.AllocatePublicIP = true; + this.Ports = [ + { + container: 80, + host: 80, + protocol: 'TCP' + } + ]; + this.CPU = 1; + this.Memory = 1; +} + +function ContainerGroupViewModel(data, subscriptionId, resourceGroupName) { + this.Id = data.id; + this.Name = data.name; + this.Location = data.location; + this.IPAddress = data.properties.ipAddress.ip; + this.Ports = data.properties.ipAddress.ports; +} + +function CreateContainerGroupRequest(model) { + this.location = model.Location; + + var containerPorts = []; + var addressPorts = []; + for (var i = 0; i < model.Ports.length; i++) { + var binding = model.Ports[i]; + + containerPorts.push({ + port: binding.container + }); + + addressPorts.push({ + port: binding.host, + protocol: binding.protocol + }); + } + + this.properties = { + osType: model.OSType, + containers: [ + { + name: model.Name, + properties: { + image: model.Image, + ports: containerPorts, + resources: { + requests: { + cpu: model.CPU, + memoryInGB: model.Memory + } + } + } + } + ], + ipAddress: { + type: model.AllocatePublicIP ? 'Public': 'Private', + ports: addressPorts + } + }; +} diff --git a/app/azure/models/location.js b/app/azure/models/location.js new file mode 100644 index 000000000..a010776ba --- /dev/null +++ b/app/azure/models/location.js @@ -0,0 +1,6 @@ +function LocationViewModel(data) { + this.Id = data.id; + this.SubscriptionId = data.subscriptionId; + this.DisplayName = data.displayName; + this.Name = data.name; +} diff --git a/app/azure/models/provider.js b/app/azure/models/provider.js new file mode 100644 index 000000000..48cca79e9 --- /dev/null +++ b/app/azure/models/provider.js @@ -0,0 +1,7 @@ +function ContainerInstanceProviderViewModel(data) { + this.Id = data.id; + this.Namespace = data.namespace; + + var containerGroupType = _.find(data.resourceTypes, { 'resourceType': 'containerGroups' }); + this.Locations = containerGroupType.locations; +} diff --git a/app/azure/models/resource_group.js b/app/azure/models/resource_group.js new file mode 100644 index 000000000..aa04f1809 --- /dev/null +++ b/app/azure/models/resource_group.js @@ -0,0 +1,6 @@ +function ResourceGroupViewModel(data, subscriptionId) { + this.Id = data.id; + this.SubscriptionId = subscriptionId; + this.Name = data.name; + this.Location = data.location; +} diff --git a/app/azure/models/subscription.js b/app/azure/models/subscription.js new file mode 100644 index 000000000..2baa0da4d --- /dev/null +++ b/app/azure/models/subscription.js @@ -0,0 +1,4 @@ +function SubscriptionViewModel(data) { + this.Id = data.subscriptionId; + this.Name = data.displayName; +} diff --git a/app/azure/rest/azure.js b/app/azure/rest/azure.js new file mode 100644 index 000000000..2621c7d50 --- /dev/null +++ b/app/azure/rest/azure.js @@ -0,0 +1,17 @@ +angular.module('portainer.azure') +.factory('Azure', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function AzureFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + + var service = {}; + + service.delete = function(id, apiVersion) { + var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/azure' + id + '?api-version=' + apiVersion; + return $http({ + method: 'DELETE', + url: url + }); + }; + + return service; +}]); diff --git a/app/azure/rest/container_group.js b/app/azure/rest/container_group.js new file mode 100644 index 000000000..76ed5c4dc --- /dev/null +++ b/app/azure/rest/container_group.js @@ -0,0 +1,41 @@ +angular.module('portainer.azure') +.factory('ContainerGroup', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ContainerGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + + var resource = {}; + + var base = $resource( + API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/providers/Microsoft.ContainerInstance/containerGroups', + { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-04-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + } + ); + + var withResourceGroup = $resource( + API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/resourceGroups/:resourceGroupName/providers/Microsoft.ContainerInstance/containerGroups/:containerGroupName', + { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-04-01' + }, + { + create: { + method: 'PUT', + params: { + subscriptionId: '@subscriptionId', + resourceGroupName: '@resourceGroupName', + containerGroupName: '@containerGroupName' + } + } + } + ); + + resource.query = base.query; + resource.create = withResourceGroup.create; + + return resource; +}]); diff --git a/app/azure/rest/location.js b/app/azure/rest/location.js new file mode 100644 index 000000000..9516761c6 --- /dev/null +++ b/app/azure/rest/location.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Location', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function LocationFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/locations', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2016-06-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + }); +}]); diff --git a/app/azure/rest/provider.js b/app/azure/rest/provider.js new file mode 100644 index 000000000..e1a848182 --- /dev/null +++ b/app/azure/rest/provider.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Provider', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ProviderFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/providers/:providerNamespace', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-02-01' + }, + { + get: { method: 'GET', params: { subscriptionId: '@subscriptionId', providerNamespace: '@providerNamespace' } } + }); +}]); diff --git a/app/azure/rest/resource_group.js b/app/azure/rest/resource_group.js new file mode 100644 index 000000000..2147682e3 --- /dev/null +++ b/app/azure/rest/resource_group.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('ResourceGroup', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function ResourceGroupFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS +'/:endpointId/azure/subscriptions/:subscriptionId/resourcegroups', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2018-02-01' + }, + { + query: { method: 'GET', params: { subscriptionId: '@subscriptionId' } } + }); +}]); diff --git a/app/azure/rest/subscription.js b/app/azure/rest/subscription.js new file mode 100644 index 000000000..5b30974c6 --- /dev/null +++ b/app/azure/rest/subscription.js @@ -0,0 +1,12 @@ +angular.module('portainer.azure') +.factory('Subscription', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', +function SubscriptionFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/azure/subscriptions', { + 'endpointId': EndpointProvider.endpointID, + 'api-version': '2016-06-01' + }, + { + query: { method: 'GET' } + }); +}]); diff --git a/app/azure/services/azureService.js b/app/azure/services/azureService.js new file mode 100644 index 000000000..8d7def765 --- /dev/null +++ b/app/azure/services/azureService.js @@ -0,0 +1,66 @@ +angular.module('portainer.azure') +.factory('AzureService', ['$q', 'Azure', 'SubscriptionService', 'ResourceGroupService', 'ContainerGroupService', 'ProviderService', +function AzureServiceFactory($q, Azure, SubscriptionService, ResourceGroupService, ContainerGroupService, ProviderService) { + 'use strict'; + var service = {}; + + service.deleteContainerGroup = function(id) { + return Azure.delete(id, '2018-04-01'); + }; + + service.createContainerGroup = function(model, subscriptionId, resourceGroupName) { + return ContainerGroupService.create(model, subscriptionId, resourceGroupName); + }; + + service.subscriptions = function() { + return SubscriptionService.subscriptions(); + }; + + service.containerInstanceProvider = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ProviderService.containerInstanceProvider); + }; + + service.resourceGroups = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ResourceGroupService.resourceGroups); + }; + + service.containerGroups = function(subscriptions) { + return retrieveResourcesForEachSubscription(subscriptions, ContainerGroupService.containerGroups); + }; + + service.aggregate = function(resourcesBySubcription) { + var aggregatedResources = []; + Object.keys(resourcesBySubcription).forEach(function(key, index) { + aggregatedResources = aggregatedResources.concat(resourcesBySubcription[key]); + }); + return aggregatedResources; + }; + + function retrieveResourcesForEachSubscription(subscriptions, resourceQuery) { + var deferred = $q.defer(); + + var resources = {}; + + var resourceQueries = []; + for (var i = 0; i < subscriptions.length; i++) { + var subscription = subscriptions[i]; + resourceQueries.push(resourceQuery(subscription.Id)); + } + + $q.all(resourceQueries) + .then(function success(data) { + for (var i = 0; i < data.length; i++) { + var result = data[i]; + resources[subscriptions[i].Id] = result; + } + deferred.resolve(resources); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve resources', err: err }); + }); + + return deferred.promise; + } + + return service; +}]); diff --git a/app/azure/services/containerGroupService.js b/app/azure/services/containerGroupService.js new file mode 100644 index 000000000..96031a587 --- /dev/null +++ b/app/azure/services/containerGroupService.js @@ -0,0 +1,33 @@ +angular.module('portainer.azure') +.factory('ContainerGroupService', ['$q', 'ContainerGroup', function ContainerGroupServiceFactory($q, ContainerGroup) { + 'use strict'; + var service = {}; + + service.containerGroups = function(subscriptionId) { + var deferred = $q.defer(); + + ContainerGroup.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var containerGroups = data.value.map(function (item) { + return new ContainerGroupViewModel(item); + }); + deferred.resolve(containerGroups); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve container groups', err: err }); + }); + + return deferred.promise; + }; + + service.create = function(model, subscriptionId, resourceGroupName) { + var payload = new CreateContainerGroupRequest(model); + return ContainerGroup.create({ + subscriptionId: subscriptionId, + resourceGroupName: resourceGroupName, + containerGroupName: model.Name + }, payload).$promise; + }; + + return service; +}]); diff --git a/app/azure/services/locationService.js b/app/azure/services/locationService.js new file mode 100644 index 000000000..547ed93b3 --- /dev/null +++ b/app/azure/services/locationService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('LocationService', ['$q', 'Location', function LocationServiceFactory($q, Location) { + 'use strict'; + var service = {}; + + service.locations = function(subscriptionId) { + var deferred = $q.defer(); + + Location.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var locations = data.value.map(function (item) { + return new LocationViewModel(item); + }); + deferred.resolve(locations); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve locations', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/providerService.js b/app/azure/services/providerService.js new file mode 100644 index 000000000..88451d4f5 --- /dev/null +++ b/app/azure/services/providerService.js @@ -0,0 +1,22 @@ +angular.module('portainer.azure') +.factory('ProviderService', ['$q', 'Provider', function ProviderServiceFactory($q, Provider) { + 'use strict'; + var service = {}; + + service.containerInstanceProvider = function(subscriptionId) { + var deferred = $q.defer(); + + Provider.get({ subscriptionId: subscriptionId, providerNamespace: 'Microsoft.ContainerInstance' }).$promise + .then(function success(data) { + var provider = new ContainerInstanceProviderViewModel(data); + deferred.resolve(provider); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve provider', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/resourceGroupService.js b/app/azure/services/resourceGroupService.js new file mode 100644 index 000000000..1777edea8 --- /dev/null +++ b/app/azure/services/resourceGroupService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('ResourceGroupService', ['$q', 'ResourceGroup', function ResourceGroupServiceFactory($q, ResourceGroup) { + 'use strict'; + var service = {}; + + service.resourceGroups = function(subscriptionId) { + var deferred = $q.defer(); + + ResourceGroup.query({ subscriptionId: subscriptionId }).$promise + .then(function success(data) { + var resourceGroups = data.value.map(function (item) { + return new ResourceGroupViewModel(item, subscriptionId); + }); + deferred.resolve(resourceGroups); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve resource groups', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/services/subscriptionService.js b/app/azure/services/subscriptionService.js new file mode 100644 index 000000000..f468e1c8e --- /dev/null +++ b/app/azure/services/subscriptionService.js @@ -0,0 +1,24 @@ +angular.module('portainer.azure') +.factory('SubscriptionService', ['$q', 'Subscription', function SubscriptionServiceFactory($q, Subscription) { + 'use strict'; + var service = {}; + + service.subscriptions = function() { + var deferred = $q.defer(); + + Subscription.query({}).$promise + .then(function success(data) { + var subscriptions = data.value.map(function (item) { + return new SubscriptionViewModel(item); + }); + deferred.resolve(subscriptions); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve subscriptions', err: err }); + }); + + return deferred.promise; + }; + + return service; +}]); diff --git a/app/azure/views/containerinstances/containerInstancesController.js b/app/azure/views/containerinstances/containerInstancesController.js new file mode 100644 index 000000000..ecf2c40cd --- /dev/null +++ b/app/azure/views/containerinstances/containerInstancesController.js @@ -0,0 +1,41 @@ +angular.module('portainer.azure') +.controller('AzureContainerInstancesController', ['$scope', '$state', 'AzureService', 'Notifications', +function ($scope, $state, AzureService, Notifications) { + + function initView() { + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + return AzureService.containerGroups(subscriptions); + }) + .then(function success(data) { + $scope.containerGroups = AzureService.aggregate(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load container groups'); + }); + } + + $scope.deleteAction = function (selectedItems) { + var actionCount = selectedItems.length; + angular.forEach(selectedItems, function (item) { + AzureService.deleteContainerGroup(item.Id) + .then(function success() { + Notifications.success('Container group successfully removed', item.Name); + var index = $scope.containerGroups.indexOf(item); + $scope.containerGroups.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove container group'); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + }; + + initView(); +}]); diff --git a/app/azure/views/containerinstances/containerinstances.html b/app/azure/views/containerinstances/containerinstances.html new file mode 100644 index 000000000..489d6b807 --- /dev/null +++ b/app/azure/views/containerinstances/containerinstances.html @@ -0,0 +1,19 @@ + + + + + + + Container instances + + +
+
+ +
+
diff --git a/app/azure/views/containerinstances/create/createContainerInstanceController.js b/app/azure/views/containerinstances/create/createContainerInstanceController.js new file mode 100644 index 000000000..b3a7ed173 --- /dev/null +++ b/app/azure/views/containerinstances/create/createContainerInstanceController.js @@ -0,0 +1,87 @@ +angular.module('portainer.azure') +.controller('AzureCreateContainerInstanceController', ['$q', '$scope', '$state', 'AzureService', 'Notifications', +function ($q, $scope, $state, AzureService, Notifications) { + + var allResourceGroups = []; + var allProviders = []; + + $scope.state = { + actionInProgress: false, + selectedSubscription: null, + selectedResourceGroup: null + }; + + $scope.changeSubscription = function() { + var selectedSubscription = $scope.state.selectedSubscription; + updateResourceGroupsAndLocations(selectedSubscription, allResourceGroups, allProviders); + }; + + $scope.addPortBinding = function() { + $scope.model.Ports.push({ host: '', container: '', protocol: 'TCP' }); + }; + + $scope.removePortBinding = function(index) { + $scope.model.Ports.splice(index, 1); + }; + + $scope.create = function() { + var model = $scope.model; + var subscriptionId = $scope.state.selectedSubscription.Id; + var resourceGroupName = $scope.state.selectedResourceGroup.Name; + + $scope.state.actionInProgress = true; + AzureService.createContainerGroup(model, subscriptionId, resourceGroupName) + .then(function success(data) { + Notifications.success('Container successfully created', model.Name); + $state.go('azure.containerinstances'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create container'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + function updateResourceGroupsAndLocations(subscription, resourceGroups, providers) { + $scope.state.selectedResourceGroup = resourceGroups[subscription.Id][0]; + $scope.resourceGroups = resourceGroups[subscription.Id]; + + var currentSubLocations = providers[subscription.Id].Locations; + $scope.model.Location = currentSubLocations[0]; + $scope.locations = currentSubLocations; + } + + function initView() { + var model = new ContainerGroupDefaultModel(); + + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + $scope.state.selectedSubscription = subscriptions[0]; + $scope.subscriptions = subscriptions; + + return $q.all({ + resourceGroups: AzureService.resourceGroups(subscriptions), + containerInstancesProviders: AzureService.containerInstanceProvider(subscriptions) + }); + }) + .then(function success(data) { + var resourceGroups = data.resourceGroups; + allResourceGroups = resourceGroups; + + var containerInstancesProviders = data.containerInstancesProviders; + allProviders = containerInstancesProviders; + + $scope.model = model; + + var selectedSubscription = $scope.state.selectedSubscription; + updateResourceGroupsAndLocations(selectedSubscription, resourceGroups, containerInstancesProviders); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve Azure resources'); + }); + } + + initView(); +}]); diff --git a/app/azure/views/containerinstances/create/createcontainerinstance.html b/app/azure/views/containerinstances/create/createcontainerinstance.html new file mode 100644 index 000000000..001461341 --- /dev/null +++ b/app/azure/views/containerinstances/create/createcontainerinstance.html @@ -0,0 +1,160 @@ + + + + Container instances > Add container + + + +
+
+ + +
+
+ Azure settings +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+ Container configuration +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ + + map additional port + +
+ +
+
+ +
+ host + +
+ + + + + +
+ container + +
+ + +
+
+ + +
+ +
+ +
+
+ +
+ + +
+
+ + +
+
+ +
+ Container resources +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ Actions +
+
+
+ +
+
+ +
+
+
+
+
diff --git a/app/azure/views/dashboard/dashboard.html b/app/azure/views/dashboard/dashboard.html new file mode 100644 index 000000000..5726304c1 --- /dev/null +++ b/app/azure/views/dashboard/dashboard.html @@ -0,0 +1,33 @@ + + + Dashboard + + +
+ + +
diff --git a/app/azure/views/dashboard/dashboardController.js b/app/azure/views/dashboard/dashboardController.js new file mode 100644 index 000000000..f24ff4e29 --- /dev/null +++ b/app/azure/views/dashboard/dashboardController.js @@ -0,0 +1,21 @@ +angular.module('portainer.azure') +.controller('AzureDashboardController', ['$scope', 'AzureService', 'Notifications', +function ($scope, AzureService, Notifications) { + + function initView() { + AzureService.subscriptions() + .then(function success(data) { + var subscriptions = data; + $scope.subscriptions = subscriptions; + return AzureService.resourceGroups(subscriptions); + }) + .then(function success(data) { + $scope.resourceGroups = AzureService.aggregate(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to load dashboard data'); + }); + } + + initView(); +}]); diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index a0a2f4e60..ad0f261a8 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -144,7 +144,7 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co $scope.state.joinNetworkInProgress = false; }); }; - + $scope.commit = function () { var image = $scope.config.Image; var registry = $scope.config.Registry; diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index 85e15aa4f..9db9f4165 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -70,7 +70,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { service.createLocalEndpoint = function() { var deferred = $q.defer(); - FileUploadService.createEndpoint('local', 'unix:///var/run/docker.sock', '', 1, false) + FileUploadService.createEndpoint('local', 1, 'unix:///var/run/docker.sock', '', 1, false) .then(function success(response) { deferred.resolve(response.data); }) @@ -81,10 +81,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; - service.createRemoteEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createRemoteEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var deferred = $q.defer(); - FileUploadService.createEndpoint(name, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + FileUploadService.createEndpoint(name, type, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(response) { deferred.resolve(response.data); }) @@ -95,5 +95,19 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) { return deferred.promise; }; + service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey) { + var deferred = $q.defer(); + + FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey) + .then(function success(response) { + deferred.resolve(response.data); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to connect to Azure', err: err}); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index d2c2120d6..c5413149c 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -42,11 +42,12 @@ angular.module('portainer.app') }); }; - service.createEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + service.createEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { return Upload.upload({ url: 'api/endpoints', data: { Name: name, + EndpointType: type, URL: URL, PublicURL: PublicURL, GroupID: groupID, @@ -61,6 +62,20 @@ angular.module('portainer.app') }); }; + service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey) { + return Upload.upload({ + url: 'api/endpoints', + data: { + Name: name, + EndpointType: 3, + AzureApplicationID: applicationId, + AzureTenantID: tenantId, + AzureAuthenticationKey: authenticationKey + }, + ignoreLoadingBar: true + }); + }; + service.uploadLDAPTLSFiles = function(TLSCAFile, TLSCertFile, TLSKeyFile) { var queue = []; diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index c7322a4c3..f50101a94 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -128,6 +128,14 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin if (loading) { state.loading = true; } + + if (type === 3) { + state.endpoint.mode = { provider: 'AZURE' }; + LocalStorage.storeEndpointState(state.endpoint); + deferred.resolve(); + return deferred.promise; + } + $q.all({ version: SystemService.version(), info: SystemService.info() diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 7b53dfe91..ab65f2163 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -13,12 +13,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica AuthenticationError: '' }; - function setActiveEndpointAndRedirectToDashboard(endpoint) { - var endpointID = EndpointProvider.endpointID(); - if (!endpointID) { - EndpointProvider.setEndpointID(endpoint.Id); - } - + function redirectToDockerDashboard(endpoint) { ExtensionManager.initEndpointExtensions(endpoint.Id) .then(function success(data) { var extensions = data; @@ -32,12 +27,31 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica }); } + function redirectToAzureDashboard(endpoint) { + StateManager.updateEndpointState(false, endpoint.Type, []) + .then(function success(data) { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); + }); + } + + function redirectToDashboard(endpoint) { + EndpointProvider.setEndpointID(endpoint.Id); + + if (endpoint.Type === 3) { + return redirectToAzureDashboard(endpoint); + } + redirectToDockerDashboard(endpoint); + } + function unauthenticatedFlow() { EndpointService.endpoints() .then(function success(data) { var endpoints = data; if (endpoints.length > 0) { - setActiveEndpointAndRedirectToDashboard(endpoints[0]); + redirectToDashboard(endpoints[0]); } else { $state.go('portainer.init.endpoint'); } @@ -79,7 +93,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica var endpoints = data; var userDetails = Authentication.getUserDetails(); if (endpoints.length > 0) { - setActiveEndpointAndRedirectToDashboard(endpoints[0]); + redirectToDashboard(endpoints[0]); } else if (endpoints.length === 0 && userDetails.role === 1) { $state.go('portainer.init.endpoint'); } else if (endpoints.length === 0 && userDetails.role === 2) { diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js index bda06746d..3418f251a 100644 --- a/app/portainer/views/endpoints/create/createEndpointController.js +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -12,7 +12,10 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) URL: '', PublicURL: '', GroupId: 1, - SecurityFormData: new EndpointSecurityFormData() + SecurityFormData: new EndpointSecurityFormData(), + AzureApplicationId: '', + AzureTenantId: '', + AzureAuthenticationKey: '' }; $scope.addDockerEndpoint = function() { @@ -30,7 +33,7 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert; var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey; - addEndpoint(name, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + addEndpoint(name, 1, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); }; $scope.addAgentEndpoint = function() { @@ -39,12 +42,38 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications) var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL; var groupId = $scope.formValues.GroupId; - addEndpoint(name, URL, publicURL, groupId, true, true, true, null, null, null); + addEndpoint(name, 2, URL, publicURL, groupId, true, true, true, null, null, null); }; - function addEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + $scope.addAzureEndpoint = function() { + var name = $scope.formValues.Name; + var applicationId = $scope.formValues.AzureApplicationId; + var tenantId = $scope.formValues.AzureTenantId; + var authenticationKey = $scope.formValues.AzureAuthenticationKey; + + createAzureEndpoint(name, applicationId, tenantId, authenticationKey); + }; + + function createAzureEndpoint(name, applicationId, tenantId, authenticationKey) { + var endpoint; + $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey) + .then(function success() { + Notifications.success('Endpoint created', name); + $state.go('portainer.endpoints', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create endpoint'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function addEndpoint(name, type, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { + $scope.state.actionInProgress = true; + EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success() { Notifications.success('Endpoint created', name); $state.go('portainer.endpoints', {}, {reload: true}); diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index 1ab647c4b..e40976bcb 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -36,6 +36,16 @@

Portainer agent

+
+ + +
@@ -59,6 +69,28 @@
+
+
+ Information +
+
+
+ +

+ This feature is experimental. +

+

+ Connect to Microsoft Azure to manage Azure Container Instances (ACI). +

+

+ + Have a look at the Azure documentation to retrieve + the credentials required below. +

+
+
+
+
Environment details
@@ -78,35 +110,88 @@ -
- -
- - +
+
+ +
+ + +
-
-
-
-
-

This field is required.

+
+
+
+

This field is required.

+
-
- -
- +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+
diff --git a/app/portainer/views/init/endpoint/initEndpoint.html b/app/portainer/views/init/endpoint/initEndpoint.html index cbaeac7f5..957fd7010 100644 --- a/app/portainer/views/init/endpoint/initEndpoint.html +++ b/app/portainer/views/init/endpoint/initEndpoint.html @@ -1,7 +1,7 @@
-
+
@@ -55,6 +55,16 @@

Connect to a Portainer agent

+
+ + +
@@ -141,6 +151,78 @@
+ +
+
+ Information +
+
+
+ +

+ This feature is experimental. +

+

+ Connect to Microsoft Azure to manage Azure Container Instances (ACI). +

+

+ + Have a look at the Azure documentation to retrieve + the credentials required below. +

+
+
+
+
+ Environment +
+ +
+ +
+ +
+
+ +
+ Azure credentials +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+
diff --git a/app/portainer/views/init/endpoint/initEndpointController.js b/app/portainer/views/init/endpoint/initEndpointController.js index 67d9da7b1..6af86fbd3 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -22,7 +22,10 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif TLSSKipClientVerify: false, TLSCACert: null, TLSCert: null, - TLSKey: null + TLSKey: null, + AzureApplicationId: '', + AzureTenantId: '', + AzureAuthenticationKey: '' }; $scope.createLocalEndpoint = function() { @@ -52,12 +55,21 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif }); }; + $scope.createAzureEndpoint = function() { + var name = $scope.formValues.Name; + var applicationId = $scope.formValues.AzureApplicationId; + var tenantId = $scope.formValues.AzureTenantId; + var authenticationKey = $scope.formValues.AzureAuthenticationKey; + + createAzureEndpoint(name, applicationId, tenantId, authenticationKey); + }; + $scope.createAgentEndpoint = function() { var name = $scope.formValues.Name; var URL = $scope.formValues.URL; var PublicURL = URL.split(':')[0]; - createRemoteEndpoint(name, URL, PublicURL, true, true, true, null, null, null); + createRemoteEndpoint(name, 2, URL, PublicURL, true, true, true, null, null, null); }; $scope.createRemoteEndpoint = function() { @@ -71,13 +83,34 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif var TLSCertFile = TLSSKipClientVerify ? null : $scope.formValues.TLSCert; var TLSKeyFile = TLSSKipClientVerify ? null : $scope.formValues.TLSKey; - createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); + createRemoteEndpoint(name, 1, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); }; + function createAzureEndpoint(name, applicationId, tenantId, authenticationKey) { + var endpoint; + + $scope.state.actionInProgress = true; + EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey) + .then(function success(data) { + endpoint = data; + EndpointProvider.setEndpointID(endpoint.Id); + return StateManager.updateEndpointState(false, endpoint.Type, []); + }) + .then(function success(data) { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Azure environment'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + function createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { var endpoint; $scope.state.actionInProgress = true; - EndpointService.createRemoteEndpoint(name, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) + EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(data) { endpoint = data; EndpointProvider.setEndpointID(endpoint.Id); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index e182e5a01..f399ed454 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -15,7 +15,9 @@ select-endpoint="switchEndpoint" > - + +