diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go index e9c6e602b..7fedc0f6d 100644 --- a/api/cron/job_snapshot.go +++ b/api/cron/job_snapshot.go @@ -53,7 +53,7 @@ func (runner *SnapshotJobRunner) Run() { } for _, endpoint := range endpoints { - if endpoint.Type == portainer.EdgeAgentEnvironment { + if endpoint.Type == portainer.AzureEnvironment || endpoint.Type == portainer.EdgeAgentEnvironment { continue } diff --git a/api/docker/client.go b/api/docker/client.go index ce8d21ec1..c1bd7a8d0 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -35,7 +35,9 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService, revers // a specific endpoint configuration. The nodeName parameter can be used // with an agent enabled endpoint to target a specific node in an agent cluster. func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) { - if endpoint.Type == portainer.AgentOnDockerEnvironment { + if endpoint.Type == portainer.AzureEnvironment { + return nil, unsupportedEnvironmentType + } else if endpoint.Type == portainer.AgentOnDockerEnvironment { return createAgentClient(endpoint, factory.signatureService, nodeName) } else if endpoint.Type == portainer.EdgeAgentEnvironment { return createEdgeClient(endpoint, factory.reverseTunnelService, nodeName) diff --git a/api/errors.go b/api/errors.go index bc639d341..8e09838a1 100644 --- a/api/errors.go +++ b/api/errors.go @@ -39,6 +39,11 @@ const ( ErrEndpointAccessDenied = Error("Access denied to endpoint") ) +// Azure environment errors +const ( + ErrAzureInvalidCredentials = Error("Invalid Azure credentials") +) + // Endpoint group errors. const ( ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group") diff --git a/api/go.sum b/api/go.sum index d3eead3d3..621d3a831 100644 --- a/api/go.sum +++ b/api/go.sum @@ -171,7 +171,6 @@ github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2 h1:0PfgGLys9yH github.com/portainer/libcrypto v0.0.0-20190723020515-23ebe86ab2c2/go.mod h1:/wIeGwJOMYc1JplE/OvYMO5korce39HddIfI8VKGyAM= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 h1:H8HR2dHdBf8HANSkUyVw4o8+4tegGcd+zyKZ3e599II= github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33/go.mod h1:Y2TfgviWI4rT2qaOTHr+hq6MdKIE5YjgQAu7qwptTV0= -github.com/portainer/portainer v0.10.1 h1:I8K345CjGWfUGsVA8c8/gqamwLCC6CIAjxZXSklAFq0= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.1.0 h1:BQ53HtBmfOitExawJ6LokA4x8ov/z0SYYb0+HxJfRI8= diff --git a/api/http/client/client.go b/api/http/client/client.go index 0e6d9d41d..fb690105f 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -2,9 +2,12 @@ package client import ( "crypto/tls" + "encoding/json" + "fmt" "io/ioutil" "log" "net/http" + "net/url" "strings" "time" @@ -16,6 +19,55 @@ const ( defaultHTTPTimeout = 5 ) +// 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 * time.Duration(defaultHTTPTimeout), + }, + } +} + +// 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 +} + // Get executes a simple HTTP GET to the specified URL and returns // the content of the response body. Timeout can be specified via the timeout parameter, // will default to defaultHTTPTimeout if set to 0. diff --git a/api/http/handler/endpointproxy/handler.go b/api/http/handler/endpointproxy/handler.go index 394fa0b54..870f2de80 100644 --- a/api/http/handler/endpointproxy/handler.go +++ b/api/http/handler/endpointproxy/handler.go @@ -23,6 +23,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { Router: mux.NewRouter(), requestBouncer: bouncer, } + h.PathPrefix("/{id}/azure").Handler( + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToAzureAPI))) h.PathPrefix("/{id}/docker").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToDockerAPI))) h.PathPrefix("/{id}/storidge").Handler( diff --git a/api/http/handler/endpointproxy/proxy_azure.go b/api/http/handler/endpointproxy/proxy_azure.go new file mode 100644 index 000000000..a9e66b66b --- /dev/null +++ b/api/http/handler/endpointproxy/proxy_azure.go @@ -0,0 +1,43 @@ +package endpointproxy + +import ( + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer/api" + + "net/http" +) + +func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err} + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint, false) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetEndpointProxy(endpoint) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create proxy", err} + } + } + + id := strconv.Itoa(endpointID) + http.StripPrefix("/"+id+"/azure", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index d8a1bb898..677ce6222 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -18,19 +18,22 @@ import ( ) type endpointCreatePayload struct { - Name string - URL string - EndpointType int - PublicURL string - GroupID int - TLS bool - TLSSkipVerify bool - TLSSkipClientVerify bool - TLSCACertFile []byte - TLSCertFile []byte - TLSKeyFile []byte - TagIDs []portainer.TagID - EdgeCheckinInterval int + Name string + URL string + EndpointType int + PublicURL string + GroupID int + TLS bool + TLSSkipVerify bool + TLSSkipClientVerify bool + TLSCACertFile []byte + TLSCertFile []byte + TLSKeyFile []byte + AzureApplicationID string + AzureTenantID string + AzureAuthenticationKey string + TagIDs []portainer.TagID + EdgeCheckinInterval int } func (payload *endpointCreatePayload) Validate(r *http.Request) error { @@ -42,7 +45,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false) if err != nil || endpointType == 0 { - return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 4 (Edge Agent environment)") + return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge Agent environment)") } payload.EndpointType = endpointType @@ -94,14 +97,35 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { } } - endpointURL, err := request.RetrieveMultiPartFormValue(r, "URL", true) - if err != nil { - return portainer.Error("Invalid endpoint URL") - } - payload.URL = endpointURL + switch portainer.EndpointType(payload.EndpointType) { + case portainer.AzureEnvironment: + azureApplicationID, err := request.RetrieveMultiPartFormValue(r, "AzureApplicationID", false) + if err != nil { + return portainer.Error("Invalid Azure application ID") + } + payload.AzureApplicationID = azureApplicationID - publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) - payload.PublicURL = publicURL + azureTenantID, err := request.RetrieveMultiPartFormValue(r, "AzureTenantID", false) + if err != nil { + return portainer.Error("Invalid Azure tenant ID") + } + payload.AzureTenantID = azureTenantID + + azureAuthenticationKey, err := request.RetrieveMultiPartFormValue(r, "AzureAuthenticationKey", false) + if err != nil { + return portainer.Error("Invalid Azure authentication key") + } + payload.AzureAuthenticationKey = azureAuthenticationKey + default: + url, err := request.RetrieveMultiPartFormValue(r, "URL", true) + if err != nil { + return portainer.Error("Invalid endpoint URL") + } + payload.URL = url + + publicURL, _ := request.RetrieveMultiPartFormValue(r, "PublicURL", true) + payload.PublicURL = publicURL + } checkinInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "CheckinInterval", true) payload.EdgeCheckinInterval = checkinInterval @@ -158,7 +182,9 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) * } func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { - if portainer.EndpointType(payload.EndpointType) == portainer.EdgeAgentEnvironment { + if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment { + return handler.createAzureEndpoint(payload) + } else if portainer.EndpointType(payload.EndpointType) == portainer.EdgeAgentEnvironment { return handler.createEdgeAgentEndpoint(payload) } @@ -168,6 +194,44 @@ func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portain return handler.createUnsecuredEndpoint(payload) } +func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { + credentials := portainer.AzureCredentials{ + ApplicationID: payload.AzureApplicationID, + TenantID: payload.AzureTenantID, + AuthenticationKey: payload.AzureAuthenticationKey, + } + + httpClient := client.NewHTTPClient() + _, err := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", err} + } + + endpointID := handler.DataStore.Endpoint().GetNextIdentifier() + endpoint := &portainer.Endpoint{ + ID: portainer.EndpointID(endpointID), + Name: payload.Name, + URL: "https://management.azure.com", + Type: portainer.AzureEnvironment, + GroupID: portainer.EndpointGroupID(payload.GroupID), + PublicURL: payload.PublicURL, + UserAccessPolicies: portainer.UserAccessPolicies{}, + TeamAccessPolicies: portainer.TeamAccessPolicies{}, + Extensions: []portainer.EndpointExtension{}, + AzureCredentials: credentials, + TagIDs: payload.TagIDs, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.Snapshot{}, + } + + err = handler.saveEndpointAndUpdateAuthorizations(endpoint) + if err != nil { + return nil, &httperror.HandlerError{http.StatusInternalServerError, "An error occured while trying to create the endpoint", err} + } + + return endpoint, nil +} + func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { endpointType := portainer.EdgeAgentEnvironment endpointID := handler.DataStore.Endpoint().GetNextIdentifier() diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 21d6b8b0a..18182db17 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -23,6 +23,10 @@ func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } + if endpoint.Type == portainer.AzureEnvironment { + return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for Azure endpoints", err} + } + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(endpoint) latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go index 092dc5df1..33d6f30d0 100644 --- a/api/http/handler/endpoints/endpoint_snapshots.go +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -17,6 +17,10 @@ func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request } for _, endpoint := range endpoints { + if endpoint.Type == portainer.AzureEnvironment { + continue + } + snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint) latestEndpointReference, err := handler.DataStore.Endpoint().Endpoint(endpoint.ID) diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 4752086c3..766c09207 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -9,21 +9,25 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" ) type endpointUpdatePayload struct { - Name *string - URL *string - PublicURL *string - GroupID *int - TLS *bool - TLSSkipVerify *bool - TLSSkipClientVerify *bool - Status *int - TagIDs []portainer.TagID - UserAccessPolicies portainer.UserAccessPolicies - TeamAccessPolicies portainer.TeamAccessPolicies - EdgeCheckinInterval *int + Name *string + URL *string + PublicURL *string + GroupID *int + TLS *bool + TLSSkipVerify *bool + TLSSkipClientVerify *bool + Status *int + AzureApplicationID *string + AzureTenantID *string + AzureAuthenticationKey *string + TagIDs []portainer.TagID + UserAccessPolicies portainer.UserAccessPolicies + TeamAccessPolicies portainer.TeamAccessPolicies + EdgeCheckinInterval *int } func (payload *endpointUpdatePayload) Validate(r *http.Request) error { @@ -138,6 +142,26 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } + if endpoint.Type == portainer.AzureEnvironment { + credentials := endpoint.AzureCredentials + if payload.AzureApplicationID != nil { + credentials.ApplicationID = *payload.AzureApplicationID + } + if payload.AzureTenantID != nil { + credentials.TenantID = *payload.AzureTenantID + } + if payload.AzureAuthenticationKey != nil { + credentials.AuthenticationKey = *payload.AzureAuthenticationKey + } + + httpClient := client.NewHTTPClient() + _, authErr := httpClient.ExecuteAzureAuthenticationRequest(&credentials) + if authErr != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate against Azure", authErr} + } + endpoint.AzureCredentials = credentials + } + if payload.TLS != nil { folder := strconv.Itoa(endpointID) @@ -182,7 +206,7 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * } } - if payload.URL != nil || payload.TLS != nil { + if payload.URL != nil || payload.TLS != nil || endpoint.Type == portainer.AzureEnvironment { _, err = handler.ProxyManager.CreateAndRegisterEndpointProxy(endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 15e8a55c0..0fb6e5b06 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -12,6 +12,7 @@ import ( ) func hideFields(endpoint *portainer.Endpoint) { + endpoint.AzureCredentials = portainer.AzureCredentials{} if len(endpoint.Snapshots) > 0 { endpoint.Snapshots[0].SnapshotRaw = portainer.SnapshotRaw{} } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index ad0fa92df..8b167b12e 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -90,6 +90,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/storidge/"): http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) + case strings.Contains(r.URL.Path, "/azure/"): + http.StripPrefix("/api/endpoints", h.EndpointProxyHandler).ServeHTTP(w, r) case strings.Contains(r.URL.Path, "/edge/"): http.StripPrefix("/api/endpoints", h.EndpointEdgeHandler).ServeHTTP(w, r) default: diff --git a/api/http/proxy/factory/azure.go b/api/http/proxy/factory/azure.go new file mode 100644 index 000000000..27b8a26f8 --- /dev/null +++ b/api/http/proxy/factory/azure.go @@ -0,0 +1,20 @@ +package factory + +import ( + "net/http" + "net/url" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/proxy/factory/azure" +) + +func newAzureProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + remoteURL, err := url.Parse(azureAPIBaseURL) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(remoteURL) + proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials) + return proxy, nil +} diff --git a/api/http/proxy/factory/azure/transport.go b/api/http/proxy/factory/azure/transport.go new file mode 100644 index 000000000..0c8505c8b --- /dev/null +++ b/api/http/proxy/factory/azure/transport.go @@ -0,0 +1,80 @@ +package azure + +import ( + "net/http" + "strconv" + "sync" + "time" + + "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/http/client" +) + +type ( + azureAPIToken struct { + value string + expirationTime time.Time + } + + Transport struct { + credentials *portainer.AzureCredentials + client *client.HTTPClient + token *azureAPIToken + mutex sync.Mutex + } +) + +// NewTransport returns a pointer to a new instance of Transport that implements the HTTP Transport +// interface for proxying requests to the Azure API. +func NewTransport(credentials *portainer.AzureCredentials) *Transport { + return &Transport{ + credentials: credentials, + client: client.NewHTTPClient(), + } +} + +// RoundTrip is the implementation of the the http.RoundTripper interface +func (transport *Transport) 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) +} + +func (transport *Transport) 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 *Transport) 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 +} diff --git a/api/http/proxy/factory/factory.go b/api/http/proxy/factory/factory.go index b81291ab9..6ebedbb9f 100644 --- a/api/http/proxy/factory/factory.go +++ b/api/http/proxy/factory/factory.go @@ -10,6 +10,8 @@ import ( "github.com/portainer/portainer/api/docker" ) +const azureAPIBaseURL = "https://management.azure.com" + var extensionPorts = map[portainer.ExtensionID]string{ portainer.RegistryManagementExtension: "7001", portainer.OAuthAuthenticationExtension: "7002", @@ -69,6 +71,11 @@ func (factory *ProxyFactory) NewLegacyExtensionProxy(extensionAPIURL string) (ht // NewEndpointProxy returns a new reverse proxy (filesystem based or HTTP) to an endpoint API server func (factory *ProxyFactory) NewEndpointProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + switch endpoint.Type { + case portainer.AzureEnvironment: + return newAzureProxy(endpoint) + } + return factory.newDockerProxy(endpoint) } diff --git a/api/portainer.go b/api/portainer.go index 8386ce8f2..a2ec3efd6 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -27,7 +27,7 @@ type ( Authorizations map[Authorization]bool // AzureCredentials represents the credentials used to connect to an Azure - // environment (deprecated). + // environment. AzureCredentials struct { ApplicationID string `json:"ApplicationID"` TenantID string `json:"TenantID"` @@ -163,6 +163,7 @@ type ( PublicURL string `json:"PublicURL"` TLSConfig TLSConfiguration `json:"TLSConfig"` Extensions []EndpointExtension `json:"Extensions"` + AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` TagIDs []TagID `json:"TagIds"` Status EndpointStatus `json:"Status"` Snapshots []Snapshot `json:"Snapshots"` @@ -185,9 +186,6 @@ type ( // Deprecated in DBVersion == 22 Tags []string `json:"Tags"` - - // Deprecated in DBVersion == 24 - AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` } // EndpointAuthorizations represents the authorizations associated to a set of endpoints @@ -1101,7 +1099,7 @@ 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 (deprecated) + // AzureEnvironment represents an endpoint connected to an Azure environment AzureEnvironment // EdgeAgentEnvironment represents an endpoint connected to an Edge agent EdgeAgentEnvironment diff --git a/api/swagger.yaml b/api/swagger.yaml index abb4aa686..2ecd61139 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -254,7 +254,7 @@ paths: - name: "EndpointType" in: "formData" type: "integer" - description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 4 (Edge agent environment)" + description: "Environment type. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge agent environment)" required: true - name: "URL" in: "formData" @@ -294,6 +294,18 @@ paths: in: "formData" type: "file" description: "TLS client key file" + - name: "AzureApplicationID" + in: "formData" + type: "string" + description: "Azure application ID. Required if endpoint type is set to 3" + - name: "AzureTenantID" + in: "formData" + type: "string" + description: "Azure tenant ID. Required if endpoint type is set to 3" + - name: "AzureAuthenticationKey" + in: "formData" + type: "string" + description: "Azure authentication key. Required if endpoint type is set to 3" responses: 200: description: "Success" @@ -3209,6 +3221,21 @@ definitions: type: "string" example: "/data/tls/key.pem" description: "Path to the TLS client key file" + AzureCredentials: + type: "object" + properties: + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" LDAPSearchSettings: type: "object" properties: @@ -3480,7 +3507,7 @@ definitions: Type: type: "integer" example: 1 - description: "Endpoint environment type. 1 for a Docker environment or 2 for an agent on Docker environment" + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment or 3 for an Azure environment." URL: type: "string" example: "docker.mydomain.tld:2375" @@ -3509,6 +3536,8 @@ definitions: description: "Team identifier" TLSConfig: $ref: "#/definitions/TLSConfiguration" + AzureCredentials: + $ref: "#/definitions/AzureCredentials" EndpointSubset: type: "object" properties: @@ -3523,7 +3552,7 @@ definitions: Type: type: "integer" example: 1 - description: "Endpoint environment type. 1 for a Docker environment or 2 for an agent on Docker environment" + description: "Endpoint environment type. 1 for a Docker environment, 2 for an agent on Docker environment, 3 for an Azure environment." URL: type: "string" example: "docker.mydomain.tld:2375" @@ -3703,6 +3732,18 @@ definitions: type: "boolean" example: false description: "Skip client verification when using TLS" + ApplicationID: + type: "string" + example: "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4" + description: "Azure application ID" + TenantID: + type: "string" + example: "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + description: "Azure tenant ID" + AuthenticationKey: + type: "string" + example: "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=" + description: "Azure authentication key" UserAccessPolicies: $ref: "#/definitions/UserAccessPolicies" TeamAccessPolicies: diff --git a/app/__module.js b/app/__module.js index 8e878ded2..0aed3bfa1 100644 --- a/app/__module.js +++ b/app/__module.js @@ -2,6 +2,7 @@ import './assets/css'; import angular from 'angular'; import './agent/_module'; +import './azure/_module'; import './docker/__module'; import './edge/__module'; import './portainer/__module'; @@ -27,6 +28,7 @@ angular.module('portainer', [ 'luegg.directives', 'portainer.app', 'portainer.agent', + 'portainer.azure', 'portainer.docker', 'portainer.edge', 'portainer.extensions', diff --git a/app/assets/css/app.css b/app/assets/css/app.css index f1b18aa53..f57e3802f 100644 --- a/app/assets/css/app.css +++ b/app/assets/css/app.css @@ -236,6 +236,10 @@ a[ng-click] { margin: 10px 4px 0 6px; } +.blocklist-item-logo.endpoint-item.azure { + margin: 0 0 0 10px; +} + .blocklist-item-title { font-size: 1.8em; font-weight: bold; diff --git a/app/azure/_module.js b/app/azure/_module.js new file mode 100644 index 000000000..a11a5aa5e --- /dev/null +++ b/app/azure/_module.js @@ -0,0 +1,51 @@ +angular.module('portainer.azure', ['portainer.app']).config([ + '$stateRegistryProvider', + function ($stateRegistryProvider) { + 'use strict'; + + var azure = { + name: 'azure', + url: '/azure', + parent: 'root', + abstract: true, + }; + + var containerInstances = { + name: 'azure.containerinstances', + url: '/containerinstances', + views: { + 'content@': { + templateUrl: './views/containerinstances/containerinstances.html', + controller: 'AzureContainerInstancesController', + }, + }, + }; + + var containerInstanceCreation = { + name: 'azure.containerinstances.new', + url: '/new/', + views: { + 'content@': { + templateUrl: './views/containerinstances/create/createcontainerinstance.html', + controller: 'AzureCreateContainerInstanceController', + }, + }, + }; + + var dashboard = { + name: 'azure.dashboard', + url: '/dashboard', + views: { + 'content@': { + templateUrl: './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-endpoint-config/azure-endpoint-config.js b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js new file mode 100644 index 000000000..ff09f0908 --- /dev/null +++ b/app/azure/components/azure-endpoint-config/azure-endpoint-config.js @@ -0,0 +1,8 @@ +angular.module('portainer.azure').component('azureEndpointConfig', { + bindings: { + applicationId: '=', + tenantId: '=', + authenticationKey: '=', + }, + templateUrl: './azureEndpointConfig.html', +}); diff --git a/app/azure/components/azure-endpoint-config/azureEndpointConfig.html b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html new file mode 100644 index 000000000..efc8bd79f --- /dev/null +++ b/app/azure/components/azure-endpoint-config/azureEndpointConfig.html @@ -0,0 +1,36 @@ +
+
+ Azure configuration +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
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..daec3ef12 --- /dev/null +++ b/app/azure/components/azure-sidebar-content/azure-sidebar-content.js @@ -0,0 +1,3 @@ +angular.module('portainer.azure').component('azureSidebarContent', { + templateUrl: './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..f9936d78b --- /dev/null +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -0,0 +1,105 @@ +
+ + +
+
{{ $ctrl.titleText }}
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + 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..8d91518a9 --- /dev/null +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.azure').component('containergroupsDatatable', { + templateUrl: './containerGroupsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + title: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<', + }, +}); diff --git a/app/azure/models/container_group.js b/app/azure/models/container_group.js new file mode 100644 index 000000000..dfc9adeef --- /dev/null +++ b/app/azure/models/container_group.js @@ -0,0 +1,66 @@ +export 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; +} + +export function ContainerGroupViewModel(data) { + this.Id = data.id; + this.Name = data.name; + this.Location = data.location; + this.IPAddress = data.properties.ipAddress.ip; + this.Ports = data.properties.ipAddress.ports; +} + +export function CreateContainerGroupRequest(model) { + this.location = model.Location; + + var containerPorts = []; + var addressPorts = []; + for (var i = 0; i < model.Ports.length; i++) { + var binding = model.Ports[i]; + + 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..6d4031331 --- /dev/null +++ b/app/azure/models/location.js @@ -0,0 +1,6 @@ +export 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..d9d6c8075 --- /dev/null +++ b/app/azure/models/provider.js @@ -0,0 +1,9 @@ +import _ from 'lodash-es'; + +export function ContainerInstanceProviderViewModel(data) { + this.Id = data.id; + this.Namespace = data.namespace; + + var containerGroupType = _.find(data.resourceTypes, { resourceType: 'containerGroups' }); + this.Locations = containerGroupType.locations; +} diff --git a/app/azure/models/resource_group.js b/app/azure/models/resource_group.js new file mode 100644 index 000000000..894ce326d --- /dev/null +++ b/app/azure/models/resource_group.js @@ -0,0 +1,6 @@ +export 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..eb9bfaf52 --- /dev/null +++ b/app/azure/models/subscription.js @@ -0,0 +1,4 @@ +export 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..f463624d6 --- /dev/null +++ b/app/azure/rest/azure.js @@ -0,0 +1,20 @@ +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..4dc7a002d --- /dev/null +++ b/app/azure/rest/container_group.js @@ -0,0 +1,45 @@ +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..7503d9fc9 --- /dev/null +++ b/app/azure/rest/location.js @@ -0,0 +1,18 @@ +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..b8e76d81e --- /dev/null +++ b/app/azure/rest/provider.js @@ -0,0 +1,18 @@ +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..644279f3b --- /dev/null +++ b/app/azure/rest/resource_group.js @@ -0,0 +1,18 @@ +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..0711d5f92 --- /dev/null +++ b/app/azure/rest/subscription.js @@ -0,0 +1,18 @@ +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..b6c3ba0aa --- /dev/null +++ b/app/azure/services/azureService.js @@ -0,0 +1,72 @@ +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) { + 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..c99b98ada --- /dev/null +++ b/app/azure/services/containerGroupService.js @@ -0,0 +1,41 @@ +import { ContainerGroupViewModel, CreateContainerGroupRequest } from '../models/container_group'; + +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..a21e7fa0a --- /dev/null +++ b/app/azure/services/locationService.js @@ -0,0 +1,29 @@ +import { LocationViewModel } from '../models/location'; + +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..edc42ae9e --- /dev/null +++ b/app/azure/services/providerService.js @@ -0,0 +1,27 @@ +import { ContainerInstanceProviderViewModel } from '../models/provider'; + +angular.module('portainer.azure').factory('ProviderService', [ + '$q', + 'Provider', + function ProviderServiceFactory($q, Provider) { + 'use strict'; + var service = {}; + + service.containerInstanceProvider = function (subscriptionId) { + var deferred = $q.defer(); + + Provider.get({ subscriptionId: subscriptionId, providerNamespace: 'Microsoft.ContainerInstance' }) + .$promise.then(function success(data) { + var provider = new ContainerInstanceProviderViewModel(data); + deferred.resolve(provider); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve provider', err: err }); + }); + + return deferred.promise; + }; + + return service; + }, +]); diff --git a/app/azure/services/resourceGroupService.js b/app/azure/services/resourceGroupService.js new file mode 100644 index 000000000..4110835f4 --- /dev/null +++ b/app/azure/services/resourceGroupService.js @@ -0,0 +1,29 @@ +import { ResourceGroupViewModel } from '../models/resource_group'; + +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..3b22ac664 --- /dev/null +++ b/app/azure/services/subscriptionService.js @@ -0,0 +1,29 @@ +import { SubscriptionViewModel } from '../models/subscription'; + +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..4863d5cac --- /dev/null +++ b/app/azure/views/containerinstances/containerInstancesController.js @@ -0,0 +1,44 @@ +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..6c0223852 --- /dev/null +++ b/app/azure/views/containerinstances/containerinstances.html @@ -0,0 +1,21 @@ + + + + + + + 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..7c2774946 --- /dev/null +++ b/app/azure/views/containerinstances/create/createContainerInstanceController.js @@ -0,0 +1,93 @@ +import { ContainerGroupDefaultModel } from '../../../models/container_group'; + +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() { + 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..625e50bc5 --- /dev/null +++ b/app/azure/views/containerinstances/create/createcontainerinstance.html @@ -0,0 +1,167 @@ + + + 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..eaa60a53e --- /dev/null +++ b/app/azure/views/dashboard/dashboard.html @@ -0,0 +1,33 @@ + + + Dashboard + + +
+
+ + + +
+ +
+
{{ subscriptions.length }}
+
Subscriptions
+
+
+
+
+
+ + + +
+ +
+
{{ resourceGroups.length }}
+
Resource groups
+
+
+
+
+
diff --git a/app/azure/views/dashboard/dashboardController.js b/app/azure/views/dashboard/dashboardController.js new file mode 100644 index 000000000..643f900a7 --- /dev/null +++ b/app/azure/views/dashboard/dashboardController.js @@ -0,0 +1,23 @@ +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/portainer/components/endpoint-list/endpoint-item/endpointItem.html b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html index c37b1fa86..de88bfa12 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html @@ -1,6 +1,6 @@
-
+
+ + +
@@ -87,6 +97,29 @@
+
+
+ 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
@@ -198,6 +231,76 @@ + +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

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

This field is required.

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

This field is required.

+
+
+
+ +
+ @@ -264,6 +367,17 @@ Add endpoint Creating endpoint... + diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index ca4ca5ca7..367ace6f8 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -118,7 +118,7 @@ -
+
+
Metadata diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js index 85d6c9bcb..561f38893 100644 --- a/app/portainer/views/endpoints/edit/endpointController.js +++ b/app/portainer/views/endpoints/edit/endpointController.js @@ -109,6 +109,9 @@ angular TLSCACert: TLSSkipVerify || securityData.TLSCACert === endpoint.TLSConfig.TLSCACert ? null : securityData.TLSCACert, TLSCert: TLSSkipClientVerify || securityData.TLSCert === endpoint.TLSConfig.TLSCert ? null : securityData.TLSCert, TLSKey: TLSSkipClientVerify || securityData.TLSKey === endpoint.TLSConfig.TLSKey ? null : securityData.TLSKey, + AzureApplicationID: endpoint.AzureCredentials.ApplicationID, + AzureTenantID: endpoint.AzureCredentials.TenantID, + AzureAuthenticationKey: endpoint.AzureCredentials.AuthenticationKey, }; if ($scope.endpointType !== 'local' && endpoint.Type !== 3) { diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index 4322c0f30..af41523f7 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -26,7 +26,9 @@ angular }; $scope.goToDashboard = function (endpoint) { - if (endpoint.Type === 4) { + if (endpoint.Type === 3) { + return switchToAzureEndpoint(endpoint); + } else if (endpoint.Type === 4) { return switchToEdgeEndpoint(endpoint); } @@ -87,6 +89,19 @@ angular return deferred.promise; } + function switchToAzureEndpoint(endpoint) { + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + StateManager.updateEndpointState(endpoint, []) + .then(function success() { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Azure endpoint'); + }); + } + function switchToEdgeEndpoint(endpoint) { if (!endpoint.EdgeID) { $state.go('portainer.endpoints.endpoint', { id: endpoint.Id }); diff --git a/app/portainer/views/init/endpoint/initEndpoint.html b/app/portainer/views/init/endpoint/initEndpoint.html index 30310d5bd..c67f678b6 100644 --- a/app/portainer/views/init/endpoint/initEndpoint.html +++ b/app/portainer/views/init/endpoint/initEndpoint.html @@ -55,6 +55,16 @@

Connect to a Portainer agent

+
+ + +
@@ -158,6 +168,91 @@ + +
+
+ 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 3c41c60af..fc4ff905a 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -28,6 +28,9 @@ angular.module('portainer.app').controller('InitEndpointController', [ TLSCACert: null, TLSCert: null, TLSKey: null, + AzureApplicationId: '', + AzureTenantId: '', + AzureAuthenticationKey: '', }; $scope.createLocalEndpoint = function () { @@ -44,6 +47,15 @@ angular.module('portainer.app').controller('InitEndpointController', [ }); }; + $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; @@ -66,6 +78,20 @@ angular.module('portainer.app').controller('InitEndpointController', [ createRemoteEndpoint(name, 1, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile); }; + function createAzureEndpoint(name, applicationId, tenantId, authenticationKey) { + $scope.state.actionInProgress = true; + EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, 1, []) + .then(function success() { + $state.go('portainer.home'); + }) + .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, type, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) { $scope.state.actionInProgress = true; EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, [], TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index 3ed2e8d7f..6e57ac2c5 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -13,8 +13,9 @@ Home +