resolve conflict with staging

This commit is contained in:
Philip Kalinsky 2020-02-05 12:21:00 -05:00
commit 38e92e427b
56 changed files with 1341 additions and 623 deletions

View File

@ -21,7 +21,7 @@ flask-wtf = "*"
pyopenssl = "*" pyopenssl = "*"
requests = "*" requests = "*"
lockfile = "*" lockfile = "*"
werkzeug = "*" werkzeug = "==0.16.1"
PyYAML = "*" PyYAML = "*"
azure-storage = "*" azure-storage = "*"
azure-storage-common = "*" azure-storage-common = "*"

20
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "faa5dab7bc6d13d39c0ef80f015f34a7fce2d66bec273ff38b7bd9ba232a3502" "sha256": "44296f145fcb42cff5fadf14a706ec9598f4436ccbdf05e1d69fcd8316c89e8d"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -164,10 +164,10 @@
}, },
"billiard": { "billiard": {
"hashes": [ "hashes": [
"sha256:01afcb4e7c4fd6480940cfbd4d9edc19d7a7509d6ada533984d0d0f49901ec82", "sha256:26fd494dc3251f8ce1f5559744f18aeed427fdaf29a75d7baae26752a5d3816f",
"sha256:b8809c74f648dfe69b973c8e660bcec00603758c9db8ba89d7719f88d5f01f26" "sha256:f4e09366653aa3cb3ae8ed16423f9ba1665ff426f087bcdbbed86bf3664fe02c"
], ],
"version": "==3.6.1.0" "version": "==3.6.2.0"
}, },
"celery": { "celery": {
"hashes": [ "hashes": [
@ -582,11 +582,11 @@
}, },
"redis": { "redis": {
"hashes": [ "hashes": [
"sha256:7595976eb0b4e1fc3ad5478f1fd44215a814ee184a7820de92726f559bdff9cd", "sha256:0dcfb335921b88a850d461dc255ff4708294943322bd55de6cfd68972490ca1f",
"sha256:e933bdb504c69cbd5bdf4e2bb819a99644a36731cef4c59aa637cebfd5ddd4f9" "sha256:b205cffd05ebfd0a468db74f0eedbff8df1a7bfc47521516ade4692991bb0833"
], ],
"index": "pypi", "index": "pypi",
"version": "==3.4.0" "version": "==3.4.1"
}, },
"requests": { "requests": {
"hashes": [ "hashes": [
@ -908,11 +908,11 @@
}, },
"ipython": { "ipython": {
"hashes": [ "hashes": [
"sha256:0f4bcf18293fb666df8511feec0403bdb7e061a5842ea6e88a3177b0ceb34ead", "sha256:d9459e7237e2e5858738ff9c3e26504b79899b58a6d49e574d352493d80684c6",
"sha256:387686dd7fc9caf29d2fddcf3116c4b07a11d9025701d220c589a430b0171d8a" "sha256:f6689108b1734501d3b59c84427259fd5ac5141afe2e846cfa8598eb811886c9"
], ],
"index": "pypi", "index": "pypi",
"version": "==7.11.1" "version": "==7.12.0"
}, },
"ipython-genutils": { "ipython-genutils": {
"hashes": [ "hashes": [

View File

@ -0,0 +1,29 @@
"""add application_role.cloud_id
Revision ID: 17da2a475429
Revises: 50979d8ef680
Create Date: 2020-02-01 10:43:03.073539
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '17da2a475429' # pragma: allowlist secret
down_revision = '50979d8ef680' # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('application_roles', sa.Column('cloud_id', sa.String(), nullable=True))
op.add_column('application_roles', sa.Column('claimed_until', sa.TIMESTAMP(timezone=True), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('application_roles', 'cloud_id')
op.drop_column('application_roles', 'claimed_until')
# ### end Alembic commands ###

View File

@ -1,8 +1,12 @@
from itertools import groupby
from typing import List
from uuid import UUID
from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.orm.exc import NoResultFound
from atst.database import db from atst.database import db
from atst.domain.environment_roles import EnvironmentRoles from atst.domain.environment_roles import EnvironmentRoles
from atst.models import ApplicationRole, ApplicationRoleStatus from atst.models import Application, ApplicationRole, ApplicationRoleStatus, Portfolio
from .permission_sets import PermissionSets from .permission_sets import PermissionSets
from .exceptions import NotFoundError from .exceptions import NotFoundError
@ -61,6 +65,15 @@ class ApplicationRoles(object):
except NoResultFound: except NoResultFound:
raise NotFoundError("application_role") raise NotFoundError("application_role")
@classmethod
def get_many(cls, ids):
return (
db.session.query(ApplicationRole)
.filter(ApplicationRole.id.in_(ids))
.filter(ApplicationRole.status != ApplicationRoleStatus.DISABLED)
.all()
)
@classmethod @classmethod
def update_permission_sets(cls, application_role, new_perm_sets_names): def update_permission_sets(cls, application_role, new_perm_sets_names):
application_role.permission_sets = ApplicationRoles._permission_sets_for_names( application_role.permission_sets = ApplicationRoles._permission_sets_for_names(
@ -92,3 +105,29 @@ class ApplicationRoles(object):
db.session.add(application_role) db.session.add(application_role)
db.session.commit() db.session.commit()
@classmethod
def get_pending_creation(cls) -> List[List[UUID]]:
"""
Returns a list of lists of ApplicationRole IDs. The IDs
should be grouped by user and portfolio.
"""
results = (
db.session.query(ApplicationRole.id, ApplicationRole.user_id, Portfolio.id)
.join(Application, Application.id == ApplicationRole.application_id)
.join(Portfolio, Portfolio.id == Application.portfolio_id)
.filter(Application.cloud_id.isnot(None))
.filter(ApplicationRole.deleted == False)
.filter(ApplicationRole.cloud_id.is_(None))
.filter(ApplicationRole.user_id.isnot(None))
.filter(ApplicationRole.status == ApplicationRoleStatus.ACTIVE)
).all()
groups = []
keyfunc = lambda pair: (pair[1], pair[2])
sorted_results = sorted(results, key=keyfunc)
for _, g in groupby(sorted_results, keyfunc):
group = [pair[0] for pair in list(g)]
groups.append(group)
return groups

View File

@ -6,7 +6,7 @@ from uuid import uuid4
from atst.utils import sha256_hex from atst.utils import sha256_hex
from .cloud_provider_interface import CloudProviderInterface from .cloud_provider_interface import CloudProviderInterface
from .exceptions import AuthenticationException from .exceptions import AuthenticationException, UserProvisioningException
from .models import ( from .models import (
SubscriptionCreationCSPPayload, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult, SubscriptionCreationCSPResult,
@ -24,6 +24,7 @@ from .models import (
BillingProfileTenantAccessCSPResult, BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult, BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
KeyVaultCredentials, KeyVaultCredentials,
ManagementGroupCSPPayload, ManagementGroupCSPPayload,
ManagementGroupCSPResponse, ManagementGroupCSPResponse,
@ -35,6 +36,7 @@ from .models import (
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult, PrincipalAdminRoleCSPResult,
ReportingCSPPayload,
TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult, TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload, TaskOrderBillingVerificationCSPPayload,
@ -51,6 +53,8 @@ from .models import (
TenantPrincipalCSPResult, TenantPrincipalCSPResult,
TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPPayload,
TenantPrincipalOwnershipCSPResult, TenantPrincipalOwnershipCSPResult,
UserCSPPayload,
UserCSPResult,
) )
from .policy import AzurePolicyManager from .policy import AzurePolicyManager
@ -196,9 +200,9 @@ class AzureCloudProvider(CloudProviderInterface):
creds = self._source_creds(payload.tenant_id) creds = self._source_creds(payload.tenant_id)
credentials = self._get_credential_obj( credentials = self._get_credential_obj(
{ {
"client_id": creds.root_sp_client_id, "client_id": creds.tenant_sp_client_id,
"secret_key": creds.root_sp_key, "secret_key": creds.tenant_sp_key,
"tenant_id": creds.root_tenant_id, "tenant_id": creds.tenant_id,
}, },
resource=self.sdk.cloud.endpoints.resource_manager, resource=self.sdk.cloud.endpoints.resource_manager,
) )
@ -352,7 +356,9 @@ class AzureCloudProvider(CloudProviderInterface):
tenant_admin_password=payload.password, tenant_admin_password=payload.password,
), ),
) )
return self._ok(TenantCSPResult(**result_dict)) return self._ok(
TenantCSPResult(domain_name=payload.domain_name, **result_dict)
)
else: else:
return self._error(result.json()) return self._error(result.json())
@ -892,6 +898,80 @@ class AzureCloudProvider(CloudProviderInterface):
return service_principal return service_principal
def create_user(self, payload: UserCSPPayload) -> UserCSPResult:
"""Create a user in an Azure Active Directory instance.
Unlike most of the methods on this interface, this requires
two API calls: one POST to create the user and one PATCH to
set the alternate email address. The email address cannot
be set on the first API call. The email address is
necessary so that users can do Self-Service Password
Recovery.
Arguments:
payload {UserCSPPayload} -- a payload object with the
data necessary for both calls
Returns:
UserCSPResult -- a result object containing the AAD ID.
"""
graph_token = self._get_tenant_principal_token(
payload.tenant_id, resource=self.graph_resource
)
if graph_token is None:
raise AuthenticationException(
"Could not resolve graph token for tenant admin"
)
result = self._create_active_directory_user(graph_token, payload)
self._update_active_directory_user_email(graph_token, result.id, payload)
return result
def _create_active_directory_user(self, graph_token, payload: UserCSPPayload):
request_body = {
"accountEnabled": True,
"displayName": payload.display_name,
"mailNickname": payload.mail_nickname,
"userPrincipalName": payload.user_principal_name,
"passwordProfile": {
"forceChangePasswordNextSignIn": True,
"password": payload.password,
},
}
auth_header = {
"Authorization": f"Bearer {graph_token}",
}
url = f"{self.graph_resource}v1.0/users"
response = self.sdk.requests.post(url, headers=auth_header, json=request_body)
if response.ok:
return UserCSPResult(**response.json())
else:
raise UserProvisioningException(f"Failed to create user: {response.json()}")
def _update_active_directory_user_email(
self, graph_token, user_id, payload: UserCSPPayload
):
request_body = {"otherMails": [payload.email]}
auth_header = {
"Authorization": f"Bearer {graph_token}",
}
url = f"{self.graph_resource}v1.0/users/{user_id}"
response = self.sdk.requests.patch(url, headers=auth_header, json=request_body)
if response.ok:
return True
else:
raise UserProvisioningException(
f"Failed update user email: {response.json()}"
)
def _extract_subscription_id(self, subscription_url): def _extract_subscription_id(self, subscription_url):
sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url) sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url)
@ -913,14 +993,15 @@ class AzureCloudProvider(CloudProviderInterface):
creds.root_tenant_id, creds.root_sp_client_id, creds.root_sp_key creds.root_tenant_id, creds.root_sp_client_id, creds.root_sp_key
) )
def _get_sp_token(self, tenant_id, client_id, secret_key): def _get_sp_token(self, tenant_id, client_id, secret_key, resource=None):
context = self.sdk.adal.AuthenticationContext( context = self.sdk.adal.AuthenticationContext(
f"{self.sdk.cloud.endpoints.active_directory}/{tenant_id}" f"{self.sdk.cloud.endpoints.active_directory}/{tenant_id}"
) )
resource = resource or self.sdk.cloud.endpoints.resource_manager
# TODO: handle failure states here # TODO: handle failure states here
token_response = context.acquire_token_with_client_credentials( token_response = context.acquire_token_with_client_credentials(
self.sdk.cloud.endpoints.resource_manager, client_id, secret_key resource, client_id, secret_key
) )
return token_response.get("accessToken", None) return token_response.get("accessToken", None)
@ -981,10 +1062,13 @@ class AzureCloudProvider(CloudProviderInterface):
"tenant_id": self.tenant_id, "tenant_id": self.tenant_id,
} }
def _get_tenant_principal_token(self, tenant_id): def _get_tenant_principal_token(self, tenant_id, resource=None):
creds = self._source_creds(tenant_id) creds = self._source_creds(tenant_id)
return self._get_sp_token( return self._get_sp_token(
creds.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key creds.tenant_id,
creds.tenant_sp_client_id,
creds.tenant_sp_key,
resource=resource,
) )
def _get_elevated_management_token(self, tenant_id): def _get_elevated_management_token(self, tenant_id):
@ -1030,3 +1114,41 @@ class AzureCloudProvider(CloudProviderInterface):
hashed = sha256_hex(tenant_id) hashed = sha256_hex(tenant_id)
raw_creds = self.get_secret(hashed) raw_creds = self.get_secret(hashed)
return KeyVaultCredentials(**json.loads(raw_creds)) return KeyVaultCredentials(**json.loads(raw_creds))
def get_reporting_data(self, payload: ReportingCSPPayload):
"""
Queries the Cost Management API for an invoice section's raw reporting data
We query at the invoiceSection scope. The full scope path is passed in
with the payload at the `invoice_section_id` key.
"""
creds = self._source_tenant_creds(payload.tenant_id)
token = self._get_sp_token(
payload.tenant_id, creds.tenant_sp_client_id, creds.tenant_sp_key
)
if not token:
raise AuthenticationException("Could not retrieve tenant access token")
headers = {"Authorization": f"Bearer {token}"}
request_body = {
"type": "Usage",
"timeframe": "Custom",
"timePeriod": {"from": payload.from_date, "to": payload.to_date,},
"dataset": {
"granularity": "Daily",
"aggregation": {"totalCost": {"name": "PreTaxCost", "function": "Sum"}},
"grouping": [{"type": "Dimension", "name": "InvoiceId"}],
},
}
cost_mgmt_url = (
f"/providers/Microsoft.CostManagement/query?api-version=2019-11-01"
)
result = self.sdk.requests.post(
f"{self.sdk.cloud.endpoints.resource_manager}{payload.invoice_section_id}{cost_mgmt_url}",
json=request_body,
headers=headers,
)
if result.ok:
return CostManagementQueryCSPResult(**result.json())

View File

@ -88,17 +88,6 @@ class UserProvisioningException(GeneralCSPException):
"""Failed to provision a user """Failed to provision a user
""" """
def __init__(self, env_identifier, user_identifier, reason):
self.env_identifier = env_identifier
self.user_identifier = user_identifier
self.reason = reason
@property
def message(self):
return "Failed to create user {} for environment {}: {}".format(
self.user_identifier, self.env_identifier, self.reason
)
class UserRemovalException(GeneralCSPException): class UserRemovalException(GeneralCSPException):
"""Failed to remove a user """Failed to remove a user

View File

@ -29,12 +29,15 @@ from .models import (
ManagementGroupCSPResponse, ManagementGroupCSPResponse,
ManagementGroupGetCSPPayload, ManagementGroupGetCSPPayload,
ManagementGroupGetCSPResponse, ManagementGroupGetCSPResponse,
CostManagementQueryCSPResult,
CostManagementQueryProperties,
ProductPurchaseCSPPayload, ProductPurchaseCSPPayload,
ProductPurchaseCSPResult, ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult, PrincipalAdminRoleCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult, SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload, SubscriptionVerificationCSPPayload,
@ -55,6 +58,8 @@ from .models import (
TenantPrincipalCSPResult, TenantPrincipalCSPResult,
TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPPayload,
TenantPrincipalOwnershipCSPResult, TenantPrincipalOwnershipCSPResult,
UserCSPPayload,
UserCSPResult,
) )
@ -179,6 +184,7 @@ class MockCloudProvider(CloudProviderInterface):
"tenant_id": "", "tenant_id": "",
"user_id": "", "user_id": "",
"user_object_id": "", "user_object_id": "",
"domain_name": "",
"tenant_admin_username": "test", "tenant_admin_username": "test",
"tenant_admin_password": "test", "tenant_admin_password": "test",
} }
@ -501,8 +507,35 @@ class MockCloudProvider(CloudProviderInterface):
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}" id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
) )
def create_user(self, payload: UserCSPPayload):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return UserCSPResult(id=str(uuid4()))
def get_credentials(self, scope="portfolio", tenant_id=None): def get_credentials(self, scope="portfolio", tenant_id=None):
return self.root_creds() return self.root_creds()
def update_tenant_creds(self, tenant_id, secret): def update_tenant_creds(self, tenant_id, secret):
return secret return secret
def get_reporting_data(self, payload: ReportingCSPPayload):
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
object_id = str(uuid4())
properties = CostManagementQueryProperties(
**dict(
columns=[
{"name": "PreTaxCost", "type": "Number"},
{"name": "UsageDate", "type": "Number"},
{"name": "InvoiceId", "type": "String"},
{"name": "Currency", "type": "String"},
],
rows=[],
)
)
return CostManagementQueryCSPResult(
**dict(name=object_id, properties=properties,)
)

View File

@ -1,6 +1,7 @@
from secrets import token_urlsafe
from typing import Dict, List, Optional from typing import Dict, List, Optional
import re
from uuid import uuid4 from uuid import uuid4
import re
from pydantic import BaseModel, validator, root_validator from pydantic import BaseModel, validator, root_validator
@ -39,6 +40,7 @@ class TenantCSPResult(AliasModel):
user_id: str user_id: str
tenant_id: str tenant_id: str
user_object_id: str user_object_id: str
domain_name: str
tenant_admin_username: Optional[str] tenant_admin_username: Optional[str]
tenant_admin_password: Optional[str] tenant_admin_password: Optional[str]
@ -484,3 +486,57 @@ class ProductPurchaseVerificationCSPPayload(BaseCSPPayload):
class ProductPurchaseVerificationCSPResult(AliasModel): class ProductPurchaseVerificationCSPResult(AliasModel):
premium_purchase_date: str premium_purchase_date: str
class UserCSPPayload(BaseCSPPayload):
display_name: str
tenant_host_name: str
email: str
password: Optional[str]
@property
def user_principal_name(self):
return f"{self.mail_nickname}@{self.tenant_host_name}.onmicrosoft.com"
@property
def mail_nickname(self):
return self.display_name.replace(" ", ".").lower()
@validator("password", pre=True, always=True)
def supply_password_default(cls, password):
return password or token_urlsafe(16)
class UserCSPResult(AliasModel):
id: str
class QueryColumn(AliasModel):
name: str
type: str
class CostManagementQueryProperties(AliasModel):
columns: List[QueryColumn]
rows: List[Optional[list]]
class CostManagementQueryCSPResult(AliasModel):
name: str
properties: CostManagementQueryProperties
class ReportingCSPPayload(BaseCSPPayload):
invoice_section_id: str
from_date: str
to_date: str
@root_validator(pre=True)
def extract_invoice_section(cls, values):
try:
values["invoice_section_id"] = values["billing_profile_properties"][
"invoice_sections"
][0]["invoice_section_id"]
return values
except (KeyError, IndexError):
raise ValueError("Invoice section ID not present in payload")

View File

@ -79,7 +79,7 @@ class AzureFileService(FileService):
sas_token = bbs.generate_blob_shared_access_signature( sas_token = bbs.generate_blob_shared_access_signature(
self.container_name, self.container_name,
object_name, object_name,
permission=self.BlobPermissions.READ, permission=self.BlobSasPermissions(read=True),
expiry=datetime.utcnow() + self.timeout, expiry=datetime.utcnow() + self.timeout,
content_disposition=f"attachment; filename={filename}", content_disposition=f"attachment; filename={filename}",
protocol="https", protocol="https",

View File

@ -15,6 +15,8 @@ from atst.models import (
Permissions, Permissions,
PortfolioRole, PortfolioRole,
PortfolioRoleStatus, PortfolioRoleStatus,
TaskOrder,
CLIN,
) )
from .query import PortfoliosQuery, PortfolioStateMachinesQuery from .query import PortfoliosQuery, PortfolioStateMachinesQuery
@ -144,7 +146,7 @@ class Portfolios(object):
return db.session.query(Portfolio.id) return db.session.query(Portfolio.id)
@classmethod @classmethod
def get_portfolios_pending_provisioning(cls) -> List[UUID]: def get_portfolios_pending_provisioning(cls, now) -> List[UUID]:
""" """
Any portfolio with a corresponding State Machine that is either: Any portfolio with a corresponding State Machine that is either:
not started yet, not started yet,
@ -153,22 +155,18 @@ class Portfolios(object):
""" """
results = ( results = (
cls.base_provision_query() db.session.query(Portfolio.id)
.join(PortfolioStateMachine) .join(PortfolioStateMachine)
.join(TaskOrder)
.join(CLIN)
.filter(Portfolio.deleted == False)
.filter(CLIN.start_date <= now)
.filter(CLIN.end_date > now)
.filter( .filter(
or_( or_(
PortfolioStateMachine.state == FSMStates.UNSTARTED, PortfolioStateMachine.state == FSMStates.UNSTARTED,
PortfolioStateMachine.state == FSMStates.FAILED, PortfolioStateMachine.state.like("%CREATED"),
PortfolioStateMachine.state == FSMStates.TENANT_FAILED,
) )
) )
) )
return [id_ for id_, in results] return [id_ for id_, in results]
# db.session.query(PortfolioStateMachine).\
# filter(
# or_(
# PortfolioStateMachine.state==FSMStates.UNSTARTED,
# PortfolioStateMachine.state==FSMStates.UNSTARTED,
# )
# ).all()

View File

@ -3,16 +3,16 @@ import pendulum
from atst.database import db from atst.database import db
from atst.queue import celery from atst.queue import celery
from atst.models import EnvironmentRole, JobFailure from atst.models import JobFailure
from atst.domain.csp.cloud.exceptions import GeneralCSPException from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.csp.cloud import CloudProviderInterface from atst.domain.csp.cloud import CloudProviderInterface
from atst.domain.applications import Applications from atst.domain.applications import Applications
from atst.domain.environments import Environments from atst.domain.environments import Environments
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.domain.environment_roles import EnvironmentRoles from atst.domain.application_roles import ApplicationRoles
from atst.models.utils import claim_for_update from atst.models.utils import claim_for_update, claim_many_for_update
from atst.utils.localization import translate from atst.utils.localization import translate
from atst.domain.csp.cloud.models import ApplicationCSPPayload from atst.domain.csp.cloud.models import ApplicationCSPPayload, UserCSPPayload
class RecordFailure(celery.Task): class RecordFailure(celery.Task):
@ -75,6 +75,34 @@ def do_create_application(csp: CloudProviderInterface, application_id=None):
db.session.commit() db.session.commit()
def do_create_user(csp: CloudProviderInterface, application_role_ids=None):
if not application_role_ids:
return
app_roles = ApplicationRoles.get_many(application_role_ids)
with claim_many_for_update(app_roles) as app_roles:
if any([ar.cloud_id for ar in app_roles]):
return
csp_details = app_roles[0].application.portfolio.csp_data
user = app_roles[0].user
payload = UserCSPPayload(
tenant_id=csp_details.get("tenant_id"),
tenant_host_name=csp_details.get("domain_name"),
display_name=user.full_name,
email=user.email,
)
result = csp.create_user(payload)
for app_role in app_roles:
app_role.cloud_id = result.id
db.session.add(app_role)
db.session.commit()
def do_create_environment(csp: CloudProviderInterface, environment_id=None): def do_create_environment(csp: CloudProviderInterface, environment_id=None):
environment = Environments.get(environment_id) environment = Environments.get(environment_id)
@ -128,21 +156,6 @@ def render_email(template_path, context):
return app.jinja_env.get_template(template_path).render(context) return app.jinja_env.get_template(template_path).render(context)
def do_provision_user(csp: CloudProviderInterface, environment_role_id=None):
environment_role = EnvironmentRoles.get_by_id(environment_role_id)
with claim_for_update(environment_role) as environment_role:
credentials = environment_role.environment.csp_credentials
csp_user_id = csp.create_or_update_user(
credentials, environment_role, environment_role.role
)
environment_role.csp_user_id = csp_user_id
environment_role.status = EnvironmentRole.Status.COMPLETED
db.session.add(environment_role)
db.session.commit()
def do_work(fn, task, csp, **kwargs): def do_work(fn, task, csp, **kwargs):
try: try:
fn(csp, **kwargs) fn(csp, **kwargs)
@ -166,6 +179,13 @@ def create_application(self, application_id=None):
do_work(do_create_application, self, app.csp.cloud, application_id=application_id) do_work(do_create_application, self, app.csp.cloud, application_id=application_id)
@celery.task(bind=True, base=RecordFailure)
def create_user(self, application_role_ids=None):
do_work(
do_create_user, self, app.csp.cloud, application_role_ids=application_role_ids
)
@celery.task(bind=True, base=RecordFailure) @celery.task(bind=True, base=RecordFailure)
def create_environment(self, environment_id=None): def create_environment(self, environment_id=None):
do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id) do_work(do_create_environment, self, app.csp.cloud, environment_id=environment_id)
@ -178,19 +198,12 @@ def create_atat_admin_user(self, environment_id=None):
) )
@celery.task(bind=True)
def provision_user(self, environment_role_id=None):
do_work(
do_provision_user, self, app.csp.cloud, environment_role_id=environment_role_id
)
@celery.task(bind=True) @celery.task(bind=True)
def dispatch_provision_portfolio(self): def dispatch_provision_portfolio(self):
""" """
Iterate over portfolios with a corresponding State Machine that have not completed. Iterate over portfolios with a corresponding State Machine that have not completed.
""" """
for portfolio_id in Portfolios.get_portfolios_pending_provisioning(): for portfolio_id in Portfolios.get_portfolios_pending_provisioning(pendulum.now()):
provision_portfolio.delay(portfolio_id=portfolio_id) provision_portfolio.delay(portfolio_id=portfolio_id)
@ -200,6 +213,12 @@ def dispatch_create_application(self):
create_application.delay(application_id=application_id) create_application.delay(application_id=application_id)
@celery.task(bind=True)
def dispatch_create_user(self):
for application_role_ids in ApplicationRoles.get_pending_creation():
create_user.delay(application_role_ids=application_role_ids)
@celery.task(bind=True) @celery.task(bind=True)
def dispatch_create_environment(self): def dispatch_create_environment(self):
for environment_id in Environments.get_environments_pending_creation( for environment_id in Environments.get_environments_pending_creation(
@ -214,11 +233,3 @@ def dispatch_create_atat_admin_user(self):
pendulum.now() pendulum.now()
): ):
create_atat_admin_user.delay(environment_id=environment_id) create_atat_admin_user.delay(environment_id=environment_id)
@celery.task(bind=True)
def dispatch_provision_user(self):
for (
environment_role_id
) in EnvironmentRoles.get_environment_roles_pending_creation():
provision_user.delay(environment_role_id=environment_role_id)

View File

@ -1,4 +1,4 @@
from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint, TIMESTAMP from sqlalchemy import and_, Column, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship, synonym from sqlalchemy.orm import relationship, synonym
from atst.models.base import Base from atst.models.base import Base
@ -9,7 +9,11 @@ from atst.models.types import Id
class Application( class Application(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
): ):
__tablename__ = "applications" __tablename__ = "applications"
@ -41,7 +45,6 @@ class Application(
) )
cloud_id = Column(String) cloud_id = Column(String)
claimed_until = Column(TIMESTAMP(timezone=True))
@property @property
def users(self): def users(self):

View File

@ -1,5 +1,5 @@
from enum import Enum from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table from sqlalchemy import Index, ForeignKey, Column, Enum as SQLAEnum, Table, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.event import listen from sqlalchemy.event import listen
@ -33,6 +33,7 @@ class ApplicationRole(
mixins.AuditableMixin, mixins.AuditableMixin,
mixins.PermissionsMixin, mixins.PermissionsMixin,
mixins.DeletableMixin, mixins.DeletableMixin,
mixins.ClaimableMixin,
): ):
__tablename__ = "application_roles" __tablename__ = "application_roles"
@ -59,6 +60,8 @@ class ApplicationRole(
primaryjoin="and_(EnvironmentRole.application_role_id == ApplicationRole.id, EnvironmentRole.deleted == False)", primaryjoin="and_(EnvironmentRole.application_role_id == ApplicationRole.id, EnvironmentRole.deleted == False)",
) )
cloud_id = Column(String)
@property @property
def latest_invitation(self): def latest_invitation(self):
if self.invitations: if self.invitations:

View File

@ -1,4 +1,4 @@
from sqlalchemy import Column, ForeignKey, String, TIMESTAMP, UniqueConstraint from sqlalchemy import Column, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from enum import Enum from enum import Enum
@ -9,7 +9,11 @@ import atst.models.types as types
class Environment( class Environment(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
): ):
__tablename__ = "environments" __tablename__ = "environments"
@ -28,8 +32,6 @@ class Environment(
cloud_id = Column(String) cloud_id = Column(String)
root_user_info = Column(JSONB(none_as_null=True)) root_user_info = Column(JSONB(none_as_null=True))
claimed_until = Column(TIMESTAMP(timezone=True))
roles = relationship( roles = relationship(
"EnvironmentRole", "EnvironmentRole",
back_populates="environment", back_populates="environment",

View File

@ -1,5 +1,5 @@
from enum import Enum from enum import Enum
from sqlalchemy import Index, ForeignKey, Column, String, TIMESTAMP, Enum as SQLAEnum from sqlalchemy import Index, ForeignKey, Column, String, Enum as SQLAEnum
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@ -15,7 +15,11 @@ class CSPRole(Enum):
class EnvironmentRole( class EnvironmentRole(
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin Base,
mixins.TimestampsMixin,
mixins.AuditableMixin,
mixins.DeletableMixin,
mixins.ClaimableMixin,
): ):
__tablename__ = "environment_roles" __tablename__ = "environment_roles"
@ -33,7 +37,6 @@ class EnvironmentRole(
application_role = relationship("ApplicationRole") application_role = relationship("ApplicationRole")
csp_user_id = Column(String()) csp_user_id = Column(String())
claimed_until = Column(TIMESTAMP(timezone=True))
class Status(Enum): class Status(Enum):
PENDING = "pending" PENDING = "pending"

View File

@ -4,3 +4,4 @@ from .permissions import PermissionsMixin
from .deletable import DeletableMixin from .deletable import DeletableMixin
from .invites import InvitesMixin from .invites import InvitesMixin
from .state_machines import FSMMixin from .state_machines import FSMMixin
from .claimable import ClaimableMixin

View File

@ -0,0 +1,5 @@
from sqlalchemy import Column, TIMESTAMP
class ClaimableMixin(object):
claimed_until = Column(TIMESTAMP(timezone=True))

View File

@ -175,11 +175,14 @@ class PortfolioStateMachine(
app.logger.info(exc.json()) app.logger.info(exc.json())
print(exc.json()) print(exc.json())
app.logger.info(payload_data) app.logger.info(payload_data)
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(stage) self.fail_stage(stage)
# TODO: catch and handle general CSP exception here
except (ConnectionException, UnknownServerException) as exc: except (ConnectionException, UnknownServerException) as exc:
app.logger.error( app.logger.error(
f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1, f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1,
) )
# TODO: Ensure that failing the stage does not preclude a Celery retry
self.fail_stage(stage) self.fail_stage(stage)
self.finish_stage(stage) self.finish_stage(stage)

View File

@ -1,3 +1,5 @@
from typing import List
from sqlalchemy import func, sql, Interval, and_, or_ from sqlalchemy import func, sql, Interval, and_, or_
from contextlib import contextmanager from contextlib import contextmanager
@ -28,7 +30,7 @@ def claim_for_update(resource, minutes=30):
.filter( .filter(
and_( and_(
Model.id == resource.id, Model.id == resource.id,
or_(Model.claimed_until == None, Model.claimed_until <= func.now()), or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()),
) )
) )
.update({"claimed_until": claim_until}, synchronize_session="fetch") .update({"claimed_until": claim_until}, synchronize_session="fetch")
@ -48,3 +50,51 @@ def claim_for_update(resource, minutes=30):
Model.claimed_until != None Model.claimed_until != None
).update({"claimed_until": None}, synchronize_session="fetch") ).update({"claimed_until": None}, synchronize_session="fetch")
db.session.commit() db.session.commit()
@contextmanager
def claim_many_for_update(resources: List, minutes=30):
"""
Claim a mutually exclusive expiring hold on a group of resources.
Uses the database as a central source of time in case the server clocks have drifted.
Args:
resources: A list of SQLAlchemy model instances with a `claimed_until` attribute.
minutes: The maximum amount of time, in minutes, to hold the claim.
"""
Model = resources[0].__class__
claim_until = func.now() + func.cast(
sql.functions.concat(minutes, " MINUTES"), Interval
)
ids = tuple(r.id for r in resources)
# Optimistically query for and update the resources in question. If they're
# already claimed, `rows_updated` will be 0 and we can give up.
rows_updated = (
db.session.query(Model)
.filter(
and_(
Model.id.in_(ids),
or_(Model.claimed_until.is_(None), Model.claimed_until <= func.now()),
)
)
.update({"claimed_until": claim_until}, synchronize_session="fetch")
)
if rows_updated < 1:
# TODO: Generalize this exception class so it can take multiple resources
raise ClaimFailedException(resources[0])
# Fetch the claimed resources
claimed = db.session.query(Model).filter(Model.id.in_(ids)).all()
try:
# Give the resource to the caller.
yield claimed
finally:
# Release the claim.
db.session.query(Model).filter(Model.id.in_(ids)).filter(
Model.claimed_until != None
).update({"claimed_until": None}, synchronize_session="fetch")
db.session.commit()

View File

@ -23,8 +23,8 @@ def update_celery(celery, app):
"task": "atst.jobs.dispatch_create_atat_admin_user", "task": "atst.jobs.dispatch_create_atat_admin_user",
"schedule": 60, "schedule": 60,
}, },
"beat-dispatch_provision_user": { "beat-dispatch_create_user": {
"task": "atst.jobs.dispatch_provision_user", "task": "atst.jobs.dispatch_create_user",
"schedule": 60, "schedule": 60,
}, },
} }

View File

@ -467,9 +467,10 @@ def revoke_invite(application_id, application_role_id):
if invite.is_pending: if invite.is_pending:
ApplicationInvitations.revoke(invite.token) ApplicationInvitations.revoke(invite.token)
flash( flash(
"application_invite_revoked", "invite_revoked",
resource="Application",
user_name=app_role.user_name, user_name=app_role.user_name,
application_name=g.application.name, resource_name=g.application.name,
) )
else: else:
flash( flash(

View File

@ -37,8 +37,14 @@ def accept_invitation(portfolio_token):
) )
@user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation") @user_can(Permissions.EDIT_PORTFOLIO_USERS, message="revoke invitation")
def revoke_invitation(portfolio_id, portfolio_token): def revoke_invitation(portfolio_id, portfolio_token):
PortfolioInvitations.revoke(portfolio_token) invite = PortfolioInvitations.revoke(portfolio_token)
flash(
"invite_revoked",
resource="Portfolio",
user_name=invite.user_name,
resource_name=g.portfolio.name,
)
return redirect( return redirect(
url_for( url_for(
"portfolios.admin", "portfolios.admin",

View File

@ -33,11 +33,6 @@ MESSAGES = {
"message": "flash.application_invite.resent.message", "message": "flash.application_invite.resent.message",
"category": "success", "category": "success",
}, },
"application_invite_revoked": {
"title": "flash.application_invite.revoked.title",
"message": "flash.application_invite.revoked.message",
"category": "success",
},
"application_member_removed": { "application_member_removed": {
"title": "flash.application_member.removed.title", "title": "flash.application_member.removed.title",
"message": "flash.application_member.removed.message", "message": "flash.application_member.removed.message",
@ -103,6 +98,11 @@ MESSAGES = {
"message": None, "message": None,
"category": "warning", "category": "warning",
}, },
"invite_revoked": {
"title": "flash.invite_revoked.title",
"message": "flash.invite_revoked.message",
"category": "success",
},
"logged_out": { "logged_out": {
"title": "flash.logged_out.title", "title": "flash.logged_out.title",
"message": "flash.logged_out.message", "message": "flash.logged_out.message",

View File

@ -1,30 +1,21 @@
import ExpandSidenavMixin from '../mixins/expand_sidenav'
import ToggleMixin from '../mixins/toggle' import ToggleMixin from '../mixins/toggle'
const cookieName = 'expandSidenav'
export default { export default {
name: 'sidenav-toggler', name: 'sidenav-toggler',
mixins: [ToggleMixin], mixins: [ExpandSidenavMixin, ToggleMixin],
props: { mounted: function() {
defaultVisible: { this.$parent.$emit('sidenavToggle', this.isVisible)
type: Boolean,
default: function() {
if (document.cookie.match(cookieName)) {
return !!document.cookie.match(cookieName + ' *= *true')
} else {
return true
}
},
},
}, },
methods: { methods: {
toggle: function(e) { toggle: function(e) {
e.preventDefault() e.preventDefault()
this.isVisible = !this.isVisible this.isVisible = !this.isVisible
document.cookie = cookieName + '=' + this.isVisible + '; path=/' document.cookie = this.cookieName + '=' + this.isVisible + '; path=/'
this.$parent.$emit('sidenavToggle', this.isVisible)
}, },
}, },
} }

View File

@ -2,6 +2,13 @@ import { buildUploader } from '../lib/upload'
import { emitFieldChange } from '../lib/emitters' import { emitFieldChange } from '../lib/emitters'
import inputValidations from '../lib/input_validations' import inputValidations from '../lib/input_validations'
function uploadResponseOkay(response) {
// check BlobUploadCommonResponse: https://docs.microsoft.com/en-us/javascript/api/@azure/storage-blob/blobuploadcommonresponse?view=azure-node-latest
// The upload operation is a PUT that should return a 201
// https://docs.microsoft.com/en-us/rest/api/storageservices/put-blob#status-code
return response._response.status === 201
}
export default { export default {
name: 'uploadinput', name: 'uploadinput',
@ -21,7 +28,7 @@ export default {
type: String, type: String,
}, },
sizeLimit: { sizeLimit: {
type: String, type: Number,
}, },
}, },
@ -34,7 +41,7 @@ export default {
sizeError: false, sizeError: false,
filenameError: false, filenameError: false,
downloadLink: '', downloadLink: '',
fileSizeLimit: parseInt(this.sizeLimit), fileSizeLimit: this.sizeLimit,
} }
}, },
@ -63,7 +70,7 @@ export default {
const uploader = await this.getUploader() const uploader = await this.getUploader()
const response = await uploader.upload(file) const response = await uploader.upload(file)
if (response.ok) { if (uploadResponseOkay(response)) {
this.attachment = e.target.value this.attachment = e.target.value
this.$refs.attachmentFilename.value = file.name this.$refs.attachmentFilename.value = file.name
this.$refs.attachmentObjectName.value = response.objectName this.$refs.attachmentObjectName.value = response.objectName
@ -73,7 +80,7 @@ export default {
this.downloadLink = await this.getDownloadLink( this.downloadLink = await this.getDownloadLink(
file.name, file.name,
response.objectName uploader.objectName
) )
} else { } else {
emitFieldChange(this) emitFieldChange(this)

View File

@ -32,12 +32,14 @@ import ToForm from './components/forms/to_form'
import ClinFields from './components/clin_fields' import ClinFields from './components/clin_fields'
import PopDateRange from './components/pop_date_range' import PopDateRange from './components/pop_date_range'
import ToggleMenu from './components/toggle_menu' import ToggleMenu from './components/toggle_menu'
import ExpandSidenav from './mixins/expand_sidenav'
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.use(VTooltip) Vue.use(VTooltip)
Vue.mixin(Modal) Vue.mixin(Modal)
Vue.mixin(ExpandSidenav)
const app = new Vue({ const app = new Vue({
el: '#app-root', el: '#app-root',
@ -67,6 +69,12 @@ const app = new Vue({
ToggleMenu, ToggleMenu,
}, },
data: function() {
return {
sidenavExpanded: this.defaultVisible,
}
},
mounted: function() { mounted: function() {
this.$on('modalOpen', data => { this.$on('modalOpen', data => {
if (data['isOpen']) { if (data['isOpen']) {
@ -105,6 +113,10 @@ const app = new Vue({
} }
}) })
}) })
this.$on('sidenavToggle', data => {
this.sidenavExpanded = data
})
}, },
delimiters: ['!{', '}'], delimiters: ['!{', '}'],

View File

@ -1,4 +1,4 @@
import Azure from 'azure-storage' import { BlobServiceClient } from '@azure/storage-blob'
import 'whatwg-fetch' import 'whatwg-fetch'
class AzureUploader { class AzureUploader {
@ -10,46 +10,22 @@ class AzureUploader {
} }
async upload(file) { async upload(file) {
const blobService = Azure.createBlobServiceWithSas( const blobServiceClient = new BlobServiceClient(
`https://${this.accountName}.blob.core.windows.net`, `https://${this.accountName}.blob.core.windows.net?${this.sasToken}`
this.sasToken
) )
const fileReader = new FileReader() const containerClient = blobServiceClient.getContainerClient(
this.containerName
)
const blobClient = containerClient.getBlockBlobClient(this.objectName)
const options = { const options = {
contentSettings: { blobHTTPHeaders: {
contentType: 'application/pdf', blobContentType: 'application/pdf',
}, },
metadata: { metadata: {
filename: file.name, filename: file.name,
}, },
} }
return blobClient.uploadBrowserData(file, options)
return new Promise((resolve, reject) => {
fileReader.addEventListener('load', f => {
blobService.createBlockBlobFromText(
this.containerName,
`${this.objectName}`,
f.target.result,
options,
(err, result) => {
if (err) {
resolve({ ok: false })
} else {
resolve({ ok: true, objectName: this.objectName })
}
}
)
})
fileReader.readAsText(file)
})
}
downloadUrl(objectName) {
const blobService = Azure.createBlobServiceWithSas(
`https://${this.accountName}.blob.core.windows.net`,
this.sasToken
)
return blobService.getUrl(this.containerName, objectName, this.sasToken)
} }
} }
@ -60,7 +36,8 @@ export class MockUploader {
} }
async upload(file, objectName) { async upload(file, objectName) {
return Promise.resolve({ ok: true, objectName: this.objectName }) // mock BlobUploadCommonResponse structure: https://docs.microsoft.com/en-us/javascript/api/@azure/storage-blob/blobuploadcommonresponse?view=azure-node-latest
return Promise.resolve({ _response: { status: 201 } })
} }
} }

View File

@ -0,0 +1,15 @@
export default {
props: {
cookieName: 'expandSidenav',
defaultVisible: {
type: Boolean,
default: function() {
if (document.cookie.match(this.cookieName)) {
return !!document.cookie.match(this.cookieName + ' *= *true')
} else {
return true
}
},
},
},
}

View File

@ -14,9 +14,9 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@azure/storage-blob": "^12.0.2",
"ally.js": "^1.4.1", "ally.js": "^1.4.1",
"autoprefixer": "^9.1.3", "autoprefixer": "^9.1.3",
"azure-storage": "^2.10.3",
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"date-fns": "^1.29.0", "date-fns": "^1.29.0",
"ramda": "^0.25.0", "ramda": "^0.25.0",

View File

@ -47,3 +47,4 @@
@import "sections/application_edit"; @import "sections/application_edit";
@import "sections/reports"; @import "sections/reports";
@import "sections/task_order"; @import "sections/task_order";
@import "sections/ccpo";

View File

@ -11,6 +11,7 @@
.usa-alert { .usa-alert {
padding-bottom: 2.4rem; padding-bottom: 2.4rem;
max-width: $max-panel-width;
} }
@mixin alert { @mixin alert {
@ -97,38 +98,3 @@
} }
} }
} }
.alert {
@include alert;
@include alert-level("info");
&.alert--success {
@include alert-level("success");
.alert__actions {
.icon-link {
@include icon-link-color($color-green, $color-white);
}
}
}
&.alert--warning {
@include alert-level("warning");
.alert__actions {
.icon-link {
@include icon-link-color($color-gold-dark, $color-white);
}
}
}
&.alert--error {
@include alert-level("error");
.alert__actions {
.icon-link {
@include icon-link-color($color-red, $color-white);
}
}
}
}

View File

@ -1,8 +1,13 @@
.error-page { .error-page {
max-width: $max-page-width;
.panel {
box-shadow: none;
background-color: unset;
border: none;
max-width: 475px; max-width: 475px;
margin: auto; margin: auto;
.panel {
&__heading { &__heading {
text-align: center; text-align: center;
padding: $gap 0; padding: $gap 0;
@ -15,17 +20,6 @@
margin-bottom: $gap; margin-bottom: $gap;
} }
} }
&__body {
padding: $gap * 2;
margin: 0;
hr {
width: 80%;
margin: auto;
margin-bottom: $gap * 3;
}
}
} }
.icon { .icon {

View File

@ -12,7 +12,7 @@
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
justify-content: space-between; justify-content: space-between;
max-width: 1190px; max-width: $max-page-width;
a { a {
color: $color-white; color: $color-white;

View File

@ -19,6 +19,7 @@ $sidenav-collapsed-width: 10rem;
$max-panel-width: 90rem; $max-panel-width: 90rem;
$home-pg-icon-width: 6rem; $home-pg-icon-width: 6rem;
$large-spacing: 4rem; $large-spacing: 4rem;
$max-page-width: $max-panel-width + $sidenav-expanded-width + $large-spacing;
/* /*
* USWDS Variables * USWDS Variables

View File

@ -32,22 +32,35 @@
} }
.action-group-footer { .action-group-footer {
padding-top: $gap;
padding-bottom: $gap;
padding-right: $gap * 4;
position: fixed;
bottom: $footer-height;
left: 0;
background: white;
border-top: 1px solid $color-gray-lighter;
z-index: 1;
width: 100%;
&.action-group-footer--expand-offset {
padding-left: $sidenav-expanded-width;
}
&.action-group-footer--collapse-offset {
padding-left: $sidenav-collapsed-width;
}
.action-group-footer--container {
@extend .action-group; @extend .action-group;
margin-top: 0;
margin-bottom: 0;
margin-left: $large-spacing;
max-width: $max-panel-width;
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
margin-top: 0; }
margin-bottom: 0;
padding-top: $gap;
padding-bottom: $gap;
position: fixed;
bottom: $footer-height;
background: white;
right: 0;
padding-right: $gap * 4;
border-top: 1px solid $color-gray-lighter;
width: 100%;
z-index: 1;
} }

View File

@ -0,0 +1,3 @@
.ccpo-panel-container {
max-width: $max-panel-width;
}

View File

@ -39,14 +39,18 @@
</div> </div>
<span class="action-group-footer"> <div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %} {% block next_button %}
{{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }} {{ SaveButton(text=('portfolios.applications.new.step_1_button_text' | translate)) }}
{% endblock %} {% endblock %}
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}"> <a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel Cancel
</a> </a>
</span> </div>
</div>
</form> </form>
</base-form> </base-form>

View File

@ -61,7 +61,10 @@
</div> </div>
</div> </div>
</div> </div>
<span class="action-group-footer"> <div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %} {% block next_button %}
{{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }} {{ SaveButton(text=('portfolios.applications.new.step_2_button_text' | translate)) }}
{% endblock %} {% endblock %}
@ -71,7 +74,8 @@
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}"> <a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
Cancel Cancel
</a> </a>
</span> </div>
</div>
</form> </form>
</application-environments> </application-environments>

View File

@ -25,7 +25,10 @@
action_update="applications.update_new_application_step_3") }} action_update="applications.update_new_application_step_3") }}
<span class="action-group-footer"> <div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
<a class="usa-button" href="{{ url_for('applications.settings', application_id=application_id) }}"> <a class="usa-button" href="{{ url_for('applications.settings', application_id=application_id) }}">
{{ "portfolios.applications.new.step_3_button_text" | translate }} {{ "portfolios.applications.new.step_3_button_text" | translate }}
</a> </a>
@ -35,6 +38,7 @@
<a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}"> <a href="{{ url_for('applications.portfolio_applications', portfolio_id=portfolio.id) }}">
{{ "common.cancel" | translate }} {{ "common.cancel" | translate }}
</a> </a>
</span> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -4,6 +4,7 @@
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% block content %} {% block content %}
<div class="ccpo-panel-container">
<base-form inline-template> <base-form inline-template>
<form id="add-ccpo-user-form" action="{{ url_for('ccpo.submit_new_user') }}" method="POST"> <form id="add-ccpo-user-form" action="{{ url_for('ccpo.submit_new_user') }}" method="POST">
{{ form.csrf_token }} {{ form.csrf_token }}
@ -21,4 +22,5 @@
</div> </div>
</form> </form>
</base-form> </base-form>
</div>
{% endblock %} {% endblock %}

View File

@ -3,6 +3,7 @@
{% from "components/text_input.html" import TextInput %} {% from "components/text_input.html" import TextInput %}
{% block content %} {% block content %}
<div class="ccpo-panel-container">
{% if new_user %} {% if new_user %}
<h3>{{ 'ccpo.form.confirm_user_title' | translate }}</h3> <h3>{{ 'ccpo.form.confirm_user_title' | translate }}</h3>
<form id="add-ccpo-user-form" action="{{ url_for('ccpo.confirm_new_user') }}" method="POST"> <form id="add-ccpo-user-form" action="{{ url_for('ccpo.confirm_new_user') }}" method="POST">
@ -30,4 +31,5 @@
</div> </div>
</form> </form>
{% endif %} {% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -6,6 +6,7 @@
{% from "components/modal.html" import Modal %} {% from "components/modal.html" import Modal %}
{% block content %} {% block content %}
<div class="ccpo-panel-container">
<div class='col'> <div class='col'>
<div class="h2"> <div class="h2">
{{ "ccpo.users_title" | translate }} {{ "ccpo.users_title" | translate }}
@ -80,4 +81,5 @@
{% endcall %} {% endcall %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</div>
{% endblock %} {% endblock %}

View File

@ -5,6 +5,7 @@
{% block content %} {% block content %}
<main class="usa-section usa-content error-page"> <main class="usa-section usa-content error-page">
<div class="panel">
<div class="panel__heading"> <div class="panel__heading">
{{ Icon('cloud', classes="icon--red icon--large")}} {{ Icon('cloud', classes="icon--red icon--large")}}
<hr> <hr>
@ -17,6 +18,7 @@
{%- endif %} {%- endif %}
</p> </p>
</div> </div>
</div>
</main> </main>
{% endblock %} {% endblock %}

View File

@ -10,7 +10,7 @@
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='img/favicon.ico') }} " /> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='img/favicon.ico') }} " />
</head> </head>
<body class="{% if g.modalOpen %} modalOpen{% endif %}"> <body class="{% if g.modalOpen %} modalOpen{% endif %}">
<div id='app-root'>
{% block template_vars %}{% endblock %} {% block template_vars %}{% endblock %}
{% include 'components/usa_header.html' %} {% include 'components/usa_header.html' %}
@ -34,5 +34,6 @@
{% assets "js_all" %} {% assets "js_all" %}
<script src="{{ ASSET_URL }}"></script> <script src="{{ ASSET_URL }}"></script>
{% endassets %} {% endassets %}
</div>
</body> </body>
</html> </html>

View File

@ -15,6 +15,7 @@
<p>{{ "portfolios.header" | translate }}</p> <p>{{ "portfolios.header" | translate }}</p>
<h1>{{ 'portfolios.new.title' | translate }}</h1> <h1>{{ 'portfolios.new.title' | translate }}</h1>
</div> </div>
</div>
{{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }} {{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
<base-form inline-template> <base-form inline-template>
<div class="row"> <div class="row">
@ -38,13 +39,18 @@
{{ "forms.portfolio.defense_component.help_text" | translate | safe }} {{ "forms.portfolio.defense_component.help_text" | translate | safe }}
</div> </div>
</div> </div>
<div class='action-group-footer'> <div
class='action-group-footer'
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %} {% block next_button %}
{{ SaveButton(text=('portfolios.new.save' | translate), form="portfolio-create", element="input") }} {{ SaveButton(text=('portfolios.new.save' | translate), form="portfolio-create", element="input") }}
{% endblock %} {% endblock %}
<a class="usa-button usa-button-secondary" href="{{ url_for('atst.home') }}"> <a class="usa-button usa-button-secondary" href="{{ url_for('atst.home') }}">
Cancel Cancel
</a> </a>
</div>
</div>
</form> </form>
</div> </div>
</base-form> </base-form>

View File

@ -31,7 +31,10 @@
<div class="task-order"> <div class="task-order">
{% block to_builder_form_field %}{% endblock %} {% block to_builder_form_field %}{% endblock %}
</div> </div>
<span class="action-group-footer"> <div
class="action-group-footer"
v-bind:class="{'action-group-footer--expand-offset': this.$root.sidenavExpanded, 'action-group-footer--collapse-offset': !this.$root.sidenavExpanded}">
<div class="action-group-footer--container">
{% block next_button %} {% block next_button %}
<input <input
type="submit" type="submit"
@ -58,14 +61,13 @@
</a> </a>
{%- endif %} {%- endif %}
{% endif %} {% endif %}
<a <a
v-on:click="openModal('cancel')" v-on:click="openModal('cancel')"
class="action-group__action icon-link"> class="action-group__action icon-link">
{{ "common.cancel" | translate }} {{ "common.cancel" | translate }}
</a> </a>
</span> </div>
</div>
</form> </form>
</to-form> </to-form>
{% endblock %} {% endblock %}

View File

@ -5,6 +5,8 @@ from uuid import uuid4
import pytest import pytest
from tests.factories import ApplicationFactory, EnvironmentFactory from tests.factories import ApplicationFactory, EnvironmentFactory
from tests.mock_azure import AUTH_CREDENTIALS, mock_azure from tests.mock_azure import AUTH_CREDENTIALS, mock_azure
import pendulum
import pydantic
from atst.domain.csp.cloud import AzureCloudProvider from atst.domain.csp.cloud import AzureCloudProvider
from atst.domain.csp.cloud.models import ( from atst.domain.csp.cloud.models import (
@ -24,10 +26,12 @@ from atst.domain.csp.cloud.models import (
ManagementGroupCSPResponse, ManagementGroupCSPResponse,
ManagementGroupGetCSPPayload, ManagementGroupGetCSPPayload,
ManagementGroupGetCSPResponse, ManagementGroupGetCSPResponse,
CostManagementQueryCSPResult,
ProductPurchaseCSPPayload, ProductPurchaseCSPPayload,
ProductPurchaseCSPResult, ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult, SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload, SubscriptionVerificationCSPPayload,
@ -765,3 +769,77 @@ def test_create_subscription_verification(mock_azure: AzureCloudProvider):
payload payload
) )
assert result.subscription_id == "60fbbb72-0516-4253-ab18-c92432ba3230" assert result.subscription_id == "60fbbb72-0516-4253-ab18-c92432ba3230"
def test_get_reporting_data(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.json.return_value = {
"eTag": None,
"id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB/providers/Microsoft.CostManagement/query/e82d0cda-2ffb-4476-a98a-425c83c216f9",
"location": None,
"name": "e82d0cda-2ffb-4476-a98a-425c83c216f9",
"properties": {
"columns": [
{"name": "PreTaxCost", "type": "Number"},
{"name": "UsageDate", "type": "Number"},
{"name": "InvoiceId", "type": "String"},
{"name": "Currency", "type": "String"},
],
"nextLink": None,
"rows": [],
},
"sku": None,
"type": "Microsoft.CostManagement/query",
}
mock_result.ok = True
mock_azure.sdk.requests.post.return_value = mock_result
mock_azure = mock_get_secret(mock_azure)
# Subset of a profile's CSP data that we care about for reporting
csp_data = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {
"invoice_sections": [
{
"invoice_section_id": "providers/Microsoft.Billing/billingAccounts/52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31/billingProfiles/XQDJ-6LB4-BG7-TGB/invoiceSections/P73M-XC7J-PJA-TGB",
}
],
},
}
data: CostManagementQueryCSPResult = mock_azure.get_reporting_data(
ReportingCSPPayload(
from_date=pendulum.now().subtract(years=1).add(days=1).format("YYYY-MM-DD"),
to_date=pendulum.now().format("YYYY-MM-DD"),
**csp_data,
)
)
assert isinstance(data, CostManagementQueryCSPResult)
assert data.name == "e82d0cda-2ffb-4476-a98a-425c83c216f9"
assert len(data.properties.columns) == 4
def test_get_reporting_data_malformed_payload(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.ok = True
mock_azure.sdk.requests.post.return_value = mock_result
mock_azure = mock_get_secret(mock_azure)
# Malformed csp_data payloads that should throw pydantic validation errors
index_error = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {"invoice_sections": [],},
}
key_error = {
"tenant_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"billing_profile_properties": {"invoice_sections": [{}],},
}
for malformed_payload in [key_error, index_error]:
with pytest.raises(pydantic.ValidationError):
assert mock_azure.get_reporting_data(
ReportingCSPPayload(
from_date="foo", to_date="bar", **malformed_payload,
)
)

View File

@ -7,6 +7,7 @@ from atst.domain.csp.cloud.models import (
KeyVaultCredentials, KeyVaultCredentials,
ManagementGroupCSPPayload, ManagementGroupCSPPayload,
ManagementGroupCSPResponse, ManagementGroupCSPResponse,
UserCSPPayload,
) )
@ -97,3 +98,26 @@ def test_KeyVaultCredentials_enforce_root_creds():
assert KeyVaultCredentials( assert KeyVaultCredentials(
root_tenant_id="an id", root_sp_client_id="C3PO", root_sp_key="beep boop" root_tenant_id="an id", root_sp_client_id="C3PO", root_sp_key="beep boop"
) )
user_payload = {
"tenant_id": "123",
"display_name": "Han Solo",
"tenant_host_name": "rebelalliance",
"email": "han@moseisley.cantina",
}
def test_UserCSPPayload_mail_nickname():
payload = UserCSPPayload(**user_payload)
assert payload.mail_nickname == f"han.solo"
def test_UserCSPPayload_user_principal_name():
payload = UserCSPPayload(**user_payload)
assert payload.user_principal_name == f"han.solo@rebelalliance.onmicrosoft.com"
def test_UserCSPPayload_password():
payload = UserCSPPayload(**user_payload)
assert payload.password

View File

@ -86,3 +86,79 @@ def test_disable(session):
session.refresh(environment_role) session.refresh(environment_role)
assert member_role.status == ApplicationRoleStatus.DISABLED assert member_role.status == ApplicationRoleStatus.DISABLED
assert environment_role.deleted assert environment_role.deleted
def test_get_pending_creation():
# ready Applications belonging to the same Portfolio
portfolio_one = PortfolioFactory.create()
ready_app = ApplicationFactory.create(cloud_id="123", portfolio=portfolio_one)
ready_app2 = ApplicationFactory.create(cloud_id="321", portfolio=portfolio_one)
# ready Application belonging to a new Portfolio
ready_app3 = ApplicationFactory.create(cloud_id="567")
unready_app = ApplicationFactory.create()
# two distinct Users
user_one = UserFactory.create()
user_two = UserFactory.create()
# Two ApplicationRoles belonging to the same User and
# different Applications. These should sort together because
# they are all under the same portfolio (portfolio_one).
role_one = ApplicationRoleFactory.create(
user=user_one, application=ready_app, status=ApplicationRoleStatus.ACTIVE
)
role_two = ApplicationRoleFactory.create(
user=user_one, application=ready_app2, status=ApplicationRoleStatus.ACTIVE
)
# An ApplicationRole belonging to a different User. This will
# be included but sort separately because it belongs to a
# different user.
role_three = ApplicationRoleFactory.create(
user=user_two, application=ready_app, status=ApplicationRoleStatus.ACTIVE
)
# An ApplicationRole belonging to one of the existing users
# but under a different portfolio. It will sort separately.
role_four = ApplicationRoleFactory.create(
user=user_one, application=ready_app3, status=ApplicationRoleStatus.ACTIVE
)
# This ApplicationRole will not be in the results because its
# application is not ready (implicitly, its cloud_id is not
# set.)
ApplicationRoleFactory.create(
user=UserFactory.create(),
application=unready_app,
status=ApplicationRoleStatus.ACTIVE,
)
# This ApplicationRole will not be in the results because it
# does not have a user associated.
ApplicationRoleFactory.create(
user=None, application=ready_app, status=ApplicationRoleStatus.ACTIVE,
)
# This ApplicationRole will not be in the results because its
# status is not ACTIVE.
ApplicationRoleFactory.create(
user=UserFactory.create(),
application=unready_app,
status=ApplicationRoleStatus.DISABLED,
)
app_ids = ApplicationRoles.get_pending_creation()
expected_ids = [[role_one.id, role_two.id], [role_three.id], [role_four.id]]
# Sort them to produce the same order.
assert sorted(app_ids) == sorted(expected_ids)
def test_get_many():
ar1 = ApplicationRoleFactory.create()
ar2 = ApplicationRoleFactory.create()
ApplicationRoleFactory.create()
result = ApplicationRoles.get_many([ar1.id, ar2.id])
assert result == [ar1, ar2]

View File

@ -1,5 +1,4 @@
import pytest import pytest
import pendulum
from uuid import uuid4 from uuid import uuid4
from atst.domain.environments import Environments from atst.domain.environments import Environments
@ -14,6 +13,7 @@ from tests.factories import (
EnvironmentRoleFactory, EnvironmentRoleFactory,
ApplicationRoleFactory, ApplicationRoleFactory,
) )
from tests.utils import EnvQueryTest
def test_create_environments(): def test_create_environments():
@ -119,40 +119,6 @@ def test_update_does_not_duplicate_names_within_application():
Environments.update(dupe_env, name) Environments.update(dupe_env, name)
class EnvQueryTest:
@property
def NOW(self):
return pendulum.now()
@property
def YESTERDAY(self):
return self.NOW.subtract(days=1)
@property
def TOMORROW(self):
return self.NOW.add(days=1)
def create_portfolio_with_clins(self, start_and_end_dates, env_data=None):
env_data = env_data or {}
return PortfolioFactory.create(
applications=[
{
"name": "Mos Eisley",
"description": "Where Han shot first",
"environments": [{"name": "thebar", **env_data}],
}
],
task_orders=[
{
"create_clins": [
{"start_date": start_date, "end_date": end_date}
for (start_date, end_date) in start_and_end_dates
]
}
],
)
class TestGetEnvironmentsPendingCreate(EnvQueryTest): class TestGetEnvironmentsPendingCreate(EnvQueryTest):
def test_with_expired_clins(self, session): def test_with_expired_clins(self, session):
self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)]) self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)])

View File

@ -26,6 +26,7 @@ from tests.factories import (
PortfolioStateMachineFactory, PortfolioStateMachineFactory,
get_all_portfolio_permission_sets, get_all_portfolio_permission_sets,
) )
from tests.utils import EnvQueryTest
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@ -263,10 +264,44 @@ def test_create_state_machine(portfolio):
assert fsm assert fsm
def test_get_portfolios_pending_provisioning(session): class TestGetPortfoliosPendingCreate(EnvQueryTest):
def test_finds_unstarted(self):
for x in range(5): for x in range(5):
portfolio = PortfolioFactory.create()
sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
if x == 2: if x == 2:
sm.state = FSMStates.COMPLETED state = "COMPLETED"
assert len(Portfolios.get_portfolios_pending_provisioning()) == 4 else:
state = "UNSTARTED"
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status=state
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 4
def test_finds_created(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_CREATED"
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 1
def test_does_not_find_failed(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], state_machine_status="TENANT_FAILED"
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_expired_clins(self):
self.create_portfolio_with_clins([(self.YESTERDAY, self.YESTERDAY)])
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_active_clins(self):
portfolio = self.create_portfolio_with_clins([(self.YESTERDAY, self.TOMORROW)])
Portfolios.get_portfolios_pending_provisioning(self.NOW) == [portfolio.id]
def test_with_future_clins(self):
self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)])
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0
def test_with_already_provisioned_env(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], env_data={"cloud_id": uuid4().hex}
)
assert len(Portfolios.get_portfolios_pending_provisioning(self.NOW)) == 0

104
tests/models/test_utils.py Normal file
View File

@ -0,0 +1,104 @@
from threading import Thread
from atst.domain.exceptions import ClaimFailedException
from atst.models.utils import claim_for_update, claim_many_for_update
from tests.factories import EnvironmentFactory
def test_claim_for_update(session):
environment = EnvironmentFactory.create()
satisfied_claims = []
exceptions = []
# Two threads race to do work on environment and check out the lock
class FirstThread(Thread):
def run(self):
try:
with claim_for_update(environment) as env:
assert env.claimed_until
satisfied_claims.append("FirstThread")
except ClaimFailedException:
exceptions.append("FirstThread")
class SecondThread(Thread):
def run(self):
try:
with claim_for_update(environment) as env:
assert env.claimed_until
satisfied_claims.append("SecondThread")
except ClaimFailedException:
exceptions.append("SecondThread")
t1 = FirstThread()
t2 = SecondThread()
t1.start()
t2.start()
t1.join()
t2.join()
session.refresh(environment)
assert len(satisfied_claims) == 1
assert len(exceptions) == 1
if satisfied_claims == ["FirstThread"]:
assert exceptions == ["SecondThread"]
else:
assert satisfied_claims == ["SecondThread"]
assert exceptions == ["FirstThread"]
# The claim is released
assert environment.claimed_until is None
def test_claim_many_for_update(session):
environments = [
EnvironmentFactory.create(),
EnvironmentFactory.create(),
]
satisfied_claims = []
exceptions = []
# Two threads race to do work on environment and check out the lock
class FirstThread(Thread):
def run(self):
try:
with claim_many_for_update(environments) as envs:
assert all([e.claimed_until for e in envs])
satisfied_claims.append("FirstThread")
except ClaimFailedException:
exceptions.append("FirstThread")
class SecondThread(Thread):
def run(self):
try:
with claim_many_for_update(environments) as envs:
assert all([e.claimed_until for e in envs])
satisfied_claims.append("SecondThread")
except ClaimFailedException:
exceptions.append("SecondThread")
t1 = FirstThread()
t2 = SecondThread()
t1.start()
t2.start()
t1.join()
t2.join()
for env in environments:
session.refresh(env)
assert len(satisfied_claims) == 1
assert len(exceptions) == 1
if satisfied_claims == ["FirstThread"]:
assert exceptions == ["SecondThread"]
else:
assert satisfied_claims == ["SecondThread"]
assert exceptions == ["FirstThread"]
# The claim is released
# assert environment.claimed_until is None

View File

@ -2,27 +2,25 @@ import pendulum
import pytest import pytest
from uuid import uuid4 from uuid import uuid4
from unittest.mock import Mock from unittest.mock import Mock
from threading import Thread
from atst.domain.csp.cloud import MockCloudProvider from atst.domain.csp.cloud import MockCloudProvider
from atst.domain.portfolios import Portfolios from atst.domain.portfolios import Portfolios
from atst.models import ApplicationRoleStatus
from atst.jobs import ( from atst.jobs import (
RecordFailure, RecordFailure,
dispatch_create_environment, dispatch_create_environment,
dispatch_create_application, dispatch_create_application,
dispatch_create_user,
dispatch_create_atat_admin_user, dispatch_create_atat_admin_user,
dispatch_provision_portfolio, dispatch_provision_portfolio,
dispatch_provision_user,
create_environment, create_environment,
do_provision_user, do_create_user,
do_provision_portfolio, do_provision_portfolio,
do_create_environment, do_create_environment,
do_create_application, do_create_application,
do_create_atat_admin_user, do_create_atat_admin_user,
) )
from atst.models.utils import claim_for_update
from atst.domain.exceptions import ClaimFailedException
from tests.factories import ( from tests.factories import (
EnvironmentFactory, EnvironmentFactory,
EnvironmentRoleFactory, EnvironmentRoleFactory,
@ -30,6 +28,7 @@ from tests.factories import (
PortfolioStateMachineFactory, PortfolioStateMachineFactory,
ApplicationFactory, ApplicationFactory,
ApplicationRoleFactory, ApplicationRoleFactory,
UserFactory,
) )
from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure from atst.models import CSPRole, EnvironmentRole, ApplicationRoleStatus, JobFailure
@ -126,6 +125,30 @@ def test_create_application_job_is_idempotent(csp):
csp.create_application.assert_not_called() csp.create_application.assert_not_called()
def test_create_user_job(session, csp):
portfolio = PortfolioFactory.create(
csp_data={
"tenant_id": str(uuid4()),
"domain_name": "rebelalliance.onmicrosoft.com",
}
)
application = ApplicationFactory.create(portfolio=portfolio, cloud_id="321")
user = UserFactory.create(
first_name="Han", last_name="Solo", email="han@example.com"
)
app_role = ApplicationRoleFactory.create(
application=application,
user=user,
status=ApplicationRoleStatus.ACTIVE,
cloud_id=None,
)
do_create_user(csp, [app_role.id])
session.refresh(app_role)
assert app_role.cloud_id
def test_create_atat_admin_user(csp, session): def test_create_atat_admin_user(csp, session):
environment = EnvironmentFactory.create(cloud_id="something") environment = EnvironmentFactory.create(cloud_id="something")
do_create_atat_admin_user(csp, environment.id) do_create_atat_admin_user(csp, environment.id)
@ -181,6 +204,29 @@ def test_dispatch_create_application(monkeypatch):
mock.delay.assert_called_once_with(application_id=app.id) mock.delay.assert_called_once_with(application_id=app.id)
def test_dispatch_create_user(monkeypatch):
application = ApplicationFactory.create(cloud_id="123")
user = UserFactory.create(
first_name="Han", last_name="Solo", email="han@example.com"
)
app_role = ApplicationRoleFactory.create(
application=application,
user=user,
status=ApplicationRoleStatus.ACTIVE,
cloud_id=None,
)
mock = Mock()
monkeypatch.setattr("atst.jobs.create_user", mock)
# When dispatch_create_user is called
dispatch_create_user.run()
# It should cause the create_user task to be called once
# with the application id
mock.delay.assert_called_once_with(application_role_ids=[app_role.id])
def test_dispatch_create_atat_admin_user(session, monkeypatch): def test_dispatch_create_atat_admin_user(session, monkeypatch):
portfolio = PortfolioFactory.create( portfolio = PortfolioFactory.create(
applications=[ applications=[
@ -240,11 +286,8 @@ def test_create_environment_no_dupes(session, celery_app, celery_worker):
assert environment.claimed_until == None assert environment.claimed_until == None
def test_claim_for_update(session): def test_dispatch_provision_portfolio(csp, monkeypatch):
portfolio = PortfolioFactory.create( portfolio = PortfolioFactory.create(
applications=[
{"environments": [{"cloud_id": uuid4().hex, "root_user_info": {}}]}
],
task_orders=[ task_orders=[
{ {
"create_clins": [ "create_clins": [
@ -256,115 +299,6 @@ def test_claim_for_update(session):
} }
], ],
) )
environment = portfolio.applications[0].environments[0]
satisfied_claims = []
exceptions = []
# Two threads race to do work on environment and check out the lock
class FirstThread(Thread):
def run(self):
try:
with claim_for_update(environment):
satisfied_claims.append("FirstThread")
except ClaimFailedException:
exceptions.append("FirstThread")
class SecondThread(Thread):
def run(self):
try:
with claim_for_update(environment):
satisfied_claims.append("SecondThread")
except ClaimFailedException:
exceptions.append("SecondThread")
t1 = FirstThread()
t2 = SecondThread()
t1.start()
t2.start()
t1.join()
t2.join()
session.refresh(environment)
assert len(satisfied_claims) == 1
assert len(exceptions) == 1
if satisfied_claims == ["FirstThread"]:
assert exceptions == ["SecondThread"]
else:
assert satisfied_claims == ["SecondThread"]
assert exceptions == ["FirstThread"]
# The claim is released
assert environment.claimed_until is None
def test_dispatch_provision_user(csp, session, celery_app, celery_worker, monkeypatch):
# Given that I have four environment roles:
# (A) one of which has a completed status
# (B) one of which has an environment that has not been provisioned
# (C) one of which is pending, has a provisioned environment but an inactive application role
# (D) one of which is pending, has a provisioned environment and has an active application role
provisioned_environment = EnvironmentFactory.create(
cloud_id="cloud_id", root_user_info={}
)
unprovisioned_environment = EnvironmentFactory.create()
_er_a = EnvironmentRoleFactory.create(
environment=provisioned_environment, status=EnvironmentRole.Status.COMPLETED
)
_er_b = EnvironmentRoleFactory.create(
environment=unprovisioned_environment, status=EnvironmentRole.Status.PENDING
)
_er_c = EnvironmentRoleFactory.create(
environment=unprovisioned_environment,
status=EnvironmentRole.Status.PENDING,
application_role=ApplicationRoleFactory(status=ApplicationRoleStatus.PENDING),
)
er_d = EnvironmentRoleFactory.create(
environment=provisioned_environment,
status=EnvironmentRole.Status.PENDING,
application_role=ApplicationRoleFactory(status=ApplicationRoleStatus.ACTIVE),
)
mock = Mock()
monkeypatch.setattr("atst.jobs.provision_user", mock)
# When I dispatch the user provisioning task
dispatch_provision_user.run()
# I expect it to dispatch only one call, to EnvironmentRole D
mock.delay.assert_called_once_with(environment_role_id=er_d.id)
def test_do_provision_user(csp, session):
# Given that I have an EnvironmentRole with a provisioned environment
credentials = MockCloudProvider(())._auth_credentials
provisioned_environment = EnvironmentFactory.create(
cloud_id="cloud_id", root_user_info={"credentials": credentials}
)
environment_role = EnvironmentRoleFactory.create(
environment=provisioned_environment,
status=EnvironmentRole.Status.PENDING,
role="ADMIN",
)
# When I call the user provisoning task
do_provision_user(csp=csp, environment_role_id=environment_role.id)
session.refresh(environment_role)
# I expect that the CSP create_or_update_user method will be called
csp.create_or_update_user.assert_called_once_with(
credentials, environment_role, CSPRole.ADMIN
)
# I expect that the EnvironmentRole now has a csp_user_id
assert environment_role.csp_user_id
def test_dispatch_provision_portfolio(
csp, session, portfolio, celery_app, celery_worker, monkeypatch
):
sm = PortfolioStateMachineFactory.create(portfolio=portfolio) sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
mock = Mock() mock = Mock()
monkeypatch.setattr("atst.jobs.provision_portfolio", mock) monkeypatch.setattr("atst.jobs.provision_portfolio", mock)

View File

@ -5,9 +5,12 @@ from unittest.mock import Mock
from OpenSSL import crypto from OpenSSL import crypto
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from flask import template_rendered from flask import template_rendered
import pendulum
from atst.utils.notification_sender import NotificationSender from atst.utils.notification_sender import NotificationSender
import tests.factories as factories
@contextmanager @contextmanager
def captured_templates(app): def captured_templates(app):
@ -62,3 +65,40 @@ def make_crl_list(x509_obj, x509_path):
issuer = x509_obj.issuer.public_bytes(default_backend()) issuer = x509_obj.issuer.public_bytes(default_backend())
filename = os.path.basename(x509_path) filename = os.path.basename(x509_path)
return [(filename, issuer.hex())] return [(filename, issuer.hex())]
class EnvQueryTest:
@property
def NOW(self):
return pendulum.now()
@property
def YESTERDAY(self):
return self.NOW.subtract(days=1)
@property
def TOMORROW(self):
return self.NOW.add(days=1)
def create_portfolio_with_clins(
self, start_and_end_dates, env_data=None, state_machine_status=None
):
env_data = env_data or {}
return factories.PortfolioFactory.create(
state=state_machine_status,
applications=[
{
"name": "Mos Eisley",
"description": "Where Han shot first",
"environments": [{"name": "thebar", **env_data}],
}
],
task_orders=[
{
"create_clins": [
{"start_date": start_date, "end_date": end_date}
for (start_date, end_date) in start_and_end_dates
]
}
],
)

View File

@ -128,9 +128,6 @@ flash:
message: There was an error processing the invitation for {user_name} from {application_name} message: There was an error processing the invitation for {user_name} from {application_name}
resent: resent:
message: "{email} has been sent an invitation to access this Application" message: "{email} has been sent an invitation to access this Application"
revoked:
title: Application invitation revoked
message: You have successfully revoked the invite for {user_name} from {application_name}
application_member: application_member:
removed: removed:
title: Team member removed from application title: Team member removed from application
@ -166,6 +163,9 @@ flash:
errors: errors:
title: There were some errors title: There were some errors
message: Please see below. message: Please see below.
invite_revoked:
title: "{resource} invitation revoked"
message: "You have successfully revoked the invite for {user_name} from {resource_name}"
login_required_message: After you log in, you will be redirected to your destination page. login_required_message: After you log in, you will be redirected to your destination page.
login_required_title: Log in required login_required_title: Log in required
logged_out: logged_out:

246
yarn.lock
View File

@ -2,6 +2,97 @@
# yarn lockfile v1 # yarn lockfile v1
"@azure/abort-controller@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.0.1.tgz#8510935b25ac051e58920300e9d7b511ca6e656a"
integrity sha512-wP2Jw6uPp8DEDy0n4KNidvwzDjyVV2xnycEIq7nPzj1rHyb/r+t3OPeNT1INZePP2wy5ZqlwyuyOMTi0ePyY1A==
dependencies:
tslib "^1.9.3"
"@azure/core-asynciterator-polyfill@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@azure/core-asynciterator-polyfill/-/core-asynciterator-polyfill-1.0.0.tgz#dcccebb88406e5c76e0e1d52e8cc4c43a68b3ee7"
integrity sha512-kmv8CGrPfN9SwMwrkiBK9VTQYxdFQEGe0BmQk+M8io56P9KNzpAxcWE/1fxJj7uouwN4kXF0BHW8DNlgx+wtCg==
"@azure/core-auth@^1.0.0":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.0.2.tgz#7377c0cacf0e3c988ce321295bf5d2c174e0e288"
integrity sha512-zhPJObdrhz2ymIqGL1x8i3meEuaLz0UPjH9mOq9RGOlJB2Pb6K6xPtkHbRsfElgoO9USR4hH2XU5pLa4/JHHIw==
dependencies:
"@azure/abort-controller" "^1.0.0"
"@azure/core-tracing" "1.0.0-preview.7"
"@opentelemetry/types" "^0.2.0"
tslib "^1.9.3"
"@azure/core-http@^1.0.0":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@azure/core-http/-/core-http-1.0.3.tgz#faea8da9187278b7109c2af74d6f96e0c806e636"
integrity sha512-hmsalo2i1noF5LMwNBNymJnf210ha7Rh6x+BQBBcb+wUZI5hVGRbaRgHzqpJiH8FmfJrDuKZI+S7i2rILUBJTg==
dependencies:
"@azure/abort-controller" "^1.0.0"
"@azure/core-auth" "^1.0.0"
"@azure/core-tracing" "1.0.0-preview.7"
"@azure/logger" "^1.0.0"
"@opentelemetry/types" "^0.2.0"
"@types/node-fetch" "^2.5.0"
"@types/tunnel" "^0.0.1"
form-data "^3.0.0"
node-fetch "^2.6.0"
process "^0.11.10"
tough-cookie "^3.0.1"
tslib "^1.10.0"
tunnel "^0.0.6"
uuid "^3.3.2"
xml2js "^0.4.19"
"@azure/core-lro@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@azure/core-lro/-/core-lro-1.0.0.tgz#9837398e03aa04b5b0b09158f4338861348dcce4"
integrity sha512-l4abIb8S9qmlv3bJkonLvgGSVQcSXq5jByA7Z28GRGJaQN/mSFal9YQOuLvVag+JXQJsoftuxJFrZiggF2TwOg==
dependencies:
"@azure/abort-controller" "^1.0.0"
"@azure/core-http" "^1.0.0"
events "^3.0.0"
tslib "^1.9.3"
"@azure/core-paging@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@azure/core-paging/-/core-paging-1.0.0.tgz#3aa3855582154260326feea97f9f8322cbfe56d9"
integrity sha512-CzaT7LwxU97PZ+/Pn7uAbNGXY2mJ/3b56kmLsZzbR9stfrNfzlILxR94WHG/D1jZEQOk4lUNiaqJ2zP7nSGJhA==
dependencies:
"@azure/core-asynciterator-polyfill" "^1.0.0"
"@azure/core-tracing@1.0.0-preview.7":
version "1.0.0-preview.7"
resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.0-preview.7.tgz#e9ee9c88f0dcf50d8e5b468fc827203165ecbc3f"
integrity sha512-pkFCw6OiJrpR+aH1VQe6DYm3fK2KWCC5Jf3m/Pv1RxF08M1Xm08RCyQ5Qe0YyW5L16yYT2nnV48krVhYZ6SGFA==
dependencies:
"@opencensus/web-types" "0.0.7"
"@opentelemetry/types" "^0.2.0"
tslib "^1.9.3"
"@azure/logger@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.0.0.tgz#48b371dfb34288c8797e5c104f6c4fb45bf1772c"
integrity sha512-g2qLDgvmhyIxR3JVS8N67CyIOeFRKQlX/llxYJQr1OSGQqM3HTpVP8MjmjcEKbL/OIt2N9C9UFaNQuKOw1laOA==
dependencies:
tslib "^1.9.3"
"@azure/storage-blob@^12.0.2":
version "12.0.2"
resolved "https://registry.yarnpkg.com/@azure/storage-blob/-/storage-blob-12.0.2.tgz#6bdaf1171007972051024f3ca852e06a82d7f1a0"
integrity sha512-URELuzMzSDKUVRLt+bXIh/PZe54qWiLxDQ443rcvfpRAPP3QBLnDFw6hFacSXeU/0bOIRcbzAeKtKOMhoE7lSw==
dependencies:
"@azure/abort-controller" "^1.0.0"
"@azure/core-http" "^1.0.0"
"@azure/core-lro" "^1.0.0"
"@azure/core-paging" "^1.0.0"
"@azure/core-tracing" "1.0.0-preview.7"
"@azure/logger" "^1.0.0"
"@opentelemetry/types" "^0.2.0"
events "^3.0.0"
tslib "^1.10.0"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5": "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5":
version "7.5.5" version "7.5.5"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d"
@ -720,6 +811,16 @@
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
"@opencensus/web-types@0.0.7":
version "0.0.7"
resolved "https://registry.yarnpkg.com/@opencensus/web-types/-/web-types-0.0.7.tgz#4426de1fe5aa8f624db395d2152b902874f0570a"
integrity sha512-xB+w7ZDAu3YBzqH44rCmG9/RlrOmFuDPt/bpf17eJr8eZSrLt7nc7LnWdxM9Mmoj/YKMHpxRg28txu3TcpiL+g==
"@opentelemetry/types@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/types/-/types-0.2.0.tgz#2a0afd40fa7026e39ea56a454642bda72b172f80"
integrity sha512-GtwNB6BNDdsIPAYEdpp3JnOGO/3AJxjPvny53s3HERBdXSJTGQw8IRhiaTEX0b3w9P8+FwFZde4k+qkjn67aVw==
"@parcel/fs@^1.11.0": "@parcel/fs@^1.11.0":
version "1.11.0" version "1.11.0"
resolved "https://registry.yarnpkg.com/@parcel/fs/-/fs-1.11.0.tgz#fb8a2be038c454ad46a50dc0554c1805f13535cd" resolved "https://registry.yarnpkg.com/@parcel/fs/-/fs-1.11.0.tgz#fb8a2be038c454ad46a50dc0554c1805f13535cd"
@ -761,6 +862,18 @@
"@parcel/utils" "^1.11.0" "@parcel/utils" "^1.11.0"
physical-cpu-count "^2.0.0" physical-cpu-count "^2.0.0"
"@types/node-fetch@^2.5.0":
version "2.5.4"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.4.tgz#5245b6d8841fc3a6208b82291119bc11c4e0ce44"
integrity sha512-Oz6id++2qAOFuOlE1j0ouk1dzl3mmI1+qINPNBhi9nt/gVOz0G+13Ao6qjhdF0Ys+eOkhu6JnFmt38bR3H0POQ==
dependencies:
"@types/node" "*"
"@types/node@*":
version "13.7.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-13.7.0.tgz#b417deda18cf8400f278733499ad5547ed1abec4"
integrity sha512-GnZbirvmqZUzMgkFn70c74OQpTTUcCzlhQliTzYjQMqg+hVKcDnxdL19Ne3UdYzdMA/+W3eb646FWn/ZaT1NfQ==
"@types/node@^10.11.7": "@types/node@^10.11.7":
version "10.14.8" version "10.14.8"
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.8.tgz#fe444203ecef1162348cd6deb76c62477b2cc6e9" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.8.tgz#fe444203ecef1162348cd6deb76c62477b2cc6e9"
@ -780,6 +893,13 @@
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ== integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
"@types/tunnel@^0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@types/tunnel/-/tunnel-0.0.1.tgz#0d72774768b73df26f25df9184273a42da72b19c"
integrity sha512-AOqu6bQu5MSWwYvehMXLukFHnupHrpZ8nvgae5Ggie9UwzDR1CCwoXgSSWNZJuyOlCdfdsWMA5F2LlmvyoTv8A==
dependencies:
"@types/node" "*"
"@vue/test-utils@^1.0.0-beta.25": "@vue/test-utils@^1.0.0-beta.25":
version "1.0.0-beta.25" version "1.0.0-beta.25"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.25.tgz#4703076de3076bac42cdd242cd53e6fb8752ed8c" resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.0.0-beta.25.tgz#4703076de3076bac42cdd242cd53e6fb8752ed8c"
@ -1112,23 +1232,6 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.0.tgz#24390e6ad61386b0a747265754d2a17219de862c" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.0.tgz#24390e6ad61386b0a747265754d2a17219de862c"
integrity sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A== integrity sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A==
azure-storage@^2.10.3:
version "2.10.3"
resolved "https://registry.yarnpkg.com/azure-storage/-/azure-storage-2.10.3.tgz#c5966bf929d87587d78f6847040ea9a4b1d4a50a"
integrity sha512-IGLs5Xj6kO8Ii90KerQrrwuJKexLgSwYC4oLWmc11mzKe7Jt2E5IVg+ZQ8K53YWZACtVTMBNO3iGuA+4ipjJxQ==
dependencies:
browserify-mime "~1.2.9"
extend "^3.0.2"
json-edm-parser "0.1.2"
md5.js "1.3.4"
readable-stream "~2.0.0"
request "^2.86.0"
underscore "~1.8.3"
uuid "^3.0.0"
validator "~9.4.1"
xml2js "0.2.8"
xmlbuilder "^9.0.7"
babel-code-frame@^6.26.0: babel-code-frame@^6.26.0:
version "6.26.0" version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
@ -1814,11 +1917,6 @@ browserify-des@^1.0.0:
inherits "^2.0.1" inherits "^2.0.1"
safe-buffer "^5.1.2" safe-buffer "^5.1.2"
browserify-mime@~1.2.9:
version "1.2.9"
resolved "https://registry.yarnpkg.com/browserify-mime/-/browserify-mime-1.2.9.tgz#aeb1af28de6c0d7a6a2ce40adb68ff18422af31f"
integrity sha1-rrGvKN5sDXpqLOQK22j/GEIq8x8=
browserify-rsa@^4.0.0: browserify-rsa@^4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524"
@ -2272,7 +2370,7 @@ combine-source-map@^0.8.0, combine-source-map@~0.8.0:
lodash.memoize "~3.0.3" lodash.memoize "~3.0.3"
source-map "~0.5.3" source-map "~0.5.3"
combined-stream@^1.0.6, combined-stream@~1.0.6: combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@ -3219,7 +3317,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
assign-symbols "^1.0.0" assign-symbols "^1.0.0"
is-extendable "^1.0.1" is-extendable "^1.0.1"
extend@^3.0.2, extend@~3.0.2: extend@~3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
@ -3391,6 +3489,15 @@ forever-agent@~0.6.1:
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
form-data@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
form-data@~2.3.2: form-data@~2.3.2:
version "2.3.3" version "2.3.3"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
@ -3976,6 +4083,11 @@ invert-kv@^2.0.0:
resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02"
integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==
ip-regex@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=
is-absolute-url@^2.0.0: is-absolute-url@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
@ -4822,13 +4934,6 @@ jsesc@~0.5.0:
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
json-edm-parser@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/json-edm-parser/-/json-edm-parser-0.1.2.tgz#1e60b0fef1bc0af67bc0d146dfdde5486cd615b4"
integrity sha1-HmCw/vG8CvZ7wNFG393lSGzWFbQ=
dependencies:
jsonparse "~1.2.0"
json-parse-better-errors@^1.0.1: json-parse-better-errors@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
@ -4882,11 +4987,6 @@ jsonparse@^1.2.0:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
jsonparse@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.2.0.tgz#5c0c5685107160e72fe7489bddea0b44c2bc67bd"
integrity sha1-XAxWhRBxYOcv50ib3eoLRMK8Z70=
jsprim@^1.2.2: jsprim@^1.2.2:
version "1.4.1" version "1.4.1"
resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
@ -5112,14 +5212,6 @@ math-random@^1.0.1:
resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
md5.js@1.3.4:
version "1.3.4"
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.4.tgz#e9bdbde94a20a5ac18b04340fc5764d5b09d901d"
integrity sha1-6b296UogpawYsENA/Fdk1bCdkB0=
dependencies:
hash-base "^3.0.0"
inherits "^2.0.1"
md5.js@^1.3.4: md5.js@^1.3.4:
version "1.3.5" version "1.3.5"
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
@ -5411,6 +5503,11 @@ node-addon-api@^1.7.1:
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.1.tgz#cf813cd69bb8d9100f6bdca6755fc268f54ac492" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.7.1.tgz#cf813cd69bb8d9100f6bdca6755fc268f54ac492"
integrity sha512-2+DuKodWvwRTrCfKOeR24KIc5unKjOh8mz17NCzVnHWfjAdDqbfbjqh7gUT+BkXBRQM52+xCHciKWonJ3CbJMQ== integrity sha512-2+DuKodWvwRTrCfKOeR24KIc5unKjOh8mz17NCzVnHWfjAdDqbfbjqh7gUT+BkXBRQM52+xCHciKWonJ3CbJMQ==
node-fetch@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
node-forge@^0.7.1: node-forge@^0.7.1:
version "0.7.6" version "0.7.6"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac"
@ -6931,7 +7028,7 @@ request-promise-native@^1.0.5:
stealthy-require "^1.1.1" stealthy-require "^1.1.1"
tough-cookie "^2.3.3" tough-cookie "^2.3.3"
request@^2.86.0, request@^2.87.0, request@^2.88.0: request@^2.87.0, request@^2.88.0:
version "2.88.0" version "2.88.0"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg== integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
@ -7116,12 +7213,7 @@ sass-graph@^2.2.4:
scss-tokenizer "^0.2.3" scss-tokenizer "^0.2.3"
yargs "^7.0.0" yargs "^7.0.0"
sax@0.5.x: sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4:
version "0.5.8"
resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1"
integrity sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE=
sax@^1.2.4, sax@~1.2.4:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@ -7885,6 +7977,15 @@ tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@^2.5.0:
psl "^1.1.28" psl "^1.1.28"
punycode "^2.1.1" punycode "^2.1.1"
tough-cookie@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-3.0.1.tgz#9df4f57e739c26930a018184887f4adb7dca73b2"
integrity sha512-yQyJ0u4pZsv9D4clxO69OEjLWYw+jbgspjTue4lTQZLfV0c5l1VmK2y1JK8E9ahdpltPOaAThPcp5nKPUgSnsg==
dependencies:
ip-regex "^2.1.0"
psl "^1.1.28"
punycode "^2.1.1"
tough-cookie@~2.4.3: tough-cookie@~2.4.3:
version "2.4.3" version "2.4.3"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
@ -7917,6 +8018,11 @@ trim-right@^1.0.1:
dependencies: dependencies:
glob "^7.1.2" glob "^7.1.2"
tslib@^1.10.0, tslib@^1.9.3:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
tty-browserify@0.0.0: tty-browserify@0.0.0:
version "0.0.0" version "0.0.0"
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@ -7933,6 +8039,11 @@ tunnel-agent@^0.6.0:
dependencies: dependencies:
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
tunnel@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
tweetnacl@^0.14.3, tweetnacl@~0.14.0: tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5" version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
@ -7990,11 +8101,6 @@ undeclared-identifiers@^1.1.2:
simple-concat "^1.0.0" simple-concat "^1.0.0"
xtend "^4.0.1" xtend "^4.0.1"
underscore@~1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
integrity sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI=
unicode-canonical-property-names-ecmascript@^1.0.4: unicode-canonical-property-names-ecmascript@^1.0.4:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
@ -8138,10 +8244,6 @@ util@~0.10.1:
dependencies: dependencies:
inherits "2.0.3" inherits "2.0.3"
uuid@^3.0.0:
version "3.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
uuid@^3.3.2: uuid@^3.3.2:
version "3.3.3" version "3.3.3"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866"
@ -8169,11 +8271,6 @@ validate-npm-package-license@^3.0.1:
spdx-correct "^3.0.0" spdx-correct "^3.0.0"
spdx-expression-parse "^3.0.0" spdx-expression-parse "^3.0.0"
validator@~9.4.1:
version "9.4.1"
resolved "https://registry.yarnpkg.com/validator/-/validator-9.4.1.tgz#abf466d398b561cd243050112c6ff1de6cc12663"
integrity sha512-YV5KjzvRmSyJ1ee/Dm5UED0G+1L4GZnLN3w6/T+zZm8scVua4sOhYKWTUrKa0H/tMiJyO9QLHMPN+9mB/aMunA==
vendors@^1.0.0: vendors@^1.0.0:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.3.tgz#a6467781abd366217c050f8202e7e50cc9eef8c0" resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.3.tgz#a6467781abd366217c050f8202e7e50cc9eef8c0"
@ -8386,17 +8483,18 @@ xml-name-validator@^3.0.0:
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
xml2js@0.2.8: xml2js@^0.4.19:
version "0.2.8" version "0.4.23"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.2.8.tgz#9b81690931631ff09d1957549faf54f4f980b3c2" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
integrity sha1-m4FpCTFjH/CdGVdUn69U9PmAs8I= integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==
dependencies: dependencies:
sax "0.5.x" sax ">=0.6.0"
xmlbuilder "~11.0.0"
xmlbuilder@^9.0.7: xmlbuilder@~11.0.0:
version "9.0.7" version "11.0.1"
resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3"
integrity sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0= integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==
xmlchars@^2.1.1: xmlchars@^2.1.1:
version "2.2.0" version "2.2.0"