Merge branch 'staging' into environment-role-creation

This commit is contained in:
dandds 2020-02-06 05:34:12 -05:00
commit 872500099e
46 changed files with 950 additions and 707 deletions

View File

@ -0,0 +1,28 @@
"""Remove root_user_info from Environment
Revision ID: 0039308c6351
Revises: 17da2a475429
Create Date: 2020-02-04 14:37:06.814645
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0039308c6351' # pragma: allowlist secret
down_revision = '17da2a475429' # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('environments', 'root_user_info')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('environments', sa.Column('root_user_info', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
# ### end Alembic commands ###

View File

@ -1,7 +1,7 @@
"""change to environment_roles.cloud_Id """change to environment_roles.cloud_Id
Revision ID: 418b52c1cedf Revision ID: 418b52c1cedf
Revises: 17da2a475429 Revises: 0039308c6351
Create Date: 2020-02-05 13:40:37.870183 Create Date: 2020-02-05 13:40:37.870183
""" """
@ -11,7 +11,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision = '418b52c1cedf' # pragma: allowlist secret revision = '418b52c1cedf' # pragma: allowlist secret
down_revision = '17da2a475429' # pragma: allowlist secret down_revision = '0039308c6351' # pragma: allowlist secret
branch_labels = None branch_labels = None
depends_on = None depends_on = None

View File

@ -6,12 +6,12 @@ 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, UserProvisioningException from .exceptions import (
AuthenticationException,
SecretException,
UserProvisioningException,
)
from .models import ( from .models import (
SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload,
SuscriptionVerificationCSPResult,
AdminRoleDefinitionCSPPayload, AdminRoleDefinitionCSPPayload,
AdminRoleDefinitionCSPResult, AdminRoleDefinitionCSPResult,
ApplicationCSPPayload, ApplicationCSPPayload,
@ -24,14 +24,21 @@ from .models import (
BillingProfileTenantAccessCSPResult, BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult, BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
EnvironmentCSPPayload,
EnvironmentCSPResult,
KeyVaultCredentials, KeyVaultCredentials,
ManagementGroupCSPResponse, PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult,
ProductPurchaseCSPPayload, ProductPurchaseCSPPayload,
ProductPurchaseCSPResult, ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload, ReportingCSPPayload,
PrincipalAdminRoleCSPResult, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload,
SuscriptionVerificationCSPResult,
TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult, TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload, TaskOrderBillingVerificationCSPPayload,
@ -55,7 +62,6 @@ from .models import (
) )
from .policy import AzurePolicyManager from .policy import AzurePolicyManager
# This needs to be a fully pathed role definition identifier, not just a UUID # This needs to be a fully pathed role definition identifier, not just a UUID
# TODO: Extract these from sdk msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD # TODO: Extract these from sdk msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
AZURE_SKU_ID = "0001" # probably a static sku specific to ATAT/JEDI AZURE_SKU_ID = "0001" # probably a static sku specific to ATAT/JEDI
@ -122,11 +128,15 @@ class AzureCloudProvider(CloudProviderInterface):
) )
try: try:
return secret_client.set_secret(secret_key, secret_value) return secret_client.set_secret(secret_key, secret_value)
except self.exceptions.HttpResponseError: except self.sdk.exceptions.HttpResponseError as exc:
app.logger.error( app.logger.error(
f"Could not SET secret in Azure keyvault for key {secret_key}.", f"Could not SET secret in Azure keyvault for key {secret_key}.",
exc_info=1, exc_info=1,
) )
raise SecretException(
f"Could not SET secret in Azure keyvault for key {secret_key}.",
exc.message,
)
def get_secret(self, secret_key): def get_secret(self, secret_key):
credential = self._get_client_secret_credential_obj() credential = self._get_client_secret_credential_obj()
@ -135,67 +145,35 @@ class AzureCloudProvider(CloudProviderInterface):
) )
try: try:
return secret_client.get_secret(secret_key).value return secret_client.get_secret(secret_key).value
except self.exceptions.HttpResponseError: except self.sdk.exceptions.HttpResponseError:
app.logger.error( app.logger.error(
f"Could not GET secret in Azure keyvault for key {secret_key}.", f"Could not GET secret in Azure keyvault for key {secret_key}.",
exc_info=1, exc_info=1,
) )
raise SecretException(
def create_environment(self, auth_credentials: Dict, user, environment): f"Could not GET secret in Azure keyvault for key {secret_key}.",
# since this operation would only occur within a tenant, should we source the tenant exc.message,
# via lookup from environment once we've created the portfolio csp data schema
# something like this:
# environment_tenant = environment.application.portfolio.csp_data.get('tenant_id', None)
# though we'd probably source the whole credentials for these calls from the portfolio csp
# data, as it would have to be where we store the creds for the at-at user within the portfolio tenant
# credentials = self._get_credential_obj(environment.application.portfolio.csp_data.get_creds())
credentials = self._get_credential_obj(self._root_creds)
display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format
management_group_id = "?" # management group id chained from environment
parent_id = "?" # from environment.application
management_group = self._create_management_group(
credentials, management_group_id, display_name, parent_id,
) )
return ManagementGroupCSPResponse(**management_group) def create_environment(self, payload: EnvironmentCSPPayload):
creds = self._source_creds(payload.tenant_id)
credentials = self._get_credential_obj(
{
"client_id": creds.tenant_sp_client_id,
"secret_key": creds.tenant_sp_key,
"tenant_id": creds.tenant_id,
},
resource=self.sdk.cloud.endpoints.resource_manager,
)
def create_atat_admin_user( response = self._create_management_group(
self, auth_credentials: Dict, csp_environment_id: str
) -> Dict:
root_creds = self._root_creds
credentials = self._get_credential_obj(root_creds)
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
subscription = sub_client.subscriptions.get(csp_environment_id)
managment_principal = self._get_management_service_principal()
auth_client = self.sdk.authorization.AuthorizationManagementClient(
credentials, credentials,
# TODO: Determine which subscription this needs to point at payload.management_group_name,
# Once we're in a multi-sub environment payload.display_name,
subscription.id, payload.parent_id,
) )
# Create role assignment for return EnvironmentCSPResult(**response)
role_assignment_id = str(uuid4())
role_assignment_create_params = auth_client.role_assignments.models.RoleAssignmentCreateParameters(
role_definition_id=REMOTE_ROOT_ROLE_DEF_ID,
principal_id=managment_principal.id,
)
auth_client.role_assignments.create(
scope=f"/subscriptions/{subscription.id}/",
role_assignment_name=role_assignment_id,
parameters=role_assignment_create_params,
)
return {
"csp_user_id": managment_principal.object_id,
"credentials": managment_principal.password_credentials,
"role_name": role_assignment_id,
}
def create_application(self, payload: ApplicationCSPPayload): def create_application(self, payload: ApplicationCSPPayload):
creds = self._source_creds(payload.tenant_id) creds = self._source_creds(payload.tenant_id)
@ -804,17 +782,6 @@ class AzureCloudProvider(CloudProviderInterface):
return self._ok() return self._ok()
def create_billing_alerts(self, TBD):
# TODO: Add azure-mgmt-consumption for Budget and Notification entities/operations
# TODO: Determine how to auth against that API using the SDK, doesn't seeem possible at the moment
# TODO: billing alerts are registered as Notifications on Budget objects, which have start/end dates
# TODO: determine what the keys in the Notifications dict are supposed to be
# we may need to rotate budget objects when new TOs/CLINs are reported?
# we likely only want the budget ID, can be updated or replaced?
response = {"id": "id"}
return self._ok({"budget_id": response["id"]})
def _get_management_service_principal(self): def _get_management_service_principal(self):
# we really should be using graph.microsoft.com, but i'm getting # we really should be using graph.microsoft.com, but i'm getting
# "expired token" errors for that # "expired token" errors for that
@ -941,7 +908,8 @@ class AzureCloudProvider(CloudProviderInterface):
"Could not resolve graph token for tenant admin" "Could not resolve graph token for tenant admin"
) )
role_definition_id = f"/providers/Microsoft.Management/managementGroups/{payload.management_group_id}/providers/Microsoft.Authorization/roleDefinitions/{self.roles[payload.role]}" role_guid = self.roles[payload.role]
role_definition_id = f"/providers/Microsoft.Management/managementGroups/{payload.management_group_id}/providers/Microsoft.Authorization/roleDefinitions/{role_guid}"
request_body = { request_body = {
"properties": { "properties": {
@ -1109,3 +1077,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

@ -11,7 +11,7 @@ class CloudProviderInterface: # pragma: no cover
def root_creds(self) -> Dict: def root_creds(self) -> Dict:
raise NotImplementedError() raise NotImplementedError()
def create_environment(self, auth_credentials: Dict, user, environment) -> str: def create_environment(self, payload):
"""Create a new environment in the CSP. """Create a new environment in the CSP.
Arguments: Arguments:
@ -31,33 +31,6 @@ class CloudProviderInterface: # pragma: no cover
""" """
raise NotImplementedError() raise NotImplementedError()
def create_atat_admin_user(
self, auth_credentials: Dict, csp_environment_id: str
) -> Dict:
"""Creates a new, programmatic user in the CSP. Grants this user full permissions to administer
the CSP.
Arguments:
auth_credentials -- Object containing CSP account credentials
csp_environment_id -- ID of the CSP Environment the admin user should be created in
Returns:
object: Object representing new remote admin user, including credentials
Something like:
{
"user_id": string,
"credentials": dict, # structure TBD based on csp
}
Raises:
AuthenticationException: Problem with the credentials
AuthorizationException: Credentials not authorized for current action(s)
ConnectionException: Issue with the CSP API connection
UnknownServerException: Unknown issue on the CSP side
UserProvisioningException: Problem creating the root user
"""
raise NotImplementedError()
def create_or_update_user( def create_or_update_user(
self, auth_credentials: Dict, user_info, csp_role_id: str self, auth_credentials: Dict, user_info, csp_role_id: str
) -> str: ) -> str:

View File

@ -118,3 +118,17 @@ class BaselineProvisionException(GeneralCSPException):
return "Could not complete baseline provisioning for environment ({}): {}".format( return "Could not complete baseline provisioning for environment ({}): {}".format(
self.env_identifier, self.reason self.env_identifier, self.reason
) )
class SecretException(GeneralCSPException):
"""A problem occurred with setting or getting secrets"""
def __init__(self, tenant_id, reason):
self.tenant_id = tenant_id
self.reason = reason
@property
def message(self):
return "Could not get or set secret for ({}): {}".format(
self.tenant_id, self.reason
)

View File

@ -4,9 +4,7 @@ from .cloud_provider_interface import CloudProviderInterface
from .exceptions import ( from .exceptions import (
AuthenticationException, AuthenticationException,
AuthorizationException, AuthorizationException,
BaselineProvisionException,
ConnectionException, ConnectionException,
EnvironmentCreationException,
GeneralCSPException, GeneralCSPException,
UnknownServerException, UnknownServerException,
UserProvisioningException, UserProvisioningException,
@ -25,16 +23,21 @@ from .models import (
BillingProfileTenantAccessCSPResult, BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult, BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
CostManagementQueryProperties,
ProductPurchaseCSPPayload, ProductPurchaseCSPPayload,
ProductPurchaseCSPResult, ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
PrincipalAdminRoleCSPPayload, PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult, PrincipalAdminRoleCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult, SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload, SubscriptionVerificationCSPPayload,
SuscriptionVerificationCSPResult, SuscriptionVerificationCSPResult,
EnvironmentCSPPayload,
EnvironmentCSPResult,
TaskOrderBillingCreationCSPPayload, TaskOrderBillingCreationCSPPayload,
TaskOrderBillingCreationCSPResult, TaskOrderBillingCreationCSPResult,
TaskOrderBillingVerificationCSPPayload, TaskOrderBillingVerificationCSPPayload,
@ -91,34 +94,6 @@ class MockCloudProvider(CloudProviderInterface):
def get_secret(self, secret_key: str, default=dict()): def get_secret(self, secret_key: str, default=dict()):
return default return default
def create_environment(self, auth_credentials, user, environment):
self._authorize(auth_credentials)
self._delay(1, 5)
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(
self.ENV_CREATE_FAILURE_PCT,
EnvironmentCreationException(
environment.id, "Could not create environment."
),
)
csp_environment_id = self._id()
self._delay(1, 5)
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(
self.ATAT_ADMIN_CREATE_FAILURE_PCT,
BaselineProvisionException(
csp_environment_id, "Could not create environment baseline."
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return csp_environment_id
def create_subscription(self, payload: SubscriptionCreationCSPPayload): def create_subscription(self, payload: SubscriptionCreationCSPPayload):
return self.create_subscription_creation(payload) return self.create_subscription_creation(payload)
@ -142,23 +117,6 @@ class MockCloudProvider(CloudProviderInterface):
subscription_id="subscriptions/60fbbb72-0516-4253-ab18-c92432ba3230" subscription_id="subscriptions/60fbbb72-0516-4253-ab18-c92432ba3230"
) )
def create_atat_admin_user(self, auth_credentials, csp_environment_id):
self._authorize(auth_credentials)
self._delay(1, 5)
self._maybe_raise(self.NETWORK_FAILURE_PCT, self.NETWORK_EXCEPTION)
self._maybe_raise(self.SERVER_FAILURE_PCT, self.SERVER_EXCEPTION)
self._maybe_raise(
self.ATAT_ADMIN_CREATE_FAILURE_PCT,
UserProvisioningException(
csp_environment_id, "atat_admin", "Could not create admin user."
),
)
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
return {"id": self._id(), "credentials": self._auth_credentials}
def create_tenant(self, payload: TenantCSPPayload): def create_tenant(self, payload: TenantCSPPayload):
""" """
payload is an instance of TenantCSPPayload data class payload is an instance of TenantCSPPayload data class
@ -477,6 +435,13 @@ class MockCloudProvider(CloudProviderInterface):
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}" id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
) )
def create_environment(self, payload: EnvironmentCSPPayload):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
return EnvironmentCSPResult(
id=f"{AZURE_MGMNT_PATH}{payload.management_group_name}"
)
def create_user(self, payload: UserCSPPayload): def create_user(self, payload: UserCSPPayload):
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException) self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
@ -487,3 +452,25 @@ class MockCloudProvider(CloudProviderInterface):
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

@ -359,6 +359,14 @@ class ApplicationCSPResult(ManagementGroupCSPResponse):
pass pass
class EnvironmentCSPPayload(ManagementGroupCSPPayload):
pass
class EnvironmentCSPResult(ManagementGroupCSPResponse):
pass
class KeyVaultCredentials(BaseModel): class KeyVaultCredentials(BaseModel):
root_sp_client_id: Optional[str] root_sp_client_id: Optional[str]
root_sp_key: Optional[str] root_sp_key: Optional[str]
@ -515,3 +523,34 @@ class UserRoleCSPPayload(BaseCSPPayload):
class UserRoleCSPResult(AliasModel): class UserRoleCSPResult(AliasModel):
id: str 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

@ -110,9 +110,11 @@ class EnvironmentRoles(object):
def disable(cls, environment_role_id): def disable(cls, environment_role_id):
environment_role = EnvironmentRoles.get_by_id(environment_role_id) environment_role = EnvironmentRoles.get_by_id(environment_role_id)
if environment_role.cloud_id and not environment_role.environment.is_pending: if environment_role.cloud_id and not environment_role.environment.cloud_id:
credentials = environment_role.environment.csp_credentials tenant_id = environment_role.environment.application.portfolio.csp_data.get(
app.csp.cloud.disable_user(credentials, environment_role.cloud_id) "tenant_id"
)
app.csp.cloud.disable_user(tenant_id, environment_role.csp_user_id)
environment_role.status = EnvironmentRole.Status.DISABLED environment_role.status = EnvironmentRole.Status.DISABLED
db.session.add(environment_role) db.session.add(environment_role)

View File

@ -124,18 +124,9 @@ class Environments(object):
Any environment with an active CLIN that doesn't yet have a `cloud_id`. Any environment with an active CLIN that doesn't yet have a `cloud_id`.
""" """
results = ( results = (
cls.base_provision_query(now).filter(Environment.cloud_id == None).all() cls.base_provision_query(now)
.filter(Application.cloud_id != None)
.filter(Environment.cloud_id.is_(None))
.all()
) )
return [id_ for id_, in results] return [id_ for id_, in results]
@classmethod
def get_environments_pending_atat_user_creation(cls, now) -> List[UUID]:
"""
Any environment with an active CLIN that has a cloud_id but no `root_user_info`.
"""
results = (
cls.base_provision_query(now)
.filter(Environment.cloud_id != None)
.filter(Environment.root_user_info == None)
).all()
return [id_ for id_, in results]

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

@ -1,24 +1,23 @@
from flask import current_app as app
import pendulum import pendulum
from flask import current_app as app
from atst.database import db from atst.database import db
from atst.queue import celery
from atst.models import JobFailure
from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.csp.cloud import CloudProviderInterface
from atst.domain.applications import Applications
from atst.domain.environments import Environments
from atst.domain.environment_roles import EnvironmentRoles
from atst.domain.portfolios import Portfolios
from atst.domain.application_roles import ApplicationRoles from atst.domain.application_roles import ApplicationRoles
from atst.models.utils import claim_for_update, claim_many_for_update from atst.domain.applications import Applications
from atst.models import CSPRole from atst.domain.csp.cloud import CloudProviderInterface
from atst.utils.localization import translate from atst.domain.csp.cloud.exceptions import GeneralCSPException
from atst.domain.csp.cloud.models import ( from atst.domain.csp.cloud.models import (
ApplicationCSPPayload, ApplicationCSPPayload,
EnvironmentCSPPayload,
UserCSPPayload, UserCSPPayload,
UserRoleCSPPayload, UserRoleCSPPayload,
) )
from atst.domain.environments import Environments
from atst.domain.environment_roles import EnvironmentRoles
from atst.domain.portfolios import Portfolios
from atst.models import CSPRole, JobFailure
from atst.models.utils import claim_for_update, claim_many_for_update
from atst.queue import celery
class RecordFailure(celery.Task): class RecordFailure(celery.Task):
@ -115,34 +114,19 @@ def do_create_environment(csp: CloudProviderInterface, environment_id=None):
with claim_for_update(environment) as environment: with claim_for_update(environment) as environment:
if environment.cloud_id is not None: if environment.cloud_id is not None:
# TODO: Return value for this?
return return
user = environment.creator csp_details = environment.application.portfolio.csp_data
parent_id = environment.application.cloud_id
# we'll need to do some checking in this job for cases where it's retrying tenant_id = csp_details.get("tenant_id")
# when a failure occured after some successful steps payload = EnvironmentCSPPayload(
# (e.g. if environment.cloud_id is not None, then we can skip first step) tenant_id=tenant_id, display_name=environment.name, parent_id=parent_id
)
# credentials either from a given user or pulled from config? env_result = csp.create_environment(payload)
# if using global creds, do we need to log what user authorized action? environment.cloud_id = env_result.id
atat_root_creds = csp.root_creds()
# user is needed because baseline root account in the environment will
# be assigned to the requesting user, open question how to handle duplicate
# email addresses across new environments
csp_environment_id = csp.create_environment(atat_root_creds, user, environment)
environment.cloud_id = csp_environment_id
db.session.add(environment) db.session.add(environment)
db.session.commit() db.session.commit()
body = render_email(
"emails/application/environment_ready.txt", {"environment": environment}
)
app.mailer.send(
[environment.creator.email], translate("email.environment_ready"), body
)
def do_create_environment_role(csp: CloudProviderInterface, environment_role_id=None): def do_create_environment_role(csp: CloudProviderInterface, environment_role_id=None):
env_role = EnvironmentRoles.get_by_id(environment_role_id) env_role = EnvironmentRoles.get_by_id(environment_role_id)
@ -164,7 +148,6 @@ def do_create_environment_role(csp: CloudProviderInterface, environment_role_id=
elif env_role.role == CSPRole.CONTRIBUTOR: elif env_role.role == CSPRole.CONTRIBUTOR:
role = UserRoleCSPPayload.Roles.contributor role = UserRoleCSPPayload.Roles.contributor
# resolve role
payload = UserRoleCSPPayload( payload = UserRoleCSPPayload(
tenant_id=csp_details.get("tenant_id"), tenant_id=csp_details.get("tenant_id"),
management_group_id=env.cloud_id, management_group_id=env.cloud_id,
@ -179,20 +162,6 @@ def do_create_environment_role(csp: CloudProviderInterface, environment_role_id=
# TODO: should send notification email to the user, maybe with their portal login name # TODO: should send notification email to the user, maybe with their portal login name
def do_create_atat_admin_user(csp: CloudProviderInterface, environment_id=None):
environment = Environments.get(environment_id)
with claim_for_update(environment) as environment:
atat_root_creds = csp.root_creds()
atat_remote_root_user = csp.create_atat_admin_user(
atat_root_creds, environment.cloud_id
)
environment.root_user_info = atat_remote_root_user
db.session.add(environment)
db.session.commit()
def render_email(template_path, context): 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)
@ -242,19 +211,12 @@ 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)
@celery.task(bind=True, base=RecordFailure)
def create_atat_admin_user(self, environment_id=None):
do_work(
do_create_atat_admin_user, self, app.csp.cloud, environment_id=environment_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)
@ -282,11 +244,3 @@ def dispatch_create_environment(self):
pendulum.now() pendulum.now()
): ):
create_environment.delay(environment_id=environment_id) create_environment.delay(environment_id=environment_id)
@celery.task(bind=True)
def dispatch_create_atat_admin_user(self):
for environment_id in Environments.get_environments_pending_atat_user_creation(
pendulum.now()
):
create_atat_admin_user.delay(environment_id=environment_id)

View File

@ -1,11 +1,9 @@
from sqlalchemy import Column, ForeignKey, String, 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 enum import Enum
from atst.models.base import Base
import atst.models.mixins as mixins import atst.models.mixins as mixins
import atst.models.types as types import atst.models.types as types
from atst.models.base import Base
class Environment( class Environment(
@ -30,7 +28,6 @@ class Environment(
creator = relationship("User") creator = relationship("User")
cloud_id = Column(String) cloud_id = Column(String)
root_user_info = Column(JSONB(none_as_null=True))
roles = relationship( roles = relationship(
"EnvironmentRole", "EnvironmentRole",
@ -44,10 +41,6 @@ class Environment(
), ),
) )
class ProvisioningStatus(Enum):
PENDING = "pending"
COMPLETED = "completed"
@property @property
def users(self): def users(self):
return {r.application_role.user for r in self.roles} return {r.application_role.user for r in self.roles}
@ -68,17 +61,6 @@ class Environment(
def portfolio_id(self): def portfolio_id(self):
return self.application.portfolio_id return self.application.portfolio_id
@property
def provisioning_status(self) -> ProvisioningStatus:
if self.cloud_id is None or self.root_user_info is None:
return self.ProvisioningStatus.PENDING
else:
return self.ProvisioningStatus.COMPLETED
@property
def is_pending(self):
return self.provisioning_status == self.ProvisioningStatus.PENDING
def __repr__(self): def __repr__(self):
return "<Environment(name='{}', num_users='{}', application='{}', portfolio='{}', id='{}')>".format( return "<Environment(name='{}', num_users='{}', application='{}', portfolio='{}', id='{}')>".format(
self.name, self.name,
@ -91,11 +73,3 @@ class Environment(
@property @property
def history(self): def history(self):
return self.get_changes() return self.get_changes()
@property
def csp_credentials(self):
return (
self.root_user_info.get("credentials")
if self.root_user_info is not None
else None
)

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

@ -19,10 +19,6 @@ def update_celery(celery, app):
"task": "atst.jobs.dispatch_create_environment", "task": "atst.jobs.dispatch_create_environment",
"schedule": 60, "schedule": 60,
}, },
"beat-dispatch_create_atat_admin_user": {
"task": "atst.jobs.dispatch_create_atat_admin_user",
"schedule": 60,
},
"beat-dispatch_create_user": { "beat-dispatch_create_user": {
"task": "atst.jobs.dispatch_create_user", "task": "atst.jobs.dispatch_create_user",
"schedule": 60, "schedule": 60,

View File

@ -39,7 +39,6 @@ def get_environments_obj_for_app(application):
{ {
"id": env.id, "id": env.id,
"name": env.name, "name": env.name,
"pending": env.is_pending,
"edit_form": EditEnvironmentForm(obj=env), "edit_form": EditEnvironmentForm(obj=env),
"member_count": len(env.roles), "member_count": len(env.roles),
"members": sorted( "members": sorted(
@ -467,9 +466,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

@ -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

@ -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

@ -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

@ -2,6 +2,8 @@ import json
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from uuid import uuid4 from uuid import uuid4
import pendulum
import pydantic
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
@ -20,10 +22,16 @@ from atst.domain.csp.cloud.models import (
BillingProfileTenantAccessCSPResult, BillingProfileTenantAccessCSPResult,
BillingProfileVerificationCSPPayload, BillingProfileVerificationCSPPayload,
BillingProfileVerificationCSPResult, BillingProfileVerificationCSPResult,
CostManagementQueryCSPResult,
EnvironmentCSPPayload,
EnvironmentCSPResult,
PrincipalAdminRoleCSPPayload,
PrincipalAdminRoleCSPResult,
ProductPurchaseCSPPayload, ProductPurchaseCSPPayload,
ProductPurchaseCSPResult, ProductPurchaseCSPResult,
ProductPurchaseVerificationCSPPayload, ProductPurchaseVerificationCSPPayload,
ProductPurchaseVerificationCSPResult, ProductPurchaseVerificationCSPResult,
ReportingCSPPayload,
SubscriptionCreationCSPPayload, SubscriptionCreationCSPPayload,
SubscriptionCreationCSPResult, SubscriptionCreationCSPResult,
SubscriptionVerificationCSPPayload, SubscriptionVerificationCSPPayload,
@ -44,7 +52,10 @@ from atst.domain.csp.cloud.models import (
TenantPrincipalCSPResult, TenantPrincipalCSPResult,
TenantPrincipalOwnershipCSPPayload, TenantPrincipalOwnershipCSPPayload,
TenantPrincipalOwnershipCSPResult, TenantPrincipalOwnershipCSPResult,
UserCSPPayload,
UserRoleCSPPayload,
) )
from atst.domain.csp.cloud.exceptions import UserProvisioningException
BILLING_ACCOUNT_NAME = "52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31" BILLING_ACCOUNT_NAME = "52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31"
@ -57,12 +68,14 @@ def mock_management_group_create(mock_azure, spec_dict):
def test_create_environment_succeeds(mock_azure: AzureCloudProvider): def test_create_environment_succeeds(mock_azure: AzureCloudProvider):
environment = EnvironmentFactory.create() environment = EnvironmentFactory.create()
mock_management_group_create(mock_azure, {"id": "Test Id"}) mock_management_group_create(mock_azure, {"id": "Test Id"})
result = mock_azure.create_environment( mock_azure = mock_get_secret(mock_azure)
AUTH_CREDENTIALS, environment.creator, environment
payload = EnvironmentCSPPayload(
tenant_id="1234", display_name=environment.name, parent_id=str(uuid4())
) )
result = mock_azure.create_environment(payload)
assert result.id == "Test Id" assert result.id == "Test Id"
@ -97,20 +110,6 @@ def test_create_application_succeeds(mock_azure: AzureCloudProvider):
assert result.id == "Test Id" assert result.id == "Test Id"
def test_create_atat_admin_user_succeeds(mock_azure: AzureCloudProvider):
environment_id = str(uuid4())
csp_user_id = str(uuid4)
mock_azure.sdk.graphrbac.GraphRbacManagementClient.return_value.service_principals.create.return_value.object_id = (
csp_user_id
)
result = mock_azure.create_atat_admin_user(AUTH_CREDENTIALS, environment_id)
assert result.get("csp_user_id") == csp_user_id
def test_create_policy_definition_succeeds(mock_azure: AzureCloudProvider): def test_create_policy_definition_succeeds(mock_azure: AzureCloudProvider):
subscription_id = str(uuid4()) subscription_id = str(uuid4())
management_group_id = str(uuid4()) management_group_id = str(uuid4())
@ -162,6 +161,27 @@ def test_create_tenant(mock_azure: AzureCloudProvider):
assert body.tenant_id == "60ff9d34-82bf-4f21-b565-308ef0533435" assert body.tenant_id == "60ff9d34-82bf-4f21-b565-308ef0533435"
def test_create_tenant_fails(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.json.return_value = {"error": "body"}
mock_result.status_code = 403
mock_azure.sdk.requests.post.return_value = mock_result
payload = TenantCSPPayload(
**dict(
user_id="admin",
password="JediJan13$coot", # pragma: allowlist secret
domain_name="jediccpospawnedtenant2",
first_name="Tedry",
last_name="Tenet",
country_code="US",
password_recovery_email_address="thomas@promptworks.com",
)
)
mock_azure = mock_get_secret(mock_azure)
result = mock_azure.create_tenant(payload)
assert result.get("status") == "error"
def test_create_billing_profile_creation(mock_azure: AzureCloudProvider): def test_create_billing_profile_creation(mock_azure: AzureCloudProvider):
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = { mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
"accessToken": "TOKEN" "accessToken": "TOKEN"
@ -573,10 +593,10 @@ def test_create_tenant_principal_credential(mock_azure: AzureCloudProvider):
def test_create_admin_role_definition(mock_azure: AzureCloudProvider): def test_create_admin_role_definition(mock_azure: AzureCloudProvider):
with patch.object( with patch.object(
AzureCloudProvider, AzureCloudProvider,
"_get_elevated_management_token", "_get_tenant_admin_token",
wraps=mock_azure._get_elevated_management_token, wraps=mock_azure._get_tenant_admin_token,
) as get_elevated_management_token: ) as get_tenant_admin_token:
get_elevated_management_token.return_value = "my fake token" get_tenant_admin_token.return_value = "my fake token"
mock_result = Mock() mock_result = Mock()
mock_result.ok = True mock_result.ok = True
@ -657,6 +677,35 @@ def test_create_tenant_principal_ownership(mock_azure: AzureCloudProvider):
assert result.principal_owner_assignment_id == "id" assert result.principal_owner_assignment_id == "id"
def test_create_principal_admin_role(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_tenant_admin_token",
wraps=mock_azure._get_tenant_admin_token,
) as get_tenant_admin_token:
get_tenant_admin_token.return_value = "my fake token"
mock_result = Mock()
mock_result.ok = True
mock_result.json.return_value = {"id": "id"}
mock_azure.sdk.requests.post.return_value = mock_result
payload = PrincipalAdminRoleCSPPayload(
**{
"tenant_id": uuid4().hex,
"principal_id": "6d2d2d6c-a6d6-41e1-8bb1-73d11475f8f4",
"admin_role_def_id": uuid4().hex,
}
)
result: PrincipalAdminRoleCSPResult = mock_azure.create_principal_admin_role(
payload
)
assert result.principal_assignment_id == "id"
def test_create_subscription_creation(mock_azure: AzureCloudProvider): def test_create_subscription_creation(mock_azure: AzureCloudProvider):
with patch.object( with patch.object(
AzureCloudProvider, AzureCloudProvider,
@ -718,3 +767,224 @@ 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,
)
)
def test_get_secret(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_client_secret_credential_obj",
wraps=mock_azure._get_client_secret_credential_obj,
) as _get_client_secret_credential_obj:
_get_client_secret_credential_obj.return_value = {}
mock_azure.sdk.secrets.SecretClient.return_value.get_secret.return_value.value = (
"my secret"
)
assert mock_azure.get_secret("secret key") == "my secret"
def test_set_secret(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_client_secret_credential_obj",
wraps=mock_azure._get_client_secret_credential_obj,
) as _get_client_secret_credential_obj:
_get_client_secret_credential_obj.return_value = {}
mock_azure.sdk.secrets.SecretClient.return_value.set_secret.return_value = (
"my secret"
)
assert mock_azure.set_secret("secret key", "secret_value") == "my secret"
def test_create_active_directory_user(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.ok = True
mock_result.json.return_value = {"id": "id"}
mock_azure.sdk.requests.post.return_value = mock_result
payload = UserCSPPayload(
tenant_id=uuid4().hex,
display_name="Test Testerson",
tenant_host_name="testtenant",
email="test@testerson.test",
password="asdfghjkl", # pragma: allowlist secret
)
result = mock_azure._create_active_directory_user("token", payload)
assert result.id == "id"
def test_update_active_directory_user_email(mock_azure: AzureCloudProvider):
mock_result = Mock()
mock_result.ok = True
mock_azure.sdk.requests.patch.return_value = mock_result
payload = UserCSPPayload(
tenant_id=uuid4().hex,
display_name="Test Testerson",
tenant_host_name="testtenant",
email="test@testerson.test",
password="asdfghjkl", # pragma: allowlist secret
)
result = mock_azure._update_active_directory_user_email(
"token", uuid4().hex, payload
)
assert result
def test_create_user(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_tenant_principal_token",
wraps=mock_azure._get_tenant_principal_token,
) as _get_tenant_principal_token:
_get_tenant_principal_token.return_value = "token"
mock_result_create = Mock()
mock_result_create.ok = True
mock_result_create.json.return_value = {"id": "id"}
mock_azure.sdk.requests.post.return_value = mock_result_create
mock_result_update = Mock()
mock_result_update.ok = True
mock_azure.sdk.requests.patch.return_value = mock_result_update
payload = UserCSPPayload(
tenant_id=uuid4().hex,
display_name="Test Testerson",
tenant_host_name="testtenant",
email="test@testerson.test",
password="asdfghjkl", # pragma: allowlist secret
)
result = mock_azure.create_user(payload)
assert result.id == "id"
def test_create_user_role(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_tenant_principal_token",
wraps=mock_azure._get_tenant_principal_token,
) as _get_tenant_principal_token:
_get_tenant_principal_token.return_value = "token"
mock_result_create = Mock()
mock_result_create.ok = True
mock_result_create.json.return_value = {"id": "id"}
mock_azure.sdk.requests.put.return_value = mock_result_create
payload = UserRoleCSPPayload(
tenant_id=uuid4().hex,
user_object_id=str(uuid4()),
management_group_id=str(uuid4()),
role="owner",
)
result = mock_azure.create_user_role(payload)
assert result.id == "id"
def test_create_user_role_failure(mock_azure: AzureCloudProvider):
with patch.object(
AzureCloudProvider,
"_get_tenant_principal_token",
wraps=mock_azure._get_tenant_principal_token,
) as _get_tenant_principal_token:
_get_tenant_principal_token.return_value = "token"
mock_result_create = Mock()
mock_result_create.ok = False
mock_azure.sdk.requests.put.return_value = mock_result_create
payload = UserRoleCSPPayload(
tenant_id=uuid4().hex,
user_object_id=str(uuid4()),
management_group_id=str(uuid4()),
role="owner",
)
with pytest.raises(UserProvisioningException):
mock_azure.create_user_role(payload)

View File

@ -1,6 +1,7 @@
import pytest import pytest
from atst.domain.csp import MockCloudProvider from atst.domain.csp import MockCloudProvider
from atst.domain.csp.cloud.models import EnvironmentCSPPayload, EnvironmentCSPResult
from tests.factories import EnvironmentFactory, EnvironmentRoleFactory, UserFactory from tests.factories import EnvironmentFactory, EnvironmentRoleFactory, UserFactory
@ -14,20 +15,17 @@ def mock_csp():
def test_create_environment(mock_csp: MockCloudProvider): def test_create_environment(mock_csp: MockCloudProvider):
environment = EnvironmentFactory.create() environment = EnvironmentFactory.create()
user = UserFactory.create() environment.application.cloud_id = "parent_id"
environment_id = mock_csp.create_environment(CREDENTIALS, user, environment) environment.application.portfolio.csp_data = {"tenant_id": "fake"}
assert isinstance(environment_id, str) payload = EnvironmentCSPPayload(
**dict(
tenant_id=environment.application.portfolio.csp_data.get("tenant_id"),
def test_create_admin_user(mock_csp: MockCloudProvider): display_name=environment.name,
admin_user = mock_csp.create_atat_admin_user(CREDENTIALS, "env_id") parent_id=environment.application.cloud_id,
assert isinstance(admin_user["id"], str) )
assert isinstance(admin_user["credentials"], dict) )
result = mock_csp.create_environment(payload)
assert isinstance(result, EnvironmentCSPResult)
def test_create_environment_baseline(mock_csp: MockCloudProvider):
baseline = mock_csp.create_atat_admin_user(CREDENTIALS, "env_id")
assert isinstance(baseline, dict)
def test_create_or_update_user(mock_csp: MockCloudProvider): def test_create_or_update_user(mock_csp: MockCloudProvider):

View File

@ -93,27 +93,25 @@ def test_disable_completed(application_role, environment):
def test_disable_checks_env_provisioning_status(session): def test_disable_checks_env_provisioning_status(session):
environment = EnvironmentFactory.create() environment = EnvironmentFactory.create()
assert environment.is_pending assert not environment.cloud_id
env_role1 = EnvironmentRoleFactory.create(environment=environment) env_role1 = EnvironmentRoleFactory.create(environment=environment)
env_role1 = EnvironmentRoles.disable(env_role1.id) env_role1 = EnvironmentRoles.disable(env_role1.id)
assert env_role1.disabled assert env_role1.disabled
environment.cloud_id = "cloud-id" environment.cloud_id = "cloud-id"
environment.root_user_info = {"credentials": "credentials"}
session.add(environment) session.add(environment)
session.commit() session.commit()
session.refresh(environment) session.refresh(environment)
assert not environment.is_pending assert environment.cloud_id
env_role2 = EnvironmentRoleFactory.create(environment=environment) env_role2 = EnvironmentRoleFactory.create(environment=environment)
env_role2 = EnvironmentRoles.disable(env_role2.id) env_role2 = EnvironmentRoles.disable(env_role2.id)
assert env_role2.disabled assert env_role2.disabled
def test_disable_checks_env_role_provisioning_status(): def test_disable_checks_env_role_provisioning_status():
environment = EnvironmentFactory.create( environment = EnvironmentFactory.create(cloud_id="cloud-id")
cloud_id="cloud-id", root_user_info={"credentials": "credentials"} environment.application.portfolio.csp_data = {"tenant_id": uuid4().hex}
)
env_role1 = EnvironmentRoleFactory.create(environment=environment) env_role1 = EnvironmentRoleFactory.create(environment=environment)
assert not env_role1.cloud_id assert not env_role1.cloud_id
env_role1 = EnvironmentRoles.disable(env_role1.id) env_role1 = EnvironmentRoles.disable(env_role1.id)

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)])
@ -168,37 +134,16 @@ class TestGetEnvironmentsPendingCreate(EnvQueryTest):
self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)]) self.create_portfolio_with_clins([(self.TOMORROW, self.TOMORROW)])
assert len(Environments.get_environments_pending_creation(self.NOW)) == 0 assert len(Environments.get_environments_pending_creation(self.NOW)) == 0
def test_with_already_provisioned_app(self, session):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], app_data={"cloud_id": uuid4().hex}
)
assert len(Environments.get_environments_pending_creation(self.NOW)) == 1
def test_with_already_provisioned_env(self, session): def test_with_already_provisioned_env(self, session):
self.create_portfolio_with_clins( self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)], env_data={"cloud_id": uuid4().hex} [(self.YESTERDAY, self.TOMORROW)],
env_data={"cloud_id": uuid4().hex},
app_data={"cloud_id": uuid4().hex},
) )
assert len(Environments.get_environments_pending_creation(self.NOW)) == 0 assert len(Environments.get_environments_pending_creation(self.NOW)) == 0
class TestGetEnvironmentsPendingAtatUserCreation(EnvQueryTest):
def test_with_provisioned_environment(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)],
{"cloud_id": uuid4().hex, "root_user_info": {}},
)
assert (
len(Environments.get_environments_pending_atat_user_creation(self.NOW)) == 0
)
def test_with_unprovisioned_environment(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.TOMORROW)],
{"cloud_id": uuid4().hex, "root_user_info": None},
)
assert (
len(Environments.get_environments_pending_atat_user_creation(self.NOW)) == 1
)
def test_with_unprovisioned_expired_clins_environment(self):
self.create_portfolio_with_clins(
[(self.YESTERDAY, self.YESTERDAY)],
{"cloud_id": uuid4().hex, "root_user_info": None},
)
assert (
len(Environments.get_environments_pending_atat_user_creation(self.NOW)) == 0
)

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

View File

@ -51,28 +51,6 @@ def test_audit_event_for_environment_deletion(session):
assert after assert after
@pytest.mark.parametrize(
"env_data,expected_status",
[
[
{"cloud_id": None, "root_user_info": None},
Environment.ProvisioningStatus.PENDING,
],
[
{"cloud_id": 1, "root_user_info": None},
Environment.ProvisioningStatus.PENDING,
],
[
{"cloud_id": 1, "root_user_info": {}},
Environment.ProvisioningStatus.COMPLETED,
],
],
)
def test_environment_provisioning_status(env_data, expected_status):
environment = EnvironmentFactory.create(**env_data)
assert environment.provisioning_status == expected_status
def test_environment_roles_do_not_include_deleted(): def test_environment_roles_do_not_include_deleted():
member_list = [ member_list = [
{"role_name": CSPRole.ADMIN}, {"role_name": CSPRole.ADMIN},

View File

@ -14,7 +14,6 @@ from atst.jobs import (
dispatch_create_application, dispatch_create_application,
dispatch_create_user, dispatch_create_user,
dispatch_create_environment_role, dispatch_create_environment_role,
dispatch_create_atat_admin_user,
dispatch_provision_portfolio, dispatch_provision_portfolio,
create_environment, create_environment,
do_create_user, do_create_user,
@ -22,7 +21,6 @@ from atst.jobs import (
do_create_environment, do_create_environment,
do_create_environment_role, do_create_environment_role,
do_create_application, do_create_application,
do_create_atat_admin_user,
) )
from tests.factories import ( from tests.factories import (
EnvironmentFactory, EnvironmentFactory,
@ -97,6 +95,10 @@ tomorrow = now.add(days=1)
def test_create_environment_job(session, csp): def test_create_environment_job(session, csp):
environment = EnvironmentFactory.create() environment = EnvironmentFactory.create()
environment.application.cloud_id = "parentId"
environment.application.portfolio.csp_data = {"tenant_id": "fake"}
session.add(environment)
session.commit()
do_create_environment(csp, environment.id) do_create_environment(csp, environment.id)
session.refresh(environment) session.refresh(environment)
@ -152,19 +154,11 @@ def test_create_user_job(session, csp):
assert app_role.cloud_id assert app_role.cloud_id
def test_create_atat_admin_user(csp, session):
environment = EnvironmentFactory.create(cloud_id="something")
do_create_atat_admin_user(csp, environment.id)
session.refresh(environment)
assert environment.root_user_info
def test_dispatch_create_environment(session, monkeypatch): def test_dispatch_create_environment(session, monkeypatch):
# Given that I have a portfolio with an active CLIN and two environments, # Given that I have a portfolio with an active CLIN and two environments,
# one of which is deleted # one of which is deleted
portfolio = PortfolioFactory.create( portfolio = PortfolioFactory.create(
applications=[{"environments": [{}, {}]}], applications=[{"environments": [{}, {}], "cloud_id": uuid4().hex}],
task_orders=[ task_orders=[
{ {
"create_clins": [ "create_clins": [
@ -230,36 +224,9 @@ def test_dispatch_create_user(monkeypatch):
mock.delay.assert_called_once_with(application_role_ids=[app_role.id]) mock.delay.assert_called_once_with(application_role_ids=[app_role.id])
def test_dispatch_create_atat_admin_user(session, monkeypatch):
portfolio = PortfolioFactory.create(
applications=[
{"environments": [{"cloud_id": uuid4().hex, "root_user_info": None}]}
],
task_orders=[
{
"create_clins": [
{
"start_date": pendulum.now().subtract(days=1),
"end_date": pendulum.now().add(days=1),
}
]
}
],
)
mock = Mock()
monkeypatch.setattr("atst.jobs.create_atat_admin_user", mock)
environment = portfolio.applications[0].environments[0]
dispatch_create_atat_admin_user.run()
mock.delay.assert_called_once_with(environment_id=environment.id)
def test_create_environment_no_dupes(session, celery_app, celery_worker): def test_create_environment_no_dupes(session, celery_app, celery_worker):
portfolio = PortfolioFactory.create( portfolio = PortfolioFactory.create(
applications=[ applications=[{"environments": [{"cloud_id": uuid4().hex}]}],
{"environments": [{"cloud_id": uuid4().hex, "root_user_info": {}}]}
],
task_orders=[ task_orders=[
{ {
"create_clins": [ "create_clins": [
@ -289,9 +256,19 @@ def test_create_environment_no_dupes(session, celery_app, celery_worker):
assert environment.claimed_until == None assert environment.claimed_until == None
def test_dispatch_provision_portfolio( def test_dispatch_provision_portfolio(csp, monkeypatch):
csp, session, portfolio, celery_app, celery_worker, monkeypatch portfolio = PortfolioFactory.create(
): task_orders=[
{
"create_clins": [
{
"start_date": pendulum.now().subtract(days=1),
"end_date": pendulum.now().add(days=1),
}
]
}
],
)
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,46 @@ 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,
app_data=None,
state_machine_status=None,
):
env_data = env_data or {}
app_data = app_data or {}
return factories.PortfolioFactory.create(
state=state_machine_status,
applications=[
{
"name": "Mos Eisley",
"description": "Where Han shot first",
"environments": [{"name": "thebar", **env_data}],
**app_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: