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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
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
+
+
+
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 @@
-
+
diff --git a/app/portainer/filters/filters.js b/app/portainer/filters/filters.js
index e4c232ee5..034dba32c 100644
--- a/app/portainer/filters/filters.js
+++ b/app/portainer/filters/filters.js
@@ -128,6 +128,8 @@ angular
return 'Docker';
} else if (type === 2) {
return 'Agent';
+ } else if (type === 3) {
+ return 'Azure ACI';
} else if (type === 4) {
return 'Edge Agent';
}
diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js
index 8d7cddd3f..511983ade 100644
--- a/app/portainer/services/api/endpointService.js
+++ b/app/portainer/services/api/endpointService.js
@@ -112,6 +112,20 @@ angular.module('portainer.app').factory('EndpointService', [
return deferred.promise;
};
+ service.createAzureEndpoint = function (name, applicationId, tenantId, authenticationKey, groupId, tagIds) {
+ var deferred = $q.defer();
+
+ FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds)
+ .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;
+ };
+
service.executeJobFromFileUpload = function (image, jobFile, endpointId, nodeName) {
return FileUploadService.executeEndpointJob(image, jobFile, endpointId, nodeName);
};
diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js
index 6477d75ba..5b2e7d67f 100644
--- a/app/portainer/services/fileUpload.js
+++ b/app/portainer/services/fileUpload.js
@@ -137,6 +137,22 @@ angular.module('portainer.app').factory('FileUploadService', [
});
};
+ service.createAzureEndpoint = function (name, applicationId, tenantId, authenticationKey, groupId, tagIds) {
+ return Upload.upload({
+ url: 'api/endpoints',
+ data: {
+ Name: name,
+ EndpointType: 3,
+ GroupID: groupId,
+ TagIds: Upload.json(tagIds),
+ 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 819d8c570..fa2b1c72b 100644
--- a/app/portainer/services/stateManager.js
+++ b/app/portainer/services/stateManager.js
@@ -168,6 +168,14 @@ angular.module('portainer.app').factory('StateManager', [
manager.updateEndpointState = function (endpoint, extensions) {
var deferred = $q.defer();
+ if (endpoint.Type === 3) {
+ state.endpoint.name = endpoint.Name;
+ state.endpoint.mode = { provider: 'AZURE' };
+ LocalStorage.storeEndpointState(state.endpoint);
+ deferred.resolve();
+ return deferred.promise;
+ }
+
$q.all({
version: endpoint.Status === 1 ? SystemService.version() : $q.when(endpoint.Snapshots[0].SnapshotRaw.Version),
info: endpoint.Status === 1 ? SystemService.info() : $q.when(endpoint.Snapshots[0].SnapshotRaw.Info),
diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js
index bea559dd7..ce5d31d60 100644
--- a/app/portainer/views/endpoints/create/createEndpointController.js
+++ b/app/portainer/views/endpoints/create/createEndpointController.js
@@ -46,6 +46,9 @@ angular
PublicURL: '',
GroupId: 1,
SecurityFormData: new EndpointSecurityFormData(),
+ AzureApplicationId: '',
+ AzureTenantId: '',
+ AzureAuthenticationKey: '',
TagIds: [],
CheckinInterval: $scope.state.availableEdgeAgentCheckinOptions[0].value,
};
@@ -102,6 +105,17 @@ angular
addEndpoint(name, 4, URL, '', groupId, tagIds, false, false, false, null, null, null, $scope.formValues.CheckinInterval);
};
+ $scope.addAzureEndpoint = function () {
+ var name = $scope.formValues.Name;
+ var applicationId = $scope.formValues.AzureApplicationId;
+ var tenantId = $scope.formValues.AzureTenantId;
+ var authenticationKey = $scope.formValues.AzureAuthenticationKey;
+ var groupId = $scope.formValues.GroupId;
+ var tagIds = $scope.formValues.TagIds;
+
+ createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds);
+ };
+
$scope.onCreateTag = function onCreateTag(tagName) {
return $async(onCreateTagAsync, tagName);
};
@@ -116,6 +130,21 @@ angular
}
}
+ function createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds) {
+ $scope.state.actionInProgress = true;
+ EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tagIds)
+ .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, tagIds, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile, CheckinInterval) {
$scope.state.actionInProgress = true;
EndpointService.createRemoteEndpoint(
diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html
index 4ab68d840..dd01e6142 100644
--- a/app/portainer/views/endpoints/create/createendpoint.html
+++ b/app/portainer/views/endpoints/create/createendpoint.html
@@ -44,6 +44,16 @@
Directly connect to the Docker API
+
+
+
+
@@ -87,6 +97,29 @@
+
Environment details
@@ -198,6 +231,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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 @@
-
+
+
+
+
+
@@ -158,6 +168,91 @@
+
+
+
+ Information
+
+
+
+ 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
+