Merge branch 'staging' into gi-wo-20200120
This commit is contained in:
commit
5eb811d564
4
Pipfile
4
Pipfile
@ -33,6 +33,10 @@ azure-mgmt-authorization = "*"
|
||||
azure-mgmt-managementgroups = "*"
|
||||
azure-mgmt-resource = "*"
|
||||
transitions = "*"
|
||||
azure-mgmt-consumption = "*"
|
||||
adal = "*"
|
||||
azure-identity = "*"
|
||||
azure-keyvault = "*"
|
||||
|
||||
[dev-packages]
|
||||
bandit = "*"
|
||||
|
134
Pipfile.lock
generated
134
Pipfile.lock
generated
@ -1,7 +1,7 @@
|
||||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "63b8f9d203f306a6f0ff20514b024909aa7e64917e1befcc9ea79931b5b4bd34"
|
||||
"sha256": "4dbb023bcb860eb6dc56e1c201c91f272e1e67ad03e5e5eeb3a7a7fdff350eed"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
@ -21,14 +21,15 @@
|
||||
"sha256:5a7f1e037c6290c6d7609cab33a9e5e988c2fbec5c51d1c4c649ee3faff37eaf",
|
||||
"sha256:fd17e5661f60634ddf96a569b95d34ccb8a98de60593d729c28bdcfe360eaad1"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.2.2"
|
||||
},
|
||||
"alembic": {
|
||||
"hashes": [
|
||||
"sha256:3b0cb1948833e062f4048992fbc97ecfaaaac24aaa0d83a1202a99fb58af8c6d"
|
||||
"sha256:d412982920653db6e5a44bfd13b1d0db5685cbaaccaf226195749c706e1e862a"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.2"
|
||||
"version": "==1.3.3"
|
||||
},
|
||||
"amqp": {
|
||||
"hashes": [
|
||||
@ -44,6 +45,13 @@
|
||||
],
|
||||
"version": "==1.1.24"
|
||||
},
|
||||
"azure-core": {
|
||||
"hashes": [
|
||||
"sha256:b8ccbd901d085048e4e3e72627b066923c5bd3780e4c43cf9cf9948aee9bdf9e",
|
||||
"sha256:e2cd99f0c0aef12c168d498cb5bc47a3a45c8ab08112183e3ec97e4dcb33ceb9"
|
||||
],
|
||||
"version": "==1.2.1"
|
||||
},
|
||||
"azure-graphrbac": {
|
||||
"hashes": [
|
||||
"sha256:53e98ae2ca7c19b349e9e9bb1b6a824aeae8dcfcbe17190d20fe69c0f185b2e2",
|
||||
@ -52,6 +60,36 @@
|
||||
"index": "pypi",
|
||||
"version": "==0.61.1"
|
||||
},
|
||||
"azure-identity": {
|
||||
"hashes": [
|
||||
"sha256:4ce65058461c277991763ed3f121efc6b9eb9c2edefb62c414dfa85c814690d3",
|
||||
"sha256:b32acd1cdb6202bfe10d9a0858dc463d8960295da70ae18097eb3b85ab12cb91"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.2.0"
|
||||
},
|
||||
"azure-keyvault": {
|
||||
"hashes": [
|
||||
"sha256:76f75cb83929f312a08616d426ad6f597f1beae180131cf445876fb88f2c8ef1",
|
||||
"sha256:e85f5bd6cb4f10b3248b99bbf02e3acc6371d366846897027d4153f18025a2d7"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"azure-keyvault-keys": {
|
||||
"hashes": [
|
||||
"sha256:2983fa42e20a0e6bf6b87976716129c108e613e0292d34c5b0f0c8dc1d488e89",
|
||||
"sha256:38c27322637a2c52620a8b96da1942ad6a8d22d09b5a01f6fa257f7a51e52ed0"
|
||||
],
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"azure-keyvault-secrets": {
|
||||
"hashes": [
|
||||
"sha256:2eae9264a8f6f59277e1a9bfdbc8b0a15969ee5a80d8efe403d7744805b4a481",
|
||||
"sha256:97a602406a833e8f117c540c66059c818f4321a35168dd17365fab1e4527d718"
|
||||
],
|
||||
"version": "==4.0.0"
|
||||
},
|
||||
"azure-mgmt-authorization": {
|
||||
"hashes": [
|
||||
"sha256:31e875a34ac2c5d6fefe77b4a8079a8b2bdbe9edb957e47e8b44222fb212d6a7",
|
||||
@ -60,6 +98,14 @@
|
||||
"index": "pypi",
|
||||
"version": "==0.60.0"
|
||||
},
|
||||
"azure-mgmt-consumption": {
|
||||
"hashes": [
|
||||
"sha256:035d4b74ca7c47e2683bea17105fd9014c27060336fb6255324ac86b27f70f5b",
|
||||
"sha256:af319ad6e3ec162a7578563f149e3cdd7d833a62ec80761cfd93caf79467610b"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==3.0.0"
|
||||
},
|
||||
"azure-mgmt-managementgroups": {
|
||||
"hashes": [
|
||||
"sha256:3d5237947458dc94b4a392141174b1c1258d26611241ee104e9006d1d798f682",
|
||||
@ -208,6 +254,14 @@
|
||||
],
|
||||
"version": "==2.8"
|
||||
},
|
||||
"dataclasses": {
|
||||
"hashes": [
|
||||
"sha256:3459118f7ede7c8bea0fe795bff7c6c2ce287d01dd226202f7c9ebc0610a7836",
|
||||
"sha256:494a6dcae3b8bcf80848eea2ef64c0cc5cd307ffc263e17cdf42f3e5420808e6"
|
||||
],
|
||||
"markers": "python_version < '3.7'",
|
||||
"version": "==0.7"
|
||||
},
|
||||
"flask": {
|
||||
"hashes": [
|
||||
"sha256:13f9f196f330c7c2c5d7a5cf91af894110ca0215ac051b5844701f2bfd934d52",
|
||||
@ -301,9 +355,9 @@
|
||||
},
|
||||
"mako": {
|
||||
"hashes": [
|
||||
"sha256:a36919599a9b7dc5d86a7a8988f23a9a3a3d083070023bab23d64f7f1d1e0a4b"
|
||||
"sha256:2984a6733e1d472796ceef37ad48c26f4a984bb18119bb2dbc37a44d8f6e75a4"
|
||||
],
|
||||
"version": "==1.1.0"
|
||||
"version": "==1.1.1"
|
||||
},
|
||||
"markupsafe": {
|
||||
"hashes": [
|
||||
@ -345,6 +399,20 @@
|
||||
],
|
||||
"version": "==8.1.0"
|
||||
},
|
||||
"msal": {
|
||||
"hashes": [
|
||||
"sha256:c944b833bf686dfbc973e9affdef94b77e616cb52ab397e76cde82e26b8a3373",
|
||||
"sha256:ecbe3f5ac77facad16abf08eb9d8562af3bc7184be5d4d90c9ef4db5bde26340"
|
||||
],
|
||||
"version": "==1.0.0"
|
||||
},
|
||||
"msal-extensions": {
|
||||
"hashes": [
|
||||
"sha256:59e171a9a4baacdbf001c66915efeaef372fb424421f1a4397115a3ddd6205dc",
|
||||
"sha256:c5a32b8e1dce1c67733dcdf8aa8bebcff5ab123e779ef7bc14e416bd0da90037"
|
||||
],
|
||||
"version": "==0.1.3"
|
||||
},
|
||||
"msrest": {
|
||||
"hashes": [
|
||||
"sha256:56b8b5b4556fb2a92cac640df267d560889bdc9e2921187772d4691d97bc4e8d",
|
||||
@ -379,6 +447,13 @@
|
||||
"index": "pypi",
|
||||
"version": "==2.0.5"
|
||||
},
|
||||
"portalocker": {
|
||||
"hashes": [
|
||||
"sha256:6f57aabb25ba176462dc7c63b86c42ad6a9b5bd3d679a9d776d0536bfb803d54",
|
||||
"sha256:dac62e53e5670cb40d2ee4cdc785e6b829665932c3ee75307ad677cf5f7d2e9f"
|
||||
],
|
||||
"version": "==1.5.2"
|
||||
},
|
||||
"psycopg2-binary": {
|
||||
"hashes": [
|
||||
"sha256:040234f8a4a8dfd692662a8308d78f63f31a97e1c42d2480e5e6810c48966a29",
|
||||
@ -444,6 +519,9 @@
|
||||
"version": "==1.3"
|
||||
},
|
||||
"pyjwt": {
|
||||
"extras": [
|
||||
"crypto"
|
||||
],
|
||||
"hashes": [
|
||||
"sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e",
|
||||
"sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96"
|
||||
@ -529,17 +607,17 @@
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
|
||||
"sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
|
||||
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
|
||||
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
|
||||
],
|
||||
"version": "==1.13.0"
|
||||
"version": "==1.14.0"
|
||||
},
|
||||
"sqlalchemy": {
|
||||
"hashes": [
|
||||
"sha256:bfb8f464a5000b567ac1d350b9090cf081180ec1ab4aa87e7bca12dab25320ec"
|
||||
"sha256:64a7b71846db6423807e96820993fa12a03b89127d278290ca25c0b11ed7b4fb"
|
||||
],
|
||||
"index": "pypi",
|
||||
"version": "==1.3.12"
|
||||
"version": "==1.3.13"
|
||||
},
|
||||
"sqlalchemy-json": {
|
||||
"hashes": [
|
||||
@ -572,10 +650,10 @@
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293",
|
||||
"sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"
|
||||
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
|
||||
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
|
||||
],
|
||||
"version": "==1.25.7"
|
||||
"version": "==1.25.8"
|
||||
},
|
||||
"vine": {
|
||||
"hashes": [
|
||||
@ -609,10 +687,10 @@
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:8dda78f06bd1674bd8720df8a50bb47b6e1233c503a4eed8e7810686bde37656",
|
||||
"sha256:d38fbe01bbf7a3593a32bc35a9c4453c32bc42b98c377f9bff7e9f8da157786c"
|
||||
"sha256:b338014b9bc7102ca69e0fb96ed07215a8954d2989bc5d83658494ab2ba634af",
|
||||
"sha256:e013e7800f60ec4dde789ebf4e9f7a54236e4bbf5df2a1a4e20ce9e1d9609d67"
|
||||
],
|
||||
"version": "==1.0.0"
|
||||
"version": "==2.0.1"
|
||||
}
|
||||
},
|
||||
"develop": {
|
||||
@ -1022,11 +1100,11 @@
|
||||
},
|
||||
"pexpect": {
|
||||
"hashes": [
|
||||
"sha256:2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1",
|
||||
"sha256:9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb"
|
||||
"sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937",
|
||||
"sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"
|
||||
],
|
||||
"markers": "sys_platform != 'win32'",
|
||||
"version": "==4.7.0"
|
||||
"version": "==4.8.0"
|
||||
},
|
||||
"pickleshare": {
|
||||
"hashes": [
|
||||
@ -1201,10 +1279,10 @@
|
||||
},
|
||||
"six": {
|
||||
"hashes": [
|
||||
"sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd",
|
||||
"sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"
|
||||
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
|
||||
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
|
||||
],
|
||||
"version": "==1.13.0"
|
||||
"version": "==1.14.0"
|
||||
},
|
||||
"smmap2": {
|
||||
"hashes": [
|
||||
@ -1285,10 +1363,10 @@
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
"sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293",
|
||||
"sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"
|
||||
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
|
||||
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
|
||||
],
|
||||
"version": "==1.25.7"
|
||||
"version": "==1.25.8"
|
||||
},
|
||||
"watchdog": {
|
||||
"hashes": [
|
||||
@ -1319,10 +1397,10 @@
|
||||
},
|
||||
"zipp": {
|
||||
"hashes": [
|
||||
"sha256:8dda78f06bd1674bd8720df8a50bb47b6e1233c503a4eed8e7810686bde37656",
|
||||
"sha256:d38fbe01bbf7a3593a32bc35a9c4453c32bc42b98c377f9bff7e9f8da157786c"
|
||||
"sha256:b338014b9bc7102ca69e0fb96ed07215a8954d2989bc5d83658494ab2ba634af",
|
||||
"sha256:e013e7800f60ec4dde789ebf4e9f7a54236e4bbf5df2a1a4e20ce9e1d9609d67"
|
||||
],
|
||||
"version": "==1.0.0"
|
||||
"version": "==2.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,11 +29,13 @@ parent_dir = Path(__file__).parent.parent
|
||||
sys.path.append(parent_dir)
|
||||
|
||||
from atst.app import make_config
|
||||
|
||||
app_config = make_config()
|
||||
config.set_main_option('sqlalchemy.url', app_config['DATABASE_URI'])
|
||||
config.set_main_option("sqlalchemy.url", app_config["DATABASE_URI"])
|
||||
|
||||
from atst.database import db
|
||||
from atst.models import *
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
@ -51,7 +53,8 @@ def run_migrations_offline():
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
@ -66,18 +69,19 @@ def run_migrations_online():
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section),
|
||||
prefix='sqlalchemy.',
|
||||
poolclass=pool.NullPool)
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata
|
||||
connection=connection, target_metadata=target_metadata, compare_type=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
|
132
alembic/versions/26319c44a8d5_state_machine_states_extended.py
Normal file
132
alembic/versions/26319c44a8d5_state_machine_states_extended.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""state machine states extended
|
||||
|
||||
Revision ID: 26319c44a8d5
|
||||
Revises: 59973fa17ded
|
||||
Create Date: 2020-01-22 15:54:03.186751
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "26319c44a8d5" # pragma: allowlist secret
|
||||
down_revision = "59973fa17ded" # pragma: allowlist secret
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"portfolio_state_machines",
|
||||
"state",
|
||||
existing_type=sa.Enum(
|
||||
"UNSTARTED",
|
||||
"STARTING",
|
||||
"STARTED",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"TENANT_CREATED",
|
||||
"TENANT_IN_PROGRESS",
|
||||
"TENANT_FAILED",
|
||||
"BILLING_PROFILE_CREATED",
|
||||
"BILLING_PROFILE_IN_PROGRESS",
|
||||
"BILLING_PROFILE_FAILED",
|
||||
"ADMIN_SUBSCRIPTION_CREATED",
|
||||
"ADMIN_SUBSCRIPTION_IN_PROGRESS",
|
||||
"ADMIN_SUBSCRIPTION_FAILED",
|
||||
name="fsmstates",
|
||||
native_enum=False,
|
||||
),
|
||||
type_=sa.Enum(
|
||||
"UNSTARTED",
|
||||
"STARTING",
|
||||
"STARTED",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"TENANT_CREATED",
|
||||
"TENANT_IN_PROGRESS",
|
||||
"TENANT_FAILED",
|
||||
"BILLING_PROFILE_CREATION_CREATED",
|
||||
"BILLING_PROFILE_CREATION_IN_PROGRESS",
|
||||
"BILLING_PROFILE_CREATION_FAILED",
|
||||
"BILLING_PROFILE_VERIFICATION_CREATED",
|
||||
"BILLING_PROFILE_VERIFICATION_IN_PROGRESS",
|
||||
"BILLING_PROFILE_VERIFICATION_FAILED",
|
||||
"BILLING_PROFILE_TENANT_ACCESS_CREATED",
|
||||
"BILLING_PROFILE_TENANT_ACCESS_IN_PROGRESS",
|
||||
"BILLING_PROFILE_TENANT_ACCESS_FAILED",
|
||||
"TASK_ORDER_BILLING_CREATION_CREATED",
|
||||
"TASK_ORDER_BILLING_CREATION_IN_PROGRESS",
|
||||
"TASK_ORDER_BILLING_CREATION_FAILED",
|
||||
"TASK_ORDER_BILLING_VERIFICATION_CREATED",
|
||||
"TASK_ORDER_BILLING_VERIFICATION_IN_PROGRESS",
|
||||
"TASK_ORDER_BILLING_VERIFICATION_FAILED",
|
||||
"BILLING_INSTRUCTION_CREATED",
|
||||
"BILLING_INSTRUCTION_IN_PROGRESS",
|
||||
"BILLING_INSTRUCTION_FAILED",
|
||||
name="fsmstates",
|
||||
native_enum=False,
|
||||
create_constraint=False,
|
||||
),
|
||||
existing_nullable=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column(
|
||||
"portfolio_state_machines",
|
||||
"state",
|
||||
existing_type=sa.Enum(
|
||||
"UNSTARTED",
|
||||
"STARTING",
|
||||
"STARTED",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"TENANT_CREATED",
|
||||
"TENANT_IN_PROGRESS",
|
||||
"TENANT_FAILED",
|
||||
"BILLING_PROFILE_CREATION_CREATED",
|
||||
"BILLING_PROFILE_CREATION_IN_PROGRESS",
|
||||
"BILLING_PROFILE_CREATION_FAILED",
|
||||
"BILLING_PROFILE_VERIFICATION_CREATED",
|
||||
"BILLING_PROFILE_VERIFICATION_IN_PROGRESS",
|
||||
"BILLING_PROFILE_VERIFICATION_FAILED",
|
||||
"BILLING_PROFILE_TENANT_ACCESS_CREATED",
|
||||
"BILLING_PROFILE_TENANT_ACCESS_IN_PROGRESS",
|
||||
"BILLING_PROFILE_TENANT_ACCESS_FAILED",
|
||||
"TASK_ORDER_BILLING_CREATION_CREATED",
|
||||
"TASK_ORDER_BILLING_CREATION_IN_PROGRESS",
|
||||
"TASK_ORDER_BILLING_CREATION_FAILED",
|
||||
"TASK_ORDER_BILLING_VERIFICATION_CREATED",
|
||||
"TASK_ORDER_BILLING_VERIFICATION_IN_PROGRESS",
|
||||
"TASK_ORDER_BILLING_VERIFICATION_FAILED",
|
||||
"BILLING_INSTRUCTION_CREATED",
|
||||
"BILLING_INSTRUCTION_IN_PROGRESS",
|
||||
"BILLING_INSTRUCTION_FAILED",
|
||||
name="fsmstates",
|
||||
native_enum=False,
|
||||
),
|
||||
type_=sa.Enum(
|
||||
"UNSTARTED",
|
||||
"STARTING",
|
||||
"STARTED",
|
||||
"COMPLETED",
|
||||
"FAILED",
|
||||
"TENANT_CREATED",
|
||||
"TENANT_IN_PROGRESS",
|
||||
"TENANT_FAILED",
|
||||
"BILLING_PROFILE_CREATED",
|
||||
"BILLING_PROFILE_IN_PROGRESS",
|
||||
"BILLING_PROFILE_FAILED",
|
||||
"ADMIN_SUBSCRIPTION_CREATED",
|
||||
"ADMIN_SUBSCRIPTION_IN_PROGRESS",
|
||||
"ADMIN_SUBSCRIPTION_FAILED",
|
||||
name="fsmstates",
|
||||
native_enum=False,
|
||||
),
|
||||
existing_nullable=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
@ -1,5 +1,3 @@
|
||||
import importlib
|
||||
|
||||
from .cloud import MockCloudProvider
|
||||
from .file_uploads import AzureUploader, MockUploader
|
||||
from .reports import MockReportingProvider
|
||||
@ -31,22 +29,3 @@ def make_csp_provider(app, csp=None):
|
||||
app.csp = MockCSP(app, test_mode=True)
|
||||
else:
|
||||
app.csp = MockCSP(app)
|
||||
|
||||
|
||||
def _stage_to_classname(stage):
|
||||
return "".join(
|
||||
map(lambda word: word.capitalize(), stage.replace("_", " ").split(" "))
|
||||
)
|
||||
|
||||
|
||||
def get_stage_csp_class(stage, class_type):
|
||||
"""
|
||||
given a stage name and class_type return the class
|
||||
class_type is either 'payload' or 'result'
|
||||
|
||||
"""
|
||||
cls_name = "".join([_stage_to_classname(stage), "CSP", class_type.capitalize()])
|
||||
try:
|
||||
return getattr(importlib.import_module("atst.domain.csp.cloud"), cls_name)
|
||||
except AttributeError:
|
||||
print("could not import CSP Result class <%s>" % cls_name)
|
||||
|
@ -1,889 +0,0 @@
|
||||
import re
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from atst.models.user import User
|
||||
from atst.models.environment import Environment
|
||||
from atst.models.environment_role import EnvironmentRole
|
||||
|
||||
|
||||
class GeneralCSPException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OperationInProgressException(GeneralCSPException):
|
||||
"""Throw this for instances when the CSP reports that the current entity is already
|
||||
being operated on/created/deleted/etc
|
||||
"""
|
||||
|
||||
def __init__(self, operation_desc):
|
||||
self.operation_desc = operation_desc
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An operation for this entity is already in progress: {}".format(
|
||||
self.operation_desc
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationException(GeneralCSPException):
|
||||
"""Throw this for instances when there is a problem with the auth credentials:
|
||||
* Missing credentials
|
||||
* Incorrect credentials
|
||||
* Other credential problems
|
||||
"""
|
||||
|
||||
def __init__(self, auth_error):
|
||||
self.auth_error = auth_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An error occurred with authentication: {}".format(self.auth_error)
|
||||
|
||||
|
||||
class AuthorizationException(GeneralCSPException):
|
||||
"""Throw this for instances when the current credentials are not authorized
|
||||
for the current action.
|
||||
"""
|
||||
|
||||
def __init__(self, auth_error):
|
||||
self.auth_error = auth_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An error occurred with authorization: {}".format(self.auth_error)
|
||||
|
||||
|
||||
class ConnectionException(GeneralCSPException):
|
||||
"""A general problem with the connection, timeouts or unresolved endpoints
|
||||
"""
|
||||
|
||||
def __init__(self, connection_error):
|
||||
self.connection_error = connection_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Could not connect to cloud provider: {}".format(self.connection_error)
|
||||
|
||||
|
||||
class UnknownServerException(GeneralCSPException):
|
||||
"""An error occured on the CSP side (5xx) and we don't know why
|
||||
"""
|
||||
|
||||
def __init__(self, server_error):
|
||||
self.server_error = server_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "A server error occured: {}".format(self.server_error)
|
||||
|
||||
|
||||
class EnvironmentCreationException(GeneralCSPException):
|
||||
"""If there was an error in creating the environment
|
||||
"""
|
||||
|
||||
def __init__(self, env_identifier, reason):
|
||||
self.env_identifier = env_identifier
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "The envionment {} couldn't be created: {}".format(
|
||||
self.env_identifier, self.reason
|
||||
)
|
||||
|
||||
|
||||
class UserProvisioningException(GeneralCSPException):
|
||||
"""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):
|
||||
"""Failed to remove a user
|
||||
"""
|
||||
|
||||
def __init__(self, user_csp_id, reason):
|
||||
self.user_csp_id = user_csp_id
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Failed to suspend or delete user {}: {}".format(
|
||||
self.user_csp_id, self.reason
|
||||
)
|
||||
|
||||
|
||||
class BaselineProvisionException(GeneralCSPException):
|
||||
"""If there's any issues standing up whatever is required
|
||||
for an environment baseline
|
||||
"""
|
||||
|
||||
def __init__(self, env_identifier, reason):
|
||||
self.env_identifier = env_identifier
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Could not complete baseline provisioning for environment ({}): {}".format(
|
||||
self.env_identifier, self.reason
|
||||
)
|
||||
|
||||
|
||||
class BaseCSPPayload(BaseModel):
|
||||
# {"username": "mock-cloud", "pass": "shh"}
|
||||
creds: Dict
|
||||
|
||||
|
||||
class TenantCSPPayload(BaseCSPPayload):
|
||||
user_id: str
|
||||
password: str
|
||||
domain_name: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
country_code: str
|
||||
password_recovery_email_address: str
|
||||
|
||||
|
||||
class TenantCSPResult(BaseModel):
|
||||
user_id: str
|
||||
tenant_id: str
|
||||
user_object_id: str
|
||||
|
||||
|
||||
class BillingProfileAddress(BaseModel):
|
||||
address: Dict
|
||||
"""
|
||||
"address": {
|
||||
"firstName": "string",
|
||||
"lastName": "string",
|
||||
"companyName": "string",
|
||||
"addressLine1": "string",
|
||||
"addressLine2": "string",
|
||||
"addressLine3": "string",
|
||||
"city": "string",
|
||||
"region": "string",
|
||||
"country": "string",
|
||||
"postalCode": "string"
|
||||
},
|
||||
"""
|
||||
|
||||
|
||||
class BillingProfileCLINBudget(BaseModel):
|
||||
clinBudget: Dict
|
||||
"""
|
||||
"clinBudget": {
|
||||
"amount": 0,
|
||||
"startDate": "2019-12-18T16:47:40.909Z",
|
||||
"endDate": "2019-12-18T16:47:40.909Z",
|
||||
"externalReferenceId": "string"
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class BillingProfileCSPPayload(
|
||||
BaseCSPPayload, BillingProfileAddress, BillingProfileCLINBudget
|
||||
):
|
||||
displayName: str
|
||||
poNumber: str
|
||||
invoiceEmailOptIn: str
|
||||
|
||||
"""
|
||||
{
|
||||
"displayName": "string",
|
||||
"poNumber": "string",
|
||||
"address": {
|
||||
"firstName": "string",
|
||||
"lastName": "string",
|
||||
"companyName": "string",
|
||||
"addressLine1": "string",
|
||||
"addressLine2": "string",
|
||||
"addressLine3": "string",
|
||||
"city": "string",
|
||||
"region": "string",
|
||||
"country": "string",
|
||||
"postalCode": "string"
|
||||
},
|
||||
"invoiceEmailOptIn": true,
|
||||
Note: These last 2 are also the body for adding/updating new TOs/clins
|
||||
"enabledAzurePlans": [
|
||||
{
|
||||
"skuId": "string"
|
||||
}
|
||||
],
|
||||
"clinBudget": {
|
||||
"amount": 0,
|
||||
"startDate": "2019-12-18T16:47:40.909Z",
|
||||
"endDate": "2019-12-18T16:47:40.909Z",
|
||||
"externalReferenceId": "string"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CloudProviderInterface:
|
||||
def root_creds(self) -> Dict:
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_environment(
|
||||
self, auth_credentials: Dict, user: User, environment: Environment
|
||||
) -> str:
|
||||
"""Create a new environment in the CSP.
|
||||
|
||||
Arguments:
|
||||
auth_credentials -- Object containing CSP account credentials
|
||||
user -- ATAT user authorizing the environment creation
|
||||
environment -- ATAT Environment model
|
||||
|
||||
Returns:
|
||||
string: ID of created environment
|
||||
|
||||
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
|
||||
EnvironmentExistsException: Environment already exists and has been created
|
||||
"""
|
||||
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(
|
||||
self, auth_credentials: Dict, user_info: EnvironmentRole, csp_role_id: str
|
||||
) -> str:
|
||||
"""Creates a user or updates an existing user's role.
|
||||
|
||||
Arguments:
|
||||
auth_credentials -- Object containing CSP account credentials
|
||||
user_info -- instance of EnvironmentRole containing user data
|
||||
if it has a csp_user_id it will try to update that user
|
||||
csp_role_id -- The id of the role the user should be given in the CSP
|
||||
|
||||
Returns:
|
||||
string: Returns the interal csp_user_id of the created/updated user account
|
||||
|
||||
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: User couldn't be created or modified
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def disable_user(self, auth_credentials: Dict, csp_user_id: str) -> bool:
|
||||
"""Revoke all privileges for a user. Used to prevent user access while a full
|
||||
delete is being processed.
|
||||
|
||||
Arguments:
|
||||
auth_credentials -- Object containing CSP account credentials
|
||||
csp_user_id -- CSP internal user identifier
|
||||
|
||||
Returns:
|
||||
bool -- True on success
|
||||
|
||||
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
|
||||
UserRemovalException: User couldn't be suspended
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_calculator_url(self) -> str:
|
||||
"""Returns the calculator url for the CSP.
|
||||
This will likely be a static property elsewhere once a CSP is chosen.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_environment_login_url(self, environment) -> str:
|
||||
"""Returns the login url for a given environment
|
||||
This may move to be a computed property on the Environment domain object
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_subscription(self, environment):
|
||||
"""Returns True if a new subscription has been created or raises an
|
||||
exception if an error occurs while creating a subscription.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class MockCloudProvider(CloudProviderInterface):
|
||||
|
||||
# TODO: All of these constants
|
||||
AUTHENTICATION_EXCEPTION = AuthenticationException("Authentication failure.")
|
||||
AUTHORIZATION_EXCEPTION = AuthorizationException("Not authorized.")
|
||||
NETWORK_EXCEPTION = ConnectionException("Network failure.")
|
||||
SERVER_EXCEPTION = UnknownServerException("Not our fault.")
|
||||
|
||||
SERVER_FAILURE_PCT = 1
|
||||
NETWORK_FAILURE_PCT = 7
|
||||
ENV_CREATE_FAILURE_PCT = 12
|
||||
ATAT_ADMIN_CREATE_FAILURE_PCT = 12
|
||||
UNAUTHORIZED_RATE = 2
|
||||
|
||||
def __init__(
|
||||
self, config, with_delay=True, with_failure=True, with_authorization=True
|
||||
):
|
||||
from time import sleep
|
||||
import random
|
||||
|
||||
self._with_delay = with_delay
|
||||
self._with_failure = with_failure
|
||||
self._with_authorization = with_authorization
|
||||
self._sleep = sleep
|
||||
self._random = random
|
||||
|
||||
def root_creds(self):
|
||||
return self._auth_credentials
|
||||
|
||||
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_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):
|
||||
"""
|
||||
payload is an instance of TenantCSPPayload data class
|
||||
"""
|
||||
|
||||
self._authorize(payload.creds)
|
||||
|
||||
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.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
# return tenant id, tenant owner id and tenant owner object id from:
|
||||
response = {"tenantId": "string", "userId": "string", "objectId": "string"}
|
||||
return {
|
||||
"tenant_id": response["tenantId"],
|
||||
"user_id": response["userId"],
|
||||
"user_object_id": response["objectId"],
|
||||
}
|
||||
|
||||
def create_billing_profile(self, creds, tenant_admin_details, billing_owner_id):
|
||||
# call billing profile creation endpoint, specifying owner
|
||||
# Payload:
|
||||
"""
|
||||
{
|
||||
"displayName": "string",
|
||||
"poNumber": "string",
|
||||
"address": {
|
||||
"firstName": "string",
|
||||
"lastName": "string",
|
||||
"companyName": "string",
|
||||
"addressLine1": "string",
|
||||
"addressLine2": "string",
|
||||
"addressLine3": "string",
|
||||
"city": "string",
|
||||
"region": "string",
|
||||
"country": "string",
|
||||
"postalCode": "string"
|
||||
},
|
||||
"invoiceEmailOptIn": true,
|
||||
Note: These last 2 are also the body for adding/updating new TOs/clins
|
||||
"enabledAzurePlans": [
|
||||
{
|
||||
"skuId": "string"
|
||||
}
|
||||
],
|
||||
"clinBudget": {
|
||||
"amount": 0,
|
||||
"startDate": "2019-12-18T16:47:40.909Z",
|
||||
"endDate": "2019-12-18T16:47:40.909Z",
|
||||
"externalReferenceId": "string"
|
||||
}
|
||||
}
|
||||
"""
|
||||
# response will be mostly the same as the body, but we only really care about the id
|
||||
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)
|
||||
|
||||
response = {"id": "string"}
|
||||
return {"billing_profile_id": response["id"]}
|
||||
|
||||
def create_or_update_user(self, auth_credentials, user_info, csp_role_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(
|
||||
user_info.environment.id,
|
||||
user_info.application_role.user_id,
|
||||
"Could not create user.",
|
||||
),
|
||||
)
|
||||
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
return self._id()
|
||||
|
||||
def disable_user(self, auth_credentials, csp_user_id):
|
||||
self._authorize(auth_credentials)
|
||||
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,
|
||||
UserRemovalException(csp_user_id, "Could not disable user."),
|
||||
)
|
||||
|
||||
return self._maybe(12)
|
||||
|
||||
def create_subscription(self, environment):
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
|
||||
|
||||
return True
|
||||
|
||||
def get_calculator_url(self):
|
||||
return "https://www.rackspace.com/en-us/calculator"
|
||||
|
||||
def get_environment_login_url(self, environment):
|
||||
"""Returns the login url for a given environment
|
||||
"""
|
||||
return "https://www.mycloud.com/my-env-login"
|
||||
|
||||
def _id(self):
|
||||
return uuid4().hex
|
||||
|
||||
def _delay(self, min_secs, max_secs):
|
||||
if self._with_delay:
|
||||
duration = self._random.randrange(min_secs, max_secs)
|
||||
self._sleep(duration)
|
||||
|
||||
def _maybe(self, pct):
|
||||
return not self._with_failure or self._random.randrange(0, 100) < pct
|
||||
|
||||
def _maybe_raise(self, pct, exc):
|
||||
if self._with_failure and self._maybe(pct):
|
||||
raise exc
|
||||
|
||||
@property
|
||||
def _auth_credentials(self):
|
||||
return {"username": "mock-cloud", "pass": "shh"}
|
||||
|
||||
def _authorize(self, credentials):
|
||||
self._delay(1, 5)
|
||||
if self._with_authorization and credentials != self._auth_credentials:
|
||||
raise self.AUTHENTICATION_EXCEPTION
|
||||
|
||||
|
||||
AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD
|
||||
AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI
|
||||
SUBSCRIPTION_ID_REGEX = re.compile(
|
||||
"subscriptions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# This needs to be a fully pathed role definition identifier, not just a UUID
|
||||
REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00000000-0000-4000-8000-000000000000"
|
||||
|
||||
|
||||
class AzureSDKProvider(object):
|
||||
def __init__(self):
|
||||
from azure.mgmt import subscription, authorization
|
||||
import azure.graphrbac as graphrbac
|
||||
import azure.common.credentials as credentials
|
||||
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
|
||||
|
||||
self.subscription = subscription
|
||||
self.authorization = authorization
|
||||
self.graphrbac = graphrbac
|
||||
self.credentials = credentials
|
||||
# may change to a JEDI cloud
|
||||
self.cloud = AZURE_PUBLIC_CLOUD
|
||||
|
||||
|
||||
class AzureCloudProvider(CloudProviderInterface):
|
||||
def __init__(self, config, azure_sdk_provider=None):
|
||||
self.config = config
|
||||
|
||||
self.client_id = config["AZURE_CLIENT_ID"]
|
||||
self.secret_key = config["AZURE_SECRET_KEY"]
|
||||
self.tenant_id = config["AZURE_TENANT_ID"]
|
||||
|
||||
if azure_sdk_provider is None:
|
||||
self.sdk = AzureSDKProvider()
|
||||
else:
|
||||
self.sdk = azure_sdk_provider
|
||||
|
||||
def create_environment(
|
||||
self, auth_credentials: Dict, user: User, environment: Environment
|
||||
):
|
||||
credentials = self._get_credential_obj(self._root_creds)
|
||||
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
|
||||
|
||||
display_name = f"{environment.application.name}_{environment.name}_{environment.id}" # proposed format
|
||||
|
||||
billing_profile_id = "?" # something chained from environment?
|
||||
sku_id = AZURE_SKU_ID
|
||||
# we want to set AT-AT as an owner here
|
||||
# we could potentially associate subscriptions with "management groups" per DOD component
|
||||
body = self.sdk.subscription.models.ModernSubscriptionCreationParameters(
|
||||
display_name,
|
||||
billing_profile_id,
|
||||
sku_id,
|
||||
# owner=<AdPrincipal: for AT-AT user>
|
||||
)
|
||||
|
||||
# These 2 seem like something that might be worthwhile to allow tiebacks to
|
||||
# TOs filed for the environment
|
||||
billing_account_name = "?"
|
||||
invoice_section_name = "?"
|
||||
# We may also want to create billing sections in the enrollment account
|
||||
sub_creation_operation = sub_client.subscription_factory.create_subscription(
|
||||
billing_account_name, invoice_section_name, body
|
||||
)
|
||||
|
||||
# the resulting object from this process is a link to the new subscription
|
||||
# not a subscription model, so we'll have to unpack the ID
|
||||
new_sub = sub_creation_operation.result()
|
||||
|
||||
subscription_id = self._extract_subscription_id(new_sub.subscription_link)
|
||||
if subscription_id:
|
||||
return subscription_id
|
||||
else:
|
||||
# troublesome error, subscription should exist at this point
|
||||
# but we just don't have a valid ID
|
||||
pass
|
||||
|
||||
def create_atat_admin_user(
|
||||
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,
|
||||
# TODO: Determine which subscription this needs to point at
|
||||
# Once we're in a multi-sub environment
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
# Create role assignment for
|
||||
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_tenant(self, payload):
|
||||
# auth as SP that is allowed to create tenant? (tenant creation sp creds)
|
||||
# create tenant with owner details (populated from portfolio point of contact, pw is generated)
|
||||
|
||||
# return tenant id, tenant owner id and tenant owner object id from:
|
||||
response = {"tenantId": "string", "userId": "string", "objectId": "string"}
|
||||
return self._ok(
|
||||
{
|
||||
"tenant_id": response["tenantId"],
|
||||
"user_id": response["userId"],
|
||||
"user_object_id": response["objectId"],
|
||||
}
|
||||
)
|
||||
|
||||
def create_billing_owner(self, creds, tenant_admin_details):
|
||||
# authenticate as tenant_admin
|
||||
# create billing owner identity
|
||||
|
||||
# TODO: Lookup response format
|
||||
# Managed service identity?
|
||||
response = {"id": "string"}
|
||||
return self._ok({"billing_owner_id": response["id"]})
|
||||
|
||||
def assign_billing_owner(self, creds, billing_owner_id, tenant_id):
|
||||
# TODO: Do we source role definition ID from config, api or self-defined?
|
||||
# TODO: If from api,
|
||||
"""
|
||||
{
|
||||
"principalId": "string",
|
||||
"principalTenantId": "string",
|
||||
"billingRoleDefinitionId": "string"
|
||||
}
|
||||
"""
|
||||
|
||||
return self.ok()
|
||||
|
||||
def create_billing_profile(self, creds, tenant_admin_details, billing_owner_id):
|
||||
# call billing profile creation endpoint, specifying owner
|
||||
# Payload:
|
||||
"""
|
||||
{
|
||||
"displayName": "string",
|
||||
"poNumber": "string",
|
||||
"address": {
|
||||
"firstName": "string",
|
||||
"lastName": "string",
|
||||
"companyName": "string",
|
||||
"addressLine1": "string",
|
||||
"addressLine2": "string",
|
||||
"addressLine3": "string",
|
||||
"city": "string",
|
||||
"region": "string",
|
||||
"country": "string",
|
||||
"postalCode": "string"
|
||||
},
|
||||
"invoiceEmailOptIn": true,
|
||||
Note: These last 2 are also the body for adding/updating new TOs/clins
|
||||
"enabledAzurePlans": [
|
||||
{
|
||||
"skuId": "string"
|
||||
}
|
||||
],
|
||||
"clinBudget": {
|
||||
"amount": 0,
|
||||
"startDate": "2019-12-18T16:47:40.909Z",
|
||||
"endDate": "2019-12-18T16:47:40.909Z",
|
||||
"externalReferenceId": "string"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# response will be mostly the same as the body, but we only really care about the id
|
||||
response = {"id": "string"}
|
||||
return self._ok({"billing_profile_id": response["id"]})
|
||||
|
||||
def report_clin(self, creds, clin_id, clin_amount, clin_start, clin_end, clin_to):
|
||||
# should consumer be responsible for reporting each clin or
|
||||
# should this take a list and manage the sequential reporting?
|
||||
""" Payload
|
||||
{
|
||||
"enabledAzurePlans": [
|
||||
{
|
||||
"skuId": "string"
|
||||
}
|
||||
],
|
||||
"clinBudget": {
|
||||
"amount": 0,
|
||||
"startDate": "2019-12-18T16:47:40.909Z",
|
||||
"endDate": "2019-12-18T16:47:40.909Z",
|
||||
"externalReferenceId": "string"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# we don't need any of the returned info for this
|
||||
return self._ok()
|
||||
|
||||
def create_remote_admin(self, creds, tenant_details):
|
||||
# create app/service principal within tenant, with name constructed from tenant details
|
||||
# assign principal global admin
|
||||
|
||||
# needs to call out to CLI with tenant owner username/password, prototyping for that underway
|
||||
|
||||
# return identifier and creds to consumer for storage
|
||||
response = {"clientId": "string", "secretKey": "string", "tenantId": "string"}
|
||||
return self._ok(
|
||||
{
|
||||
"client_id": response["clientId"],
|
||||
"secret_key": response["secret_key"],
|
||||
"tenant_id": response["tenantId"],
|
||||
}
|
||||
)
|
||||
|
||||
def force_tenant_admin_pw_update(self, creds, tenant_owner_id):
|
||||
# use creds to update to force password recovery?
|
||||
# not sure what the endpoint/method for this is, yet
|
||||
|
||||
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):
|
||||
# we really should be using graph.microsoft.com, but i'm getting
|
||||
# "expired token" errors for that
|
||||
# graph_resource = "https://graph.microsoft.com"
|
||||
graph_resource = "https://graph.windows.net"
|
||||
graph_creds = self._get_credential_obj(
|
||||
self._root_creds, resource=graph_resource
|
||||
)
|
||||
# I needed to set permissions for the graph.windows.net API before I
|
||||
# could get this to work.
|
||||
|
||||
# how do we scope the graph client to the new subscription rather than
|
||||
# the cloud0 subscription? tenant id seems to be separate from subscription id
|
||||
graph_client = self.sdk.graphrbac.GraphRbacManagementClient(
|
||||
graph_creds, self._root_creds.get("tenant_id")
|
||||
)
|
||||
|
||||
# do we need to create a new application to manage each subscripition
|
||||
# or should we manage access to each subscription from a single service
|
||||
# principal with multiple role assignments?
|
||||
app_display_name = "?" # name should reflect the subscription it exists
|
||||
app_create_param = self.sdk.graphrbac.models.ApplicationCreateParameters(
|
||||
display_name=app_display_name
|
||||
)
|
||||
|
||||
# we need the appropriate perms here:
|
||||
# https://docs.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-beta&tabs=http
|
||||
# https://docs.microsoft.com/en-us/graph/permissions-reference#microsoft-graph-permission-names
|
||||
# set app perms in app registration portal
|
||||
# https://docs.microsoft.com/en-us/graph/auth-v2-service#2-configure-permissions-for-microsoft-graph
|
||||
app: self.sdk.graphrbac.models.Application = graph_client.applications.create(
|
||||
app_create_param
|
||||
)
|
||||
|
||||
# create a new service principle for the new application, which should be scoped
|
||||
# to the new subscription
|
||||
app_id = app.app_id
|
||||
sp_create_params = self.sdk.graphrbac.models.ServicePrincipalCreateParameters(
|
||||
app_id=app_id, account_enabled=True
|
||||
)
|
||||
|
||||
service_principal = graph_client.service_principals.create(sp_create_params)
|
||||
|
||||
return service_principal
|
||||
|
||||
def _extract_subscription_id(self, subscription_url):
|
||||
sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url)
|
||||
|
||||
if sub_id_match:
|
||||
return sub_id_match.group(1)
|
||||
|
||||
def _get_credential_obj(self, creds, resource=None):
|
||||
|
||||
return self.sdk.credentials.ServicePrincipalCredentials(
|
||||
client_id=creds.get("client_id"),
|
||||
secret=creds.get("secret_key"),
|
||||
tenant=creds.get("tenant_id"),
|
||||
resource=resource,
|
||||
cloud_environment=self.sdk.cloud,
|
||||
)
|
||||
|
||||
def _make_tenant_admin_cred_obj(self, username, password):
|
||||
return self.sdk.credentials.UserPassCredentials(username, password)
|
||||
|
||||
def _ok(self, body=None):
|
||||
return self._make_response("ok", body)
|
||||
|
||||
def _error(self, body=None):
|
||||
return self._make_response("error", body)
|
||||
|
||||
def _make_response(self, status, body=dict()):
|
||||
"""Create body for responses from API
|
||||
|
||||
Arguments:
|
||||
status {string} -- "ok" or "error"
|
||||
body {dict} -- dict containing details of response or error, if applicable
|
||||
|
||||
Returns:
|
||||
dict -- status of call with body containing details
|
||||
"""
|
||||
return {"status": status, "body": body}
|
||||
|
||||
@property
|
||||
def _root_creds(self):
|
||||
return {
|
||||
"client_id": self.client_id,
|
||||
"secret_key": self.secret_key,
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
3
atst/domain/csp/cloud/__init__.py
Normal file
3
atst/domain/csp/cloud/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .azure_cloud_provider import AzureCloudProvider
|
||||
from .cloud_provider_interface import CloudProviderInterface
|
||||
from .mock_cloud_provider import MockCloudProvider
|
628
atst/domain/csp/cloud/azure_cloud_provider.py
Normal file
628
atst/domain/csp/cloud/azure_cloud_provider.py
Normal file
@ -0,0 +1,628 @@
|
||||
import re
|
||||
from secrets import token_urlsafe
|
||||
from typing import Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from atst.models.application import Application
|
||||
from atst.models.environment import Environment
|
||||
from atst.models.user import User
|
||||
|
||||
from .cloud_provider_interface import CloudProviderInterface
|
||||
from .exceptions import AuthenticationException
|
||||
from .models import (
|
||||
BillingInstructionCSPPayload,
|
||||
BillingInstructionCSPResult,
|
||||
BillingProfileCreationCSPPayload,
|
||||
BillingProfileCreationCSPResult,
|
||||
BillingProfileTenantAccessCSPPayload,
|
||||
BillingProfileTenantAccessCSPResult,
|
||||
BillingProfileVerificationCSPPayload,
|
||||
BillingProfileVerificationCSPResult,
|
||||
TaskOrderBillingCreationCSPPayload,
|
||||
TaskOrderBillingCreationCSPResult,
|
||||
TaskOrderBillingVerificationCSPPayload,
|
||||
TaskOrderBillingVerificationCSPResult,
|
||||
TenantCSPPayload,
|
||||
TenantCSPResult,
|
||||
)
|
||||
from .policy import AzurePolicyManager
|
||||
|
||||
AZURE_ENVIRONMENT = "AZURE_PUBLIC_CLOUD" # TBD
|
||||
AZURE_SKU_ID = "?" # probably a static sku specific to ATAT/JEDI
|
||||
SUBSCRIPTION_ID_REGEX = re.compile(
|
||||
"subscriptions\/([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})",
|
||||
re.I,
|
||||
)
|
||||
|
||||
# This needs to be a fully pathed role definition identifier, not just a UUID
|
||||
REMOTE_ROOT_ROLE_DEF_ID = "/providers/Microsoft.Authorization/roleDefinitions/00000000-0000-4000-8000-000000000000"
|
||||
AZURE_MANAGEMENT_API = "https://management.azure.com"
|
||||
|
||||
|
||||
class AzureSDKProvider(object):
|
||||
def __init__(self):
|
||||
from azure.mgmt import subscription, authorization, managementgroups
|
||||
from azure.mgmt.resource import policy
|
||||
import azure.graphrbac as graphrbac
|
||||
import azure.common.credentials as credentials
|
||||
import azure.identity as identity
|
||||
from azure.keyvault import secrets
|
||||
|
||||
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
|
||||
import adal
|
||||
import requests
|
||||
|
||||
self.subscription = subscription
|
||||
self.policy = policy
|
||||
self.managementgroups = managementgroups
|
||||
self.authorization = authorization
|
||||
self.adal = adal
|
||||
self.graphrbac = graphrbac
|
||||
self.credentials = credentials
|
||||
self.identity = identity
|
||||
self.exceptions = exceptions
|
||||
self.secrets = secrets
|
||||
self.requests = requests
|
||||
# may change to a JEDI cloud
|
||||
self.cloud = AZURE_PUBLIC_CLOUD
|
||||
|
||||
|
||||
class AzureCloudProvider(CloudProviderInterface):
|
||||
def __init__(self, config, azure_sdk_provider=None):
|
||||
self.config = config
|
||||
|
||||
self.client_id = config["AZURE_CLIENT_ID"]
|
||||
self.secret_key = config["AZURE_SECRET_KEY"]
|
||||
self.tenant_id = config["AZURE_TENANT_ID"]
|
||||
self.vault_url = config["AZURE_VAULT_URL"]
|
||||
|
||||
if azure_sdk_provider is None:
|
||||
self.sdk = AzureSDKProvider()
|
||||
else:
|
||||
self.sdk = azure_sdk_provider
|
||||
|
||||
self.policy_manager = AzurePolicyManager(config["AZURE_POLICY_LOCATION"])
|
||||
|
||||
def set_secret(self, secret_key, secret_value):
|
||||
credential = self._get_client_secret_credential_obj({})
|
||||
secret_client = self.secrets.SecretClient(
|
||||
vault_url=self.vault_url, credential=credential,
|
||||
)
|
||||
try:
|
||||
return secret_client.set_secret(secret_key, secret_value)
|
||||
except self.exceptions.HttpResponseError:
|
||||
app.logger.error(
|
||||
f"Could not SET secret in Azure keyvault for key {secret_key}.",
|
||||
exc_info=1,
|
||||
)
|
||||
|
||||
def get_secret(self, secret_key):
|
||||
credential = self._get_client_secret_credential_obj({})
|
||||
secret_client = self.secrets.SecretClient(
|
||||
vault_url=self.vault_url, credential=credential,
|
||||
)
|
||||
try:
|
||||
return secret_client.get_secret(secret_key).value
|
||||
except self.exceptions.HttpResponseError:
|
||||
app.logger.error(
|
||||
f"Could not GET secret in Azure keyvault for key {secret_key}.",
|
||||
exc_info=1,
|
||||
)
|
||||
|
||||
def create_environment(
|
||||
self, auth_credentials: Dict, user: User, environment: Environment
|
||||
):
|
||||
# since this operation would only occur within a tenant, should we source the tenant
|
||||
# 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 management_group
|
||||
|
||||
def create_atat_admin_user(
|
||||
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,
|
||||
# TODO: Determine which subscription this needs to point at
|
||||
# Once we're in a multi-sub environment
|
||||
subscription.id,
|
||||
)
|
||||
|
||||
# Create role assignment for
|
||||
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, auth_credentials: Dict, application: Application):
|
||||
management_group_name = str(uuid4()) # can be anything, not just uuid
|
||||
display_name = application.name # Does this need to be unique?
|
||||
credentials = self._get_credential_obj(auth_credentials)
|
||||
parent_id = "?" # application.portfolio.csp_details.management_group_id
|
||||
|
||||
return self._create_management_group(
|
||||
credentials, management_group_name, display_name, parent_id,
|
||||
)
|
||||
|
||||
def _create_management_group(
|
||||
self, credentials, management_group_id, display_name, parent_id=None,
|
||||
):
|
||||
mgmgt_group_client = self.sdk.managementgroups.ManagementGroupsAPI(credentials)
|
||||
create_parent_grp_info = self.sdk.managementgroups.models.CreateParentGroupInfo(
|
||||
id=parent_id
|
||||
)
|
||||
create_mgmt_grp_details = self.sdk.managementgroups.models.CreateManagementGroupDetails(
|
||||
parent=create_parent_grp_info
|
||||
)
|
||||
mgmt_grp_create = self.sdk.managementgroups.models.CreateManagementGroupRequest(
|
||||
name=management_group_id,
|
||||
display_name=display_name,
|
||||
details=create_mgmt_grp_details,
|
||||
)
|
||||
create_request = mgmgt_group_client.management_groups.create_or_update(
|
||||
management_group_id, mgmt_grp_create
|
||||
)
|
||||
|
||||
# result is a synchronous wait, might need to do a poll instead to handle first mgmt group create
|
||||
# since we were told it could take 10+ minutes to complete, unless this handles that polling internally
|
||||
return create_request.result()
|
||||
|
||||
def _create_subscription(
|
||||
self,
|
||||
credentials,
|
||||
display_name,
|
||||
billing_profile_id,
|
||||
sku_id,
|
||||
management_group_id,
|
||||
billing_account_name,
|
||||
invoice_section_name,
|
||||
):
|
||||
sub_client = self.sdk.subscription.SubscriptionClient(credentials)
|
||||
|
||||
billing_profile_id = "?" # where do we source this?
|
||||
sku_id = AZURE_SKU_ID
|
||||
# These 2 seem like something that might be worthwhile to allow tiebacks to
|
||||
# TOs filed for the environment
|
||||
billing_account_name = "?" # from TO?
|
||||
invoice_section_name = "?" # from TO?
|
||||
|
||||
body = self.sdk.subscription.models.ModernSubscriptionCreationParameters(
|
||||
display_name=display_name,
|
||||
billing_profile_id=billing_profile_id,
|
||||
sku_id=sku_id,
|
||||
management_group_id=management_group_id,
|
||||
)
|
||||
|
||||
# We may also want to create billing sections in the enrollment account
|
||||
sub_creation_operation = sub_client.subscription_factory.create_subscription(
|
||||
billing_account_name, invoice_section_name, body
|
||||
)
|
||||
|
||||
# the resulting object from this process is a link to the new subscription
|
||||
# not a subscription model, so we'll have to unpack the ID
|
||||
new_sub = sub_creation_operation.result()
|
||||
|
||||
subscription_id = self._extract_subscription_id(new_sub.subscription_link)
|
||||
if subscription_id:
|
||||
return subscription_id
|
||||
else:
|
||||
# troublesome error, subscription should exist at this point
|
||||
# but we just don't have a valid ID
|
||||
pass
|
||||
|
||||
def _create_policy_definition(
|
||||
self, credentials, subscription_id, management_group_id, properties,
|
||||
):
|
||||
"""
|
||||
Requires credentials that have AZURE_MANAGEMENT_API
|
||||
specified as the resource. The Service Principal
|
||||
specified in the credentials must have the "Resource
|
||||
Policy Contributor" role assigned with a scope at least
|
||||
as high as the management group specified by
|
||||
management_group_id.
|
||||
|
||||
Arguments:
|
||||
credentials -- ServicePrincipalCredentials
|
||||
subscription_id -- str, ID of the subscription (just the UUID, not the path)
|
||||
management_group_id -- str, ID of the management group (just the UUID, not the path)
|
||||
properties -- dictionary, the "properties" section of a valid Azure policy definition document
|
||||
|
||||
Returns:
|
||||
azure.mgmt.resource.policy.[api version].models.PolicyDefinition: the PolicyDefinition object provided to Azure
|
||||
|
||||
Raises:
|
||||
TBD
|
||||
"""
|
||||
# TODO: which subscription would this be?
|
||||
client = self.sdk.policy.PolicyClient(credentials, subscription_id)
|
||||
|
||||
definition = client.policy_definitions.models.PolicyDefinition(
|
||||
policy_type=properties.get("policyType"),
|
||||
mode=properties.get("mode"),
|
||||
display_name=properties.get("displayName"),
|
||||
description=properties.get("description"),
|
||||
policy_rule=properties.get("policyRule"),
|
||||
parameters=properties.get("parameters"),
|
||||
)
|
||||
|
||||
name = properties.get("displayName")
|
||||
|
||||
return client.policy_definitions.create_or_update_at_management_group(
|
||||
policy_definition_name=name,
|
||||
parameters=definition,
|
||||
management_group_id=management_group_id,
|
||||
)
|
||||
|
||||
def create_tenant(self, payload: TenantCSPPayload):
|
||||
sp_token = self._get_sp_token(payload.creds)
|
||||
if sp_token is None:
|
||||
raise AuthenticationException("Could not resolve token for tenant creation")
|
||||
payload.password = token_urlsafe(16)
|
||||
create_tenant_body = payload.dict(by_alias=True)
|
||||
|
||||
create_tenant_headers = {
|
||||
"Authorization": f"Bearer {sp_token}",
|
||||
}
|
||||
|
||||
result = self.sdk.requests.post(
|
||||
"https://management.azure.com/providers/Microsoft.SignUp/createTenant?api-version=2020-01-01-preview",
|
||||
json=create_tenant_body,
|
||||
headers=create_tenant_headers,
|
||||
)
|
||||
|
||||
if result.status_code == 200:
|
||||
return self._ok(
|
||||
TenantCSPResult(
|
||||
**result.json(),
|
||||
tenant_admin_password=payload.password,
|
||||
tenant_admin_username=payload.user_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
return self._error(result.json())
|
||||
|
||||
def create_billing_profile_creation(
|
||||
self, payload: BillingProfileCreationCSPPayload
|
||||
):
|
||||
sp_token = self._get_sp_token(payload.creds)
|
||||
if sp_token is None:
|
||||
raise AuthenticationException(
|
||||
"Could not resolve token for billing profile creation"
|
||||
)
|
||||
|
||||
create_billing_account_body = payload.dict(by_alias=True)
|
||||
|
||||
create_billing_account_headers = {
|
||||
"Authorization": f"Bearer {sp_token}",
|
||||
}
|
||||
|
||||
billing_account_create_url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles?api-version=2019-10-01-preview"
|
||||
|
||||
result = self.sdk.requests.post(
|
||||
billing_account_create_url,
|
||||
json=create_billing_account_body,
|
||||
headers=create_billing_account_headers,
|
||||
)
|
||||
|
||||
if result.status_code == 202:
|
||||
# 202 has location/retry after headers
|
||||
return self._ok(BillingProfileCreationCSPResult(**result.headers))
|
||||
elif result.status_code == 200:
|
||||
# NB: Swagger docs imply call can sometimes resolve immediately
|
||||
return self._ok(BillingProfileVerificationCSPResult(**result.json()))
|
||||
else:
|
||||
return self._error(result.json())
|
||||
|
||||
def create_billing_profile_verification(
|
||||
self, payload: BillingProfileVerificationCSPPayload
|
||||
):
|
||||
sp_token = self._get_sp_token(payload.creds)
|
||||
if sp_token is None:
|
||||
raise AuthenticationException(
|
||||
"Could not resolve token for billing profile validation"
|
||||
)
|
||||
|
||||
auth_header = {
|
||||
"Authorization": f"Bearer {sp_token}",
|
||||
}
|
||||
|
||||
result = self.sdk.requests.get(
|
||||
payload.billing_profile_verify_url, headers=auth_header
|
||||
)
|
||||
|
||||
if result.status_code == 202:
|
||||
# 202 has location/retry after headers
|
||||
return self._ok(BillingProfileCreationCSPResult(**result.headers))
|
||||
elif result.status_code == 200:
|
||||
return self._ok(BillingProfileVerificationCSPResult(**result.json()))
|
||||
else:
|
||||
return self._error(result.json())
|
||||
|
||||
def create_billing_profile_tenant_access(
|
||||
self, payload: BillingProfileTenantAccessCSPPayload
|
||||
):
|
||||
sp_token = self._get_sp_token(payload.creds)
|
||||
request_body = {
|
||||
"properties": {
|
||||
"principalTenantId": payload.tenant_id, # from tenant creation
|
||||
"principalId": payload.user_object_id, # from tenant creationn
|
||||
"roleDefinitionId": f"/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000",
|
||||
}
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {sp_token}",
|
||||
}
|
||||
|
||||
url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/createBillingRoleAssignment?api-version=2019-10-01-preview"
|
||||
|
||||
result = self.sdk.requests.post(url, headers=headers, json=request_body)
|
||||
if result.status_code == 201:
|
||||
return self._ok(BillingProfileTenantAccessCSPResult(**result.json()))
|
||||
else:
|
||||
return self._error(result.json())
|
||||
|
||||
def create_task_order_billing_creation(
|
||||
self, payload: TaskOrderBillingCreationCSPPayload
|
||||
):
|
||||
sp_token = self._get_sp_token(payload.creds)
|
||||
request_body = [
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/enabledAzurePlans",
|
||||
"value": [{"skuId": "0001"}],
|
||||
}
|
||||
]
|
||||
|
||||
request_headers = {
|
||||
"Authorization": f"Bearer {sp_token}",
|
||||
}
|
||||
|
||||
url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}?api-version=2019-10-01-preview"
|
||||
|
||||
result = self.sdk.requests.patch(
|
||||
url, headers=request_headers, json=request_body
|
||||
)
|
||||
|
||||
if result.status_code == 202:
|
||||
# 202 has location/retry after headers
|
||||
return self._ok(TaskOrderBillingCreationCSPResult(**result.headers))
|
||||
elif result.status_code == 200:
|
||||
return self._ok(TaskOrderBillingVerificationCSPResult(**result.json()))
|
||||
else:
|
||||
return self._error(result.json())
|
||||
|
||||
def create_task_order_billing_verification(
|
||||
self, payload: TaskOrderBillingVerificationCSPPayload
|
||||
):
|
||||
sp_token = self._get_sp_token(payload.creds)
|
||||
if sp_token is None:
|
||||
raise AuthenticationException(
|
||||
"Could not resolve token for task order billing validation"
|
||||
)
|
||||
|
||||
auth_header = {
|
||||
"Authorization": f"Bearer {sp_token}",
|
||||
}
|
||||
|
||||
result = self.sdk.requests.get(
|
||||
payload.task_order_billing_verify_url, headers=auth_header
|
||||
)
|
||||
|
||||
if result.status_code == 202:
|
||||
# 202 has location/retry after headers
|
||||
return self._ok(TaskOrderBillingCreationCSPResult(**result.headers))
|
||||
elif result.status_code == 200:
|
||||
return self._ok(TaskOrderBillingVerificationCSPResult(**result.json()))
|
||||
else:
|
||||
return self._error(result.json())
|
||||
|
||||
def create_billing_instruction(self, payload: BillingInstructionCSPPayload):
|
||||
sp_token = self._get_sp_token(payload.creds)
|
||||
if sp_token is None:
|
||||
raise AuthenticationException(
|
||||
"Could not resolve token for task order billing validation"
|
||||
)
|
||||
|
||||
request_body = {
|
||||
"properties": {
|
||||
"amount": payload.initial_clin_amount,
|
||||
"startDate": payload.initial_clin_start_date,
|
||||
"endDate": payload.initial_clin_end_date,
|
||||
}
|
||||
}
|
||||
|
||||
url = f"https://management.azure.com/providers/Microsoft.Billing/billingAccounts/{payload.billing_account_name}/billingProfiles/{payload.billing_profile_name}/instructions/{payload.initial_task_order_id}:CLIN00{payload.initial_clin_type}?api-version=2019-10-01-preview"
|
||||
|
||||
auth_header = {
|
||||
"Authorization": f"Bearer {sp_token}",
|
||||
}
|
||||
|
||||
result = self.sdk.requests.put(url, headers=auth_header, json=request_body)
|
||||
|
||||
if result.status_code == 200:
|
||||
return self._ok(BillingInstructionCSPResult(**result.json()))
|
||||
else:
|
||||
return self._error(result.json())
|
||||
|
||||
def create_remote_admin(self, creds, tenant_details):
|
||||
# create app/service principal within tenant, with name constructed from tenant details
|
||||
# assign principal global admin
|
||||
|
||||
# needs to call out to CLI with tenant owner username/password, prototyping for that underway
|
||||
|
||||
# return identifier and creds to consumer for storage
|
||||
response = {"clientId": "string", "secretKey": "string", "tenantId": "string"}
|
||||
return self._ok(
|
||||
{
|
||||
"client_id": response["clientId"],
|
||||
"secret_key": response["secret_key"],
|
||||
"tenant_id": response["tenantId"],
|
||||
}
|
||||
)
|
||||
|
||||
def force_tenant_admin_pw_update(self, creds, tenant_owner_id):
|
||||
# use creds to update to force password recovery?
|
||||
# not sure what the endpoint/method for this is, yet
|
||||
|
||||
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):
|
||||
# we really should be using graph.microsoft.com, but i'm getting
|
||||
# "expired token" errors for that
|
||||
# graph_resource = "https://graph.microsoft.com"
|
||||
graph_resource = "https://graph.windows.net"
|
||||
graph_creds = self._get_credential_obj(
|
||||
self._root_creds, resource=graph_resource
|
||||
)
|
||||
# I needed to set permissions for the graph.windows.net API before I
|
||||
# could get this to work.
|
||||
|
||||
# how do we scope the graph client to the new subscription rather than
|
||||
# the cloud0 subscription? tenant id seems to be separate from subscription id
|
||||
graph_client = self.sdk.graphrbac.GraphRbacManagementClient(
|
||||
graph_creds, self._root_creds.get("tenant_id")
|
||||
)
|
||||
|
||||
# do we need to create a new application to manage each subscripition
|
||||
# or should we manage access to each subscription from a single service
|
||||
# principal with multiple role assignments?
|
||||
app_display_name = "?" # name should reflect the subscription it exists
|
||||
app_create_param = self.sdk.graphrbac.models.ApplicationCreateParameters(
|
||||
display_name=app_display_name
|
||||
)
|
||||
|
||||
# we need the appropriate perms here:
|
||||
# https://docs.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-beta&tabs=http
|
||||
# https://docs.microsoft.com/en-us/graph/permissions-reference#microsoft-graph-permission-names
|
||||
# set app perms in app registration portal
|
||||
# https://docs.microsoft.com/en-us/graph/auth-v2-service#2-configure-permissions-for-microsoft-graph
|
||||
app: self.sdk.graphrbac.models.Application = graph_client.applications.create(
|
||||
app_create_param
|
||||
)
|
||||
|
||||
# create a new service principle for the new application, which should be scoped
|
||||
# to the new subscription
|
||||
app_id = app.app_id
|
||||
sp_create_params = self.sdk.graphrbac.models.ServicePrincipalCreateParameters(
|
||||
app_id=app_id, account_enabled=True
|
||||
)
|
||||
|
||||
service_principal = graph_client.service_principals.create(sp_create_params)
|
||||
|
||||
return service_principal
|
||||
|
||||
def _extract_subscription_id(self, subscription_url):
|
||||
sub_id_match = SUBSCRIPTION_ID_REGEX.match(subscription_url)
|
||||
|
||||
if sub_id_match:
|
||||
return sub_id_match.group(1)
|
||||
|
||||
def _get_sp_token(self, creds):
|
||||
home_tenant_id = creds.get("home_tenant_id")
|
||||
client_id = creds.get("client_id")
|
||||
secret_key = creds.get("secret_key")
|
||||
|
||||
# TODO: Make endpoints consts or configs
|
||||
authentication_endpoint = "https://login.microsoftonline.com/"
|
||||
resource = "https://management.azure.com/"
|
||||
|
||||
context = self.sdk.adal.AuthenticationContext(
|
||||
authentication_endpoint + home_tenant_id
|
||||
)
|
||||
|
||||
# TODO: handle failure states here
|
||||
token_response = context.acquire_token_with_client_credentials(
|
||||
resource, client_id, secret_key
|
||||
)
|
||||
|
||||
return token_response.get("accessToken", None)
|
||||
|
||||
def _get_credential_obj(self, creds, resource=None):
|
||||
return self.sdk.credentials.ServicePrincipalCredentials(
|
||||
client_id=creds.get("client_id"),
|
||||
secret=creds.get("secret_key"),
|
||||
tenant=creds.get("tenant_id"),
|
||||
resource=resource,
|
||||
cloud_environment=self.sdk.cloud,
|
||||
)
|
||||
|
||||
def _get_client_secret_credential_obj(self, creds):
|
||||
return self.sdk.identity.ClientSecretCredential(
|
||||
tenant_id=creds.get("tenant_id"),
|
||||
client_id=creds.get("client_id"),
|
||||
client_secret=creds.get("secret_key"),
|
||||
)
|
||||
|
||||
def _make_tenant_admin_cred_obj(self, username, password):
|
||||
return self.sdk.credentials.UserPassCredentials(username, password)
|
||||
|
||||
def _ok(self, body=None):
|
||||
return self._make_response("ok", body)
|
||||
|
||||
def _error(self, body=None):
|
||||
return self._make_response("error", body)
|
||||
|
||||
def _make_response(self, status, body=dict()):
|
||||
"""Create body for responses from API
|
||||
|
||||
Arguments:
|
||||
status {string} -- "ok" or "error"
|
||||
body {dict} -- dict containing details of response or error, if applicable
|
||||
|
||||
Returns:
|
||||
dict -- status of call with body containing details
|
||||
"""
|
||||
return {"status": status, "body": body}
|
||||
|
||||
@property
|
||||
def _root_creds(self):
|
||||
return {
|
||||
"client_id": self.client_id,
|
||||
"secret_key": self.secret_key,
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
126
atst/domain/csp/cloud/cloud_provider_interface.py
Normal file
126
atst/domain/csp/cloud/cloud_provider_interface.py
Normal file
@ -0,0 +1,126 @@
|
||||
from typing import Dict
|
||||
|
||||
from atst.models.user import User
|
||||
from atst.models.environment import Environment
|
||||
from atst.models.environment_role import EnvironmentRole
|
||||
|
||||
|
||||
class CloudProviderInterface:
|
||||
def set_secret(self, secret_key: str, secret_value: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_secret(self, secret_key: str):
|
||||
raise NotImplementedError()
|
||||
|
||||
def root_creds(self) -> Dict:
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_environment(
|
||||
self, auth_credentials: Dict, user: User, environment: Environment
|
||||
) -> str:
|
||||
"""Create a new environment in the CSP.
|
||||
|
||||
Arguments:
|
||||
auth_credentials -- Object containing CSP account credentials
|
||||
user -- ATAT user authorizing the environment creation
|
||||
environment -- ATAT Environment model
|
||||
|
||||
Returns:
|
||||
string: ID of created environment
|
||||
|
||||
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
|
||||
EnvironmentExistsException: Environment already exists and has been created
|
||||
"""
|
||||
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(
|
||||
self, auth_credentials: Dict, user_info: EnvironmentRole, csp_role_id: str
|
||||
) -> str:
|
||||
"""Creates a user or updates an existing user's role.
|
||||
|
||||
Arguments:
|
||||
auth_credentials -- Object containing CSP account credentials
|
||||
user_info -- instance of EnvironmentRole containing user data
|
||||
if it has a csp_user_id it will try to update that user
|
||||
csp_role_id -- The id of the role the user should be given in the CSP
|
||||
|
||||
Returns:
|
||||
string: Returns the interal csp_user_id of the created/updated user account
|
||||
|
||||
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: User couldn't be created or modified
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def disable_user(self, auth_credentials: Dict, csp_user_id: str) -> bool:
|
||||
"""Revoke all privileges for a user. Used to prevent user access while a full
|
||||
delete is being processed.
|
||||
|
||||
Arguments:
|
||||
auth_credentials -- Object containing CSP account credentials
|
||||
csp_user_id -- CSP internal user identifier
|
||||
|
||||
Returns:
|
||||
bool -- True on success
|
||||
|
||||
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
|
||||
UserRemovalException: User couldn't be suspended
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_calculator_url(self) -> str:
|
||||
"""Returns the calculator url for the CSP.
|
||||
This will likely be a static property elsewhere once a CSP is chosen.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_environment_login_url(self, environment) -> str:
|
||||
"""Returns the login url for a given environment
|
||||
This may move to be a computed property on the Environment domain object
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_subscription(self, environment):
|
||||
"""Returns True if a new subscription has been created or raises an
|
||||
exception if an error occurs while creating a subscription.
|
||||
"""
|
||||
raise NotImplementedError()
|
131
atst/domain/csp/cloud/exceptions.py
Normal file
131
atst/domain/csp/cloud/exceptions.py
Normal file
@ -0,0 +1,131 @@
|
||||
class GeneralCSPException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class OperationInProgressException(GeneralCSPException):
|
||||
"""Throw this for instances when the CSP reports that the current entity is already
|
||||
being operated on/created/deleted/etc
|
||||
"""
|
||||
|
||||
def __init__(self, operation_desc):
|
||||
self.operation_desc = operation_desc
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An operation for this entity is already in progress: {}".format(
|
||||
self.operation_desc
|
||||
)
|
||||
|
||||
|
||||
class AuthenticationException(GeneralCSPException):
|
||||
"""Throw this for instances when there is a problem with the auth credentials:
|
||||
* Missing credentials
|
||||
* Incorrect credentials
|
||||
* Other credential problems
|
||||
"""
|
||||
|
||||
def __init__(self, auth_error):
|
||||
self.auth_error = auth_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An error occurred with authentication: {}".format(self.auth_error)
|
||||
|
||||
|
||||
class AuthorizationException(GeneralCSPException):
|
||||
"""Throw this for instances when the current credentials are not authorized
|
||||
for the current action.
|
||||
"""
|
||||
|
||||
def __init__(self, auth_error):
|
||||
self.auth_error = auth_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "An error occurred with authorization: {}".format(self.auth_error)
|
||||
|
||||
|
||||
class ConnectionException(GeneralCSPException):
|
||||
"""A general problem with the connection, timeouts or unresolved endpoints
|
||||
"""
|
||||
|
||||
def __init__(self, connection_error):
|
||||
self.connection_error = connection_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Could not connect to cloud provider: {}".format(self.connection_error)
|
||||
|
||||
|
||||
class UnknownServerException(GeneralCSPException):
|
||||
"""An error occured on the CSP side (5xx) and we don't know why
|
||||
"""
|
||||
|
||||
def __init__(self, server_error):
|
||||
self.server_error = server_error
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "A server error occured: {}".format(self.server_error)
|
||||
|
||||
|
||||
class EnvironmentCreationException(GeneralCSPException):
|
||||
"""If there was an error in creating the environment
|
||||
"""
|
||||
|
||||
def __init__(self, env_identifier, reason):
|
||||
self.env_identifier = env_identifier
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "The envionment {} couldn't be created: {}".format(
|
||||
self.env_identifier, self.reason
|
||||
)
|
||||
|
||||
|
||||
class UserProvisioningException(GeneralCSPException):
|
||||
"""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):
|
||||
"""Failed to remove a user
|
||||
"""
|
||||
|
||||
def __init__(self, user_csp_id, reason):
|
||||
self.user_csp_id = user_csp_id
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Failed to suspend or delete user {}: {}".format(
|
||||
self.user_csp_id, self.reason
|
||||
)
|
||||
|
||||
|
||||
class BaselineProvisionException(GeneralCSPException):
|
||||
"""If there's any issues standing up whatever is required
|
||||
for an environment baseline
|
||||
"""
|
||||
|
||||
def __init__(self, env_identifier, reason):
|
||||
self.env_identifier = env_identifier
|
||||
self.reason = reason
|
||||
|
||||
@property
|
||||
def message(self):
|
||||
return "Could not complete baseline provisioning for environment ({}): {}".format(
|
||||
self.env_identifier, self.reason
|
||||
)
|
342
atst/domain/csp/cloud/mock_cloud_provider.py
Normal file
342
atst/domain/csp/cloud/mock_cloud_provider.py
Normal file
@ -0,0 +1,342 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from atst.domain.csp.cloud.exceptions import (
|
||||
BaselineProvisionException,
|
||||
EnvironmentCreationException,
|
||||
GeneralCSPException,
|
||||
UserProvisioningException,
|
||||
UserRemovalException,
|
||||
)
|
||||
from atst.domain.csp.cloud.models import BillingProfileTenantAccessCSPResult
|
||||
|
||||
from .cloud_provider_interface import CloudProviderInterface
|
||||
from .exceptions import (
|
||||
AuthenticationException,
|
||||
AuthorizationException,
|
||||
ConnectionException,
|
||||
UnknownServerException,
|
||||
)
|
||||
from .models import (
|
||||
BillingInstructionCSPPayload,
|
||||
BillingInstructionCSPResult,
|
||||
BillingProfileCreationCSPPayload,
|
||||
BillingProfileCreationCSPResult,
|
||||
BillingProfileVerificationCSPPayload,
|
||||
BillingProfileVerificationCSPResult,
|
||||
TaskOrderBillingCreationCSPPayload,
|
||||
TaskOrderBillingCreationCSPResult,
|
||||
TaskOrderBillingVerificationCSPPayload,
|
||||
TaskOrderBillingVerificationCSPResult,
|
||||
TenantCSPPayload,
|
||||
TenantCSPResult,
|
||||
)
|
||||
|
||||
|
||||
class MockCloudProvider(CloudProviderInterface):
|
||||
|
||||
# TODO: All of these constants
|
||||
AUTHENTICATION_EXCEPTION = AuthenticationException("Authentication failure.")
|
||||
AUTHORIZATION_EXCEPTION = AuthorizationException("Not authorized.")
|
||||
NETWORK_EXCEPTION = ConnectionException("Network failure.")
|
||||
SERVER_EXCEPTION = UnknownServerException("Not our fault.")
|
||||
|
||||
SERVER_FAILURE_PCT = 1
|
||||
NETWORK_FAILURE_PCT = 7
|
||||
ENV_CREATE_FAILURE_PCT = 12
|
||||
ATAT_ADMIN_CREATE_FAILURE_PCT = 12
|
||||
UNAUTHORIZED_RATE = 2
|
||||
|
||||
def __init__(
|
||||
self, config, with_delay=True, with_failure=True, with_authorization=True
|
||||
):
|
||||
from time import sleep
|
||||
import random
|
||||
|
||||
self._with_delay = with_delay
|
||||
self._with_failure = with_failure
|
||||
self._with_authorization = with_authorization
|
||||
self._sleep = sleep
|
||||
self._random = random
|
||||
|
||||
def root_creds(self):
|
||||
return self._auth_credentials
|
||||
|
||||
def set_secret(self, secret_key: str, secret_value: str):
|
||||
pass
|
||||
|
||||
def get_secret(self, secret_key: str, default=dict()):
|
||||
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_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):
|
||||
"""
|
||||
payload is an instance of TenantCSPPayload data class
|
||||
"""
|
||||
|
||||
self._authorize(payload.creds)
|
||||
|
||||
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.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
|
||||
return TenantCSPResult(
|
||||
**{
|
||||
"tenant_id": "",
|
||||
"user_id": "",
|
||||
"user_object_id": "",
|
||||
"tenant_admin_username": "test",
|
||||
"tenant_admin_password": "test",
|
||||
}
|
||||
)
|
||||
|
||||
def create_billing_profile_creation(
|
||||
self, payload: BillingProfileCreationCSPPayload
|
||||
):
|
||||
# response will be mostly the same as the body, but we only really care about the id
|
||||
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)
|
||||
|
||||
return BillingProfileCreationCSPResult(
|
||||
**dict(
|
||||
billing_profile_verify_url="https://zombo.com",
|
||||
billing_profile_retry_after=10,
|
||||
)
|
||||
)
|
||||
|
||||
def create_billing_profile_verification(
|
||||
self, payload: BillingProfileVerificationCSPPayload
|
||||
):
|
||||
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)
|
||||
return BillingProfileVerificationCSPResult(
|
||||
**{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
|
||||
"name": "KQWI-W2SU-BG7-TGB",
|
||||
"properties": {
|
||||
"address": {
|
||||
"addressLine1": "123 S Broad Street, Suite 2400",
|
||||
"city": "Philadelphia",
|
||||
"companyName": "Promptworks",
|
||||
"country": "US",
|
||||
"postalCode": "19109",
|
||||
"region": "PA",
|
||||
},
|
||||
"currency": "USD",
|
||||
"displayName": "Test Billing Profile",
|
||||
"enabledAzurePlans": [],
|
||||
"hasReadAccess": True,
|
||||
"invoiceDay": 5,
|
||||
"invoiceEmailOptIn": False,
|
||||
"invoiceSections": [
|
||||
{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/invoiceSections/CHCO-BAAR-PJA-TGB",
|
||||
"name": "CHCO-BAAR-PJA-TGB",
|
||||
"properties": {"displayName": "Test Billing Profile"},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles/invoiceSections",
|
||||
}
|
||||
],
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
|
||||
}
|
||||
)
|
||||
|
||||
def create_billing_profile_tenant_access(self, payload):
|
||||
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)
|
||||
|
||||
return BillingProfileTenantAccessCSPResult(
|
||||
**{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleAssignments/40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"name": "40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"properties": {
|
||||
"createdOn": "2020-01-14T14:39:26.3342192+00:00",
|
||||
"createdByPrincipalId": "82e2b376-3297-4096-8743-ed65b3be0b03",
|
||||
"principalId": "0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"principalTenantId": "60ff9d34-82bf-4f21-b565-308ef0533435",
|
||||
"roleDefinitionId": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000",
|
||||
"scope": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
|
||||
},
|
||||
"type": "Microsoft.Billing/billingRoleAssignments",
|
||||
}
|
||||
)
|
||||
|
||||
def create_task_order_billing_creation(
|
||||
self, payload: TaskOrderBillingCreationCSPPayload
|
||||
):
|
||||
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)
|
||||
|
||||
return TaskOrderBillingCreationCSPResult(
|
||||
**{"Location": "https://somelocation", "Retry-After": "10"}
|
||||
)
|
||||
|
||||
def create_task_order_billing_verification(
|
||||
self, payload: TaskOrderBillingVerificationCSPPayload
|
||||
):
|
||||
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)
|
||||
|
||||
return TaskOrderBillingVerificationCSPResult(
|
||||
**{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/XC36-GRNZ-BG7-TGB",
|
||||
"name": "XC36-GRNZ-BG7-TGB",
|
||||
"properties": {
|
||||
"address": {
|
||||
"addressLine1": "123 S Broad Street, Suite 2400",
|
||||
"city": "Philadelphia",
|
||||
"companyName": "Promptworks",
|
||||
"country": "US",
|
||||
"postalCode": "19109",
|
||||
"region": "PA",
|
||||
},
|
||||
"currency": "USD",
|
||||
"displayName": "First Portfolio Billing Profile",
|
||||
"enabledAzurePlans": [
|
||||
{
|
||||
"productId": "DZH318Z0BPS6",
|
||||
"skuId": "0001",
|
||||
"skuDescription": "Microsoft Azure Plan",
|
||||
}
|
||||
],
|
||||
"hasReadAccess": True,
|
||||
"invoiceDay": 5,
|
||||
"invoiceEmailOptIn": False,
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
|
||||
}
|
||||
)
|
||||
|
||||
def create_billing_instruction(self, payload: BillingInstructionCSPPayload):
|
||||
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)
|
||||
|
||||
return BillingInstructionCSPResult(
|
||||
**{
|
||||
"name": "TO1:CLIN001",
|
||||
"properties": {
|
||||
"amount": 1000.0,
|
||||
"endDate": "2020-03-01T00:00:00+00:00",
|
||||
"startDate": "2020-01-01T00:00:00+00:00",
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles/billingInstructions",
|
||||
}
|
||||
)
|
||||
|
||||
def create_or_update_user(self, auth_credentials, user_info, csp_role_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(
|
||||
user_info.environment.id,
|
||||
user_info.application_role.user_id,
|
||||
"Could not create user.",
|
||||
),
|
||||
)
|
||||
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, self.AUTHORIZATION_EXCEPTION)
|
||||
return self._id()
|
||||
|
||||
def disable_user(self, auth_credentials, csp_user_id):
|
||||
self._authorize(auth_credentials)
|
||||
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,
|
||||
UserRemovalException(csp_user_id, "Could not disable user."),
|
||||
)
|
||||
|
||||
return self._maybe(12)
|
||||
|
||||
def create_subscription(self, environment):
|
||||
self._maybe_raise(self.UNAUTHORIZED_RATE, GeneralCSPException)
|
||||
|
||||
return True
|
||||
|
||||
def get_calculator_url(self):
|
||||
return "https://www.rackspace.com/en-us/calculator"
|
||||
|
||||
def get_environment_login_url(self, environment):
|
||||
"""Returns the login url for a given environment
|
||||
"""
|
||||
return "https://www.mycloud.com/my-env-login"
|
||||
|
||||
def _id(self):
|
||||
return uuid4().hex
|
||||
|
||||
def _delay(self, min_secs, max_secs):
|
||||
if self._with_delay:
|
||||
duration = self._random.randrange(min_secs, max_secs)
|
||||
self._sleep(duration)
|
||||
|
||||
def _maybe(self, pct):
|
||||
return not self._with_failure or self._random.randrange(0, 100) < pct
|
||||
|
||||
def _maybe_raise(self, pct, exc):
|
||||
if self._with_failure and self._maybe(pct):
|
||||
raise exc
|
||||
|
||||
@property
|
||||
def _auth_credentials(self):
|
||||
return {"username": "mock-cloud", "password": "shh"} # pragma: allowlist secret
|
||||
|
||||
def _authorize(self, credentials):
|
||||
self._delay(1, 5)
|
||||
if self._with_authorization and credentials != self._auth_credentials:
|
||||
raise self.AUTHENTICATION_EXCEPTION
|
234
atst/domain/csp/cloud/models.py
Normal file
234
atst/domain/csp/cloud/models.py
Normal file
@ -0,0 +1,234 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from atst.utils import snake_to_camel
|
||||
|
||||
|
||||
class AliasModel(BaseModel):
|
||||
"""
|
||||
This provides automatic camel <-> snake conversion for serializing to/from json
|
||||
You can override the alias generation in subclasses by providing a Config that defines
|
||||
a fields property with a dict mapping variables to their cast names, for cases like:
|
||||
* some_url:someURL
|
||||
* user_object_id:objectId
|
||||
"""
|
||||
|
||||
class Config:
|
||||
alias_generator = snake_to_camel
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class BaseCSPPayload(AliasModel):
|
||||
# {"username": "mock-cloud", "pass": "shh"}
|
||||
creds: Dict
|
||||
|
||||
def dict(self, *args, **kwargs):
|
||||
exclude = {"creds"}
|
||||
if "exclude" not in kwargs:
|
||||
kwargs["exclude"] = exclude
|
||||
else:
|
||||
kwargs["exclude"].update(exclude)
|
||||
|
||||
return super().dict(*args, **kwargs)
|
||||
|
||||
|
||||
class TenantCSPPayload(BaseCSPPayload):
|
||||
user_id: str
|
||||
password: Optional[str]
|
||||
domain_name: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
country_code: str
|
||||
password_recovery_email_address: str
|
||||
|
||||
|
||||
class TenantCSPResult(AliasModel):
|
||||
user_id: str
|
||||
tenant_id: str
|
||||
user_object_id: str
|
||||
|
||||
tenant_admin_username: Optional[str]
|
||||
tenant_admin_password: Optional[str]
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"user_object_id": "objectId",
|
||||
}
|
||||
|
||||
def dict(self, *args, **kwargs):
|
||||
exclude = {"tenant_admin_username", "tenant_admin_password"}
|
||||
if "exclude" not in kwargs:
|
||||
kwargs["exclude"] = exclude
|
||||
else:
|
||||
kwargs["exclude"].update(exclude)
|
||||
|
||||
return super().dict(*args, **kwargs)
|
||||
|
||||
def get_creds(self):
|
||||
return {
|
||||
"tenant_admin_username": self.tenant_admin_username,
|
||||
"tenant_admin_password": self.tenant_admin_password,
|
||||
"tenant_id": self.tenant_id,
|
||||
}
|
||||
|
||||
|
||||
class BillingProfileAddress(AliasModel):
|
||||
company_name: str
|
||||
address_line_1: str
|
||||
city: str
|
||||
region: str
|
||||
country: str
|
||||
postal_code: str
|
||||
|
||||
|
||||
class BillingProfileCLINBudget(AliasModel):
|
||||
clin_budget: Dict
|
||||
"""
|
||||
"clinBudget": {
|
||||
"amount": 0,
|
||||
"startDate": "2019-12-18T16:47:40.909Z",
|
||||
"endDate": "2019-12-18T16:47:40.909Z",
|
||||
"externalReferenceId": "string"
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class BillingProfileCreationCSPPayload(BaseCSPPayload):
|
||||
tenant_id: str
|
||||
billing_profile_display_name: str
|
||||
billing_account_name: str
|
||||
enabled_azure_plans: Optional[List[str]]
|
||||
address: BillingProfileAddress
|
||||
|
||||
@validator("enabled_azure_plans", pre=True, always=True)
|
||||
def default_enabled_azure_plans(cls, v):
|
||||
"""
|
||||
Normally you'd implement this by setting the field with a value of:
|
||||
dataclasses.field(default_factory=list)
|
||||
but that prevents the object from being correctly pickled, so instead we need
|
||||
to rely on a validator to ensure this has an empty value when not specified
|
||||
"""
|
||||
return v or []
|
||||
|
||||
class Config:
|
||||
fields = {"billing_profile_display_name": "displayName"}
|
||||
|
||||
|
||||
class BillingProfileCreationCSPResult(AliasModel):
|
||||
billing_profile_verify_url: str
|
||||
billing_profile_retry_after: int
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"billing_profile_verify_url": "Location",
|
||||
"billing_profile_retry_after": "Retry-After",
|
||||
}
|
||||
|
||||
|
||||
class BillingProfileVerificationCSPPayload(BaseCSPPayload):
|
||||
billing_profile_verify_url: str
|
||||
|
||||
|
||||
class BillingInvoiceSection(AliasModel):
|
||||
invoice_section_id: str
|
||||
invoice_section_name: str
|
||||
|
||||
class Config:
|
||||
fields = {"invoice_section_id": "id", "invoice_section_name": "name"}
|
||||
|
||||
|
||||
class BillingProfileProperties(AliasModel):
|
||||
address: BillingProfileAddress
|
||||
billing_profile_display_name: str
|
||||
invoice_sections: List[BillingInvoiceSection]
|
||||
|
||||
class Config:
|
||||
fields = {"billing_profile_display_name": "displayName"}
|
||||
|
||||
|
||||
class BillingProfileVerificationCSPResult(AliasModel):
|
||||
billing_profile_id: str
|
||||
billing_profile_name: str
|
||||
billing_profile_properties: BillingProfileProperties
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"billing_profile_id": "id",
|
||||
"billing_profile_name": "name",
|
||||
"billing_profile_properties": "properties",
|
||||
}
|
||||
|
||||
|
||||
class BillingProfileTenantAccessCSPPayload(BaseCSPPayload):
|
||||
tenant_id: str
|
||||
user_object_id: str
|
||||
billing_account_name: str
|
||||
billing_profile_name: str
|
||||
|
||||
|
||||
class BillingProfileTenantAccessCSPResult(AliasModel):
|
||||
billing_role_assignment_id: str
|
||||
billing_role_assignment_name: str
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"billing_role_assignment_id": "id",
|
||||
"billing_role_assignment_name": "name",
|
||||
}
|
||||
|
||||
|
||||
class TaskOrderBillingCreationCSPPayload(BaseCSPPayload):
|
||||
billing_account_name: str
|
||||
billing_profile_name: str
|
||||
|
||||
|
||||
class TaskOrderBillingCreationCSPResult(AliasModel):
|
||||
task_order_billing_verify_url: str
|
||||
task_order_retry_after: int
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"task_order_billing_verify_url": "Location",
|
||||
"task_order_retry_after": "Retry-After",
|
||||
}
|
||||
|
||||
|
||||
class TaskOrderBillingVerificationCSPPayload(BaseCSPPayload):
|
||||
task_order_billing_verify_url: str
|
||||
|
||||
|
||||
class BillingProfileEnabledPlanDetails(AliasModel):
|
||||
enabled_azure_plans: List[Dict]
|
||||
|
||||
|
||||
class TaskOrderBillingVerificationCSPResult(AliasModel):
|
||||
billing_profile_id: str
|
||||
billing_profile_name: str
|
||||
billing_profile_enabled_plan_details: BillingProfileEnabledPlanDetails
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"billing_profile_id": "id",
|
||||
"billing_profile_name": "name",
|
||||
"billing_profile_enabled_plan_details": "properties",
|
||||
}
|
||||
|
||||
|
||||
class BillingInstructionCSPPayload(BaseCSPPayload):
|
||||
initial_clin_amount: float
|
||||
initial_clin_start_date: str
|
||||
initial_clin_end_date: str
|
||||
initial_clin_type: str
|
||||
initial_task_order_id: str
|
||||
billing_account_name: str
|
||||
billing_profile_name: str
|
||||
|
||||
|
||||
class BillingInstructionCSPResult(AliasModel):
|
||||
reported_clin_name: str
|
||||
|
||||
class Config:
|
||||
fields = {
|
||||
"reported_clin_name": "name",
|
||||
}
|
@ -9,10 +9,10 @@ from atst.models import (
|
||||
EnvironmentRole,
|
||||
PortfolioJobFailure,
|
||||
)
|
||||
from atst.domain.csp.cloud import CloudProviderInterface, GeneralCSPException
|
||||
from atst.domain.csp.cloud.exceptions import GeneralCSPException
|
||||
from atst.domain.csp.cloud import CloudProviderInterface
|
||||
from atst.domain.environments import Environments
|
||||
from atst.domain.portfolios import Portfolios
|
||||
|
||||
from atst.domain.environment_roles import EnvironmentRoles
|
||||
from atst.models.utils import claim_for_update
|
||||
from atst.utils.localization import translate
|
||||
|
@ -1,5 +1,7 @@
|
||||
from enum import Enum
|
||||
|
||||
from flask import current_app as app
|
||||
|
||||
|
||||
class StageStates(Enum):
|
||||
CREATED = "created"
|
||||
@ -9,8 +11,12 @@ class StageStates(Enum):
|
||||
|
||||
class AzureStages(Enum):
|
||||
TENANT = "tenant"
|
||||
BILLING_PROFILE = "billing profile"
|
||||
ADMIN_SUBSCRIPTION = "admin subscription"
|
||||
BILLING_PROFILE_CREATION = "billing profile creation"
|
||||
BILLING_PROFILE_VERIFICATION = "billing profile verification"
|
||||
BILLING_PROFILE_TENANT_ACCESS = "billing profile tenant access"
|
||||
TASK_ORDER_BILLING_CREATION = "task order billing creation"
|
||||
TASK_ORDER_BILLING_VERIFICATION = "task order billing verification"
|
||||
BILLING_INSTRUCTION = "billing instruction"
|
||||
|
||||
|
||||
def _build_csp_states(csp_stages):
|
||||
@ -31,14 +37,14 @@ def _build_csp_states(csp_stages):
|
||||
|
||||
FSMStates = Enum("FSMStates", _build_csp_states(AzureStages))
|
||||
|
||||
compose_state = lambda csp_stage, state: getattr(
|
||||
FSMStates, "_".join([csp_stage.name, state.name])
|
||||
)
|
||||
|
||||
|
||||
def _build_transitions(csp_stages):
|
||||
transitions = []
|
||||
states = []
|
||||
compose_state = lambda csp_stage, state: getattr(
|
||||
FSMStates, "_".join([csp_stage.name, state.name])
|
||||
)
|
||||
|
||||
for stage_i, csp_stage in enumerate(csp_stages):
|
||||
for state in StageStates:
|
||||
states.append(
|
||||
@ -99,6 +105,22 @@ class FSMMixin:
|
||||
{"trigger": "fail", "source": "*", "dest": FSMStates.FAILED,},
|
||||
]
|
||||
|
||||
def fail_stage(self, stage):
|
||||
fail_trigger = "fail" + stage
|
||||
if fail_trigger in self.machine.get_triggers(self.current_state.name):
|
||||
self.trigger(fail_trigger)
|
||||
app.logger.info(
|
||||
f"calling fail trigger '{fail_trigger}' for '{self.__repr__()}'"
|
||||
)
|
||||
|
||||
def finish_stage(self, stage):
|
||||
finish_trigger = "finish_" + stage
|
||||
if finish_trigger in self.machine.get_triggers(self.current_state.name):
|
||||
app.logger.info(
|
||||
f"calling finish trigger '{finish_trigger}' for '{self.__repr__()}'"
|
||||
)
|
||||
self.trigger(finish_trigger)
|
||||
|
||||
def prepare_init(self, event):
|
||||
pass
|
||||
|
||||
@ -125,13 +147,3 @@ class FSMMixin:
|
||||
|
||||
def after_reset(self, event):
|
||||
pass
|
||||
|
||||
def fail_stage(self, stage):
|
||||
fail_trigger = "fail" + stage
|
||||
if fail_trigger in self.machine.get_triggers(self.current_state.name):
|
||||
self.trigger(fail_trigger)
|
||||
|
||||
def finish_stage(self, stage):
|
||||
finish_trigger = "finish_" + stage
|
||||
if finish_trigger in self.machine.get_triggers(self.current_state.name):
|
||||
self.trigger(finish_trigger)
|
||||
|
@ -1,3 +1,5 @@
|
||||
import importlib
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum
|
||||
from sqlalchemy.orm import relationship, reconstructor
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
@ -8,8 +10,7 @@ from transitions.extensions.states import add_state_features, Tags
|
||||
|
||||
from flask import current_app as app
|
||||
|
||||
from atst.domain.csp.cloud import ConnectionException, UnknownServerException
|
||||
from atst.domain.csp import MockCSP, AzureCSP, get_stage_csp_class
|
||||
from atst.domain.csp.cloud.exceptions import ConnectionException, UnknownServerException
|
||||
from atst.database import db
|
||||
from atst.models.types import Id
|
||||
from atst.models.base import Base
|
||||
@ -17,6 +18,25 @@ import atst.models.mixins as mixins
|
||||
from atst.models.mixins.state_machines import FSMStates, AzureStages, _build_transitions
|
||||
|
||||
|
||||
def _stage_to_classname(stage):
|
||||
return "".join(map(lambda word: word.capitalize(), stage.split("_")))
|
||||
|
||||
|
||||
def get_stage_csp_class(stage, class_type):
|
||||
"""
|
||||
given a stage name and class_type return the class
|
||||
class_type is either 'payload' or 'result'
|
||||
|
||||
"""
|
||||
cls_name = f"{_stage_to_classname(stage)}CSP{class_type.capitalize()}"
|
||||
try:
|
||||
return getattr(
|
||||
importlib.import_module("atst.domain.csp.cloud.models"), cls_name
|
||||
)
|
||||
except AttributeError:
|
||||
print("could not import CSP Result class <%s>" % cls_name)
|
||||
|
||||
|
||||
@add_state_features(Tags)
|
||||
class StateMachineWithTags(Machine):
|
||||
pass
|
||||
@ -50,6 +70,9 @@ class PortfolioStateMachine(
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PortfolioStateMachine(state='{self.current_state.name}', portfolio='{self.portfolio.name}'"
|
||||
|
||||
@reconstructor
|
||||
def attach_machine(self):
|
||||
"""
|
||||
@ -73,109 +96,115 @@ class PortfolioStateMachine(
|
||||
return getattr(FSMStates, self.state)
|
||||
return self.state
|
||||
|
||||
def trigger_next_transition(self):
|
||||
def trigger_next_transition(self, **kwargs):
|
||||
state_obj = self.machine.get_state(self.state)
|
||||
|
||||
if state_obj.is_system:
|
||||
if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING):
|
||||
# call the first trigger availabe for these two system states
|
||||
trigger_name = self.machine.get_triggers(self.current_state.name)[0]
|
||||
self.trigger(trigger_name)
|
||||
self.trigger(trigger_name, **kwargs)
|
||||
|
||||
elif self.current_state == FSMStates.STARTED:
|
||||
# get the first trigger that starts with 'create_'
|
||||
create_trigger = list(
|
||||
create_trigger = next(
|
||||
filter(
|
||||
lambda trigger: trigger.startswith("create_"),
|
||||
self.machine.get_triggers(FSMStates.STARTED.name),
|
||||
),
|
||||
None,
|
||||
)
|
||||
if create_trigger:
|
||||
self.trigger(create_trigger, **kwargs)
|
||||
else:
|
||||
app.logger.info(
|
||||
f"could not locate 'create trigger' for {self.__repr__()}"
|
||||
)
|
||||
)[0]
|
||||
self.trigger(create_trigger)
|
||||
self.fail_stage(stage)
|
||||
|
||||
elif state_obj.is_IN_PROGRESS:
|
||||
pass
|
||||
elif state_obj.is_CREATED:
|
||||
# the create trigger for the next stage should be in the available
|
||||
# triggers for the current state
|
||||
create_trigger = next(
|
||||
filter(
|
||||
lambda trigger: trigger.startswith("create_"),
|
||||
self.machine.get_triggers(self.state.name),
|
||||
),
|
||||
None,
|
||||
)
|
||||
if create_trigger is not None:
|
||||
self.trigger(create_trigger, **kwargs)
|
||||
|
||||
# elif state_obj.is_TENANT:
|
||||
# pass
|
||||
# elif state_obj.is_BILLING_PROFILE:
|
||||
# pass
|
||||
|
||||
# @with_payload
|
||||
def after_in_progress_callback(self, event):
|
||||
stage = self.current_state.name.split("_IN_PROGRESS")[0].lower()
|
||||
if stage == "tenant":
|
||||
payload = dict( # nosec
|
||||
creds={"username": "mock-cloud", "pass": "shh"},
|
||||
user_id="123",
|
||||
password="123",
|
||||
domain_name="123",
|
||||
first_name="john",
|
||||
last_name="doe",
|
||||
country_code="US",
|
||||
password_recovery_email_address="password@email.com",
|
||||
)
|
||||
elif stage == "billing_profile":
|
||||
payload = dict(creds={"username": "mock-cloud", "pass": "shh"},)
|
||||
|
||||
# Accumulate payload w/ creds
|
||||
payload = event.kwargs.get("csp_data")
|
||||
payload["creds"] = event.kwargs.get("creds")
|
||||
|
||||
payload_data_cls = get_stage_csp_class(stage, "payload")
|
||||
if not payload_data_cls:
|
||||
app.logger.info(f"could not resolve payload data class for stage {stage}")
|
||||
self.fail_stage(stage)
|
||||
try:
|
||||
payload_data = payload_data_cls(**payload)
|
||||
except PydanticValidationError as exc:
|
||||
app.logger.error(
|
||||
f"Payload Validation Error in {self.__repr__()}:", exc_info=1
|
||||
)
|
||||
app.logger.info(exc.json())
|
||||
print(exc.json())
|
||||
app.logger.info(payload)
|
||||
self.fail_stage(stage)
|
||||
|
||||
csp = event.kwargs.get("csp")
|
||||
if csp is not None:
|
||||
self.csp = AzureCSP(app).cloud
|
||||
else:
|
||||
self.csp = MockCSP(app).cloud
|
||||
# TODO: Determine best place to do this, maybe @reconstructor
|
||||
self.csp = app.csp.cloud
|
||||
|
||||
for attempt in range(5):
|
||||
try:
|
||||
response = getattr(self.csp, "create_" + stage)(payload_data)
|
||||
except (ConnectionException, UnknownServerException) as exc:
|
||||
print("caught exception. retry", attempt)
|
||||
continue
|
||||
else:
|
||||
break
|
||||
else:
|
||||
# failed all attempts
|
||||
try:
|
||||
func_name = f"create_{stage}"
|
||||
response = getattr(self.csp, func_name)(payload_data)
|
||||
if self.portfolio.csp_data is None:
|
||||
self.portfolio.csp_data = {}
|
||||
self.portfolio.csp_data.update(response.dict())
|
||||
db.session.add(self.portfolio)
|
||||
db.session.commit()
|
||||
|
||||
if getattr(response, "get_creds", None) is not None:
|
||||
new_creds = response.get_creds()
|
||||
# TODO: one way salted hash of tenant_id to use as kv key name?
|
||||
tenant_id = new_creds.get("tenant_id")
|
||||
secret = self.csp.get_secret(tenant_id, new_creds)
|
||||
secret.update(new_creds)
|
||||
self.csp.set_secret(tenant_id, secret)
|
||||
except PydanticValidationError as exc:
|
||||
app.logger.error(
|
||||
f"Failed to cast response to valid result class {self.__repr__()}:",
|
||||
exc_info=1,
|
||||
)
|
||||
app.logger.info(exc.json())
|
||||
print(exc.json())
|
||||
app.logger.info(payload_data)
|
||||
self.fail_stage(stage)
|
||||
except (ConnectionException, UnknownServerException) as exc:
|
||||
app.logger.error(
|
||||
f"CSP api call. Caught exception for {self.__repr__()}.", exc_info=1,
|
||||
)
|
||||
self.fail_stage(stage)
|
||||
|
||||
if self.portfolio.csp_data is None:
|
||||
self.portfolio.csp_data = {}
|
||||
self.portfolio.csp_data[stage + "_data"] = response
|
||||
db.session.add(self.portfolio)
|
||||
db.session.commit()
|
||||
|
||||
self.finish_stage(stage)
|
||||
|
||||
def is_csp_data_valid(self, event):
|
||||
# check portfolio csp details json field for fields
|
||||
|
||||
"""
|
||||
This function guards advancing states from *_IN_PROGRESS to *_COMPLETED.
|
||||
"""
|
||||
if self.portfolio.csp_data is None or not isinstance(
|
||||
self.portfolio.csp_data, dict
|
||||
):
|
||||
return False
|
||||
|
||||
stage = self.current_state.name.split("_IN_PROGRESS")[0].lower()
|
||||
stage_data = self.portfolio.csp_data.get(stage + "_data")
|
||||
cls = get_stage_csp_class(stage, "result")
|
||||
if not cls:
|
||||
return False
|
||||
|
||||
try:
|
||||
cls(**stage_data)
|
||||
except PydanticValidationError as exc:
|
||||
print(exc.json())
|
||||
print("no csp data")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# print('failed condition', self.portfolio.csp_data)
|
||||
|
||||
@property
|
||||
def application_id(self):
|
||||
return None
|
||||
|
@ -13,7 +13,7 @@ from atst.domain.environments import Environments
|
||||
from atst.domain.applications import Applications
|
||||
from atst.domain.application_roles import ApplicationRoles
|
||||
from atst.domain.audit_log import AuditLog
|
||||
from atst.domain.csp.cloud import GeneralCSPException
|
||||
from atst.domain.csp.cloud.exceptions import GeneralCSPException
|
||||
from atst.domain.common import Paginator
|
||||
from atst.domain.environment_roles import EnvironmentRoles
|
||||
from atst.domain.invitations import ApplicationInvitations
|
||||
|
@ -25,6 +25,11 @@ def camel_to_snake(camel_cased):
|
||||
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
|
||||
|
||||
|
||||
def snake_to_camel(snake_cased):
|
||||
parts = snake_cased.split("_")
|
||||
return f"{parts[0]}{''.join([w.capitalize() for w in parts[1:]])}"
|
||||
|
||||
|
||||
def pick(keys, dct):
|
||||
_keys = set(keys)
|
||||
return {k: v for (k, v) in dct.items() if k in _keys}
|
||||
|
@ -8,7 +8,7 @@ run_python_lint() {
|
||||
}
|
||||
|
||||
run_python_typecheck() {
|
||||
run_command "mypy --ignore-missing-imports --follow-imports=skip atst/domain/csp/cloud.py"
|
||||
run_command "mypy --ignore-missing-imports --follow-imports=skip atst/domain/csp/cloud/__init__.py"
|
||||
return $?
|
||||
}
|
||||
|
||||
|
1
static/icons/clock.svg
Normal file
1
static/icons/clock.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="clock" class="svg-inline--fa fa-clock fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm61.8-104.4l-84.9-61.7c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v141.7l66.8 48.6c5.4 3.9 6.5 11.4 2.6 16.8L334.6 349c-3.9 5.3-11.4 6.5-16.8 2.6z"></path></svg>
|
After Width: | Height: | Size: 554 B |
1
static/icons/user.svg
Normal file
1
static/icons/user.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg height='100px' width='100px' fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" enable-background="new 0 0 16 16" x="0px" y="0px"><path d="M16 16h-2v-3.225l-3.919-.781c-.626-.125-1.081-.68-1.081-1.319v-1.433c0-.477.236-.921.631-1.187.288-.195 1.369-1.46 1.369-3.055 0-1.853-1.558-3-3-3-1.449 0-3 1.206-3 3 0 1.596 1.081 2.859 1.371 3.056.395.268.629.711.629 1.186v1.433c0 .64-.455 1.194-1.083 1.319l-3.916.783-.001 3.223h-2v-3.221c0-.951.677-1.776 1.609-1.963l3.391-.677v-.623c-.765-.677-2-2.38-2-4.516 0-3.088 2.595-5 5-5 2.757 0 5 2.243 5 5 0 2.134-1.234 3.837-2 4.516v.623l3.396.679c.929.187 1.604 1.01 1.604 1.957v3.225z"></path></svg>
|
After Width: | Height: | Size: 664 B |
@ -1,8 +1,6 @@
|
||||
.empty-state {
|
||||
padding: $gap * 3;
|
||||
max-width: 100%;
|
||||
max-width: $max-panel-width;
|
||||
background-color: $color-gray-lightest;
|
||||
margin-top: $gap * 5;
|
||||
|
||||
&--white {
|
||||
background-color: $color-white;
|
||||
@ -18,17 +16,28 @@
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1rem;
|
||||
padding: 3.2rem 2.4rem 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
padding: 0 $gap * 3;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-left: -$gap * 3;
|
||||
margin-right: -$gap * 3;
|
||||
margin: $gap * 4 0 0;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
text-align: center;
|
||||
background-color: $color-gray-lightest;
|
||||
padding: $gap * 3;
|
||||
|
||||
a.usa-button {
|
||||
width: 60%;
|
||||
display: inline-block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,9 +3,7 @@
|
||||
background-color: $color-white;
|
||||
border-top: 1px solid $color-gray-lightest;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
padding: $gap * 1.5;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
@ -13,8 +11,11 @@
|
||||
height: $footer-height;
|
||||
color: $color-gray-dark;
|
||||
font-size: 1.5rem;
|
||||
padding: 0 $gap * 1.5;
|
||||
|
||||
&__login {
|
||||
padding-left: 0.8rem;
|
||||
width: 100%;
|
||||
max-width: 1175px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
@ -22,15 +22,18 @@ body {
|
||||
padding-bottom: $footer-height * 2.5;
|
||||
|
||||
.global-panel-container {
|
||||
margin: $gap;
|
||||
flex-grow: 1;
|
||||
-ms-flex-negative: 1;
|
||||
top: $usa-banner-height + $topbar-height;
|
||||
position: relative;
|
||||
padding: 0 $large-spacing;
|
||||
|
||||
@include media($medium-screen) {
|
||||
margin: $gap * 2;
|
||||
top: $usa-banner-height + $topbar-height;
|
||||
}
|
||||
|
||||
.user-edit {
|
||||
max-width: $max-panel-width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,26 +3,34 @@
|
||||
@include grid-row;
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
margin-left: 2 * $gap;
|
||||
.portfolio-header-new .portfolio-header__name {
|
||||
padding: 1.6rem 0;
|
||||
}
|
||||
|
||||
.portfolio-header {
|
||||
flex-direction: column;
|
||||
margin: $gap * 2 0;
|
||||
max-width: $max-panel-width;
|
||||
|
||||
@include media($small-screen) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
margin-bottom: $gap * 1;
|
||||
|
||||
.col--grow {
|
||||
overflow: inherit;
|
||||
display: table;
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
&__name {
|
||||
@include h1;
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
|
||||
h1 {
|
||||
margin: 0 $gap ($gap * 2) 0;
|
||||
margin: 0;
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
@ -30,6 +38,7 @@
|
||||
font-size: $small-font-size;
|
||||
margin: 0 0 (-$gap * 0.5);
|
||||
color: $color-gray-medium;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,9 +47,15 @@
|
||||
font-size: $small-font-size;
|
||||
|
||||
.icon-link {
|
||||
padding: $gap;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
color: $color-blue-darkest;
|
||||
min-width: 10rem;
|
||||
min-height: 10rem;
|
||||
|
||||
.col {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $color-aqua-lightest;
|
||||
@ -53,6 +68,7 @@
|
||||
&.active {
|
||||
color: $color-blue;
|
||||
background-color: $color-gray-lightest;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-aqua-lightest;
|
||||
@ -82,11 +98,19 @@
|
||||
margin-bottom: 3 * $gap;
|
||||
}
|
||||
|
||||
.portfolio-content {
|
||||
margin: (4 * $gap) $gap 0 $gap;
|
||||
.portfolio-admin {
|
||||
margin: $large-spacing 0;
|
||||
max-width: $max-panel-width;
|
||||
}
|
||||
|
||||
.portfolio-content {
|
||||
.panel {
|
||||
padding-bottom: 2rem;
|
||||
max-width: $max-panel-width;
|
||||
}
|
||||
|
||||
hr {
|
||||
max-width: $max-panel-width;
|
||||
}
|
||||
|
||||
a.add-new-button {
|
||||
@ -251,6 +275,7 @@
|
||||
|
||||
.portfolio-applications {
|
||||
margin-top: $gap * 5;
|
||||
max-width: $max-panel-width;
|
||||
|
||||
&__header {
|
||||
&--title {
|
||||
@ -296,8 +321,8 @@
|
||||
}
|
||||
|
||||
.portfolio-funding {
|
||||
padding: 2 * $gap;
|
||||
padding-top: 0;
|
||||
max-width: $max-panel-width;
|
||||
margin: $large-spacing 0;
|
||||
|
||||
.panel {
|
||||
@include shadow-panel;
|
||||
@ -366,6 +391,8 @@
|
||||
}
|
||||
|
||||
.portfolio-reports {
|
||||
max-width: $max-panel-width;
|
||||
|
||||
&__header {
|
||||
margin-bottom: 4 * $gap;
|
||||
|
||||
|
@ -20,12 +20,10 @@
|
||||
.sticky-cta-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 90rem;
|
||||
|
||||
.usa-button {
|
||||
margin: $gap $gap * 1.5 $gap 0;
|
||||
width: 20rem;
|
||||
height: 3.2rem;
|
||||
font-size: $small-font-size;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +40,10 @@
|
||||
&-buttons {
|
||||
display: flex;
|
||||
|
||||
a {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
margin: 0;
|
||||
|
||||
|
@ -4,14 +4,15 @@
|
||||
height: $topbar-height;
|
||||
position: fixed;
|
||||
top: $usa-banner-height;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
|
||||
&__navigation {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: space-between;
|
||||
max-width: 1190px;
|
||||
|
||||
a {
|
||||
color: $color-white;
|
||||
@ -64,3 +65,11 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.login-topbar .topbar__navigation {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.login-topbar .topbar__context .topbar__link-icon {
|
||||
margin: 0 0 0 0.8rem;
|
||||
}
|
||||
|
@ -41,7 +41,6 @@
|
||||
|
||||
&.col--grow {
|
||||
flex: 1 auto;
|
||||
padding-right: $spacing-small;
|
||||
}
|
||||
|
||||
&.col--half {
|
||||
|
@ -94,3 +94,7 @@ hr {
|
||||
margin: ($gap * 3) ($site-margins * -4);
|
||||
}
|
||||
}
|
||||
|
||||
.usa-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -16,8 +16,9 @@ $footer-height: 5rem;
|
||||
$usa-banner-height: 2.8rem;
|
||||
$sidenav-expanded-width: 25rem;
|
||||
$sidenav-collapsed-width: 10rem;
|
||||
$max-panel-width: 80rem;
|
||||
$max-panel-width: 90rem;
|
||||
$home-pg-icon-width: 6rem;
|
||||
$large-spacing: 4rem;
|
||||
|
||||
/*
|
||||
* USWDS Variables
|
||||
@ -189,4 +190,4 @@ $spacing-x-small: 0.5rem;
|
||||
$spacing-small: 1rem;
|
||||
$spacing-md-small: 1.5rem;
|
||||
$spacing-medium: 2rem;
|
||||
$spacing-large: 3rem;
|
||||
$spacing-large: 4rem;
|
||||
|
@ -21,7 +21,7 @@
|
||||
text-transform: uppercase;
|
||||
|
||||
&--default {
|
||||
background-color: $color-gray-dark;
|
||||
background-color: $color-gray;
|
||||
}
|
||||
|
||||
&--info {
|
||||
|
@ -19,10 +19,7 @@
|
||||
}
|
||||
|
||||
@mixin panel-margin {
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: $site-margins-mobile * 6;
|
||||
margin: $spacing-large 0;
|
||||
|
||||
@include media($medium-screen) {
|
||||
margin-bottom: $site-margins * 8;
|
||||
@ -56,9 +53,10 @@
|
||||
@include panel-theme-default;
|
||||
@include panel-margin;
|
||||
@include shadow-panel;
|
||||
max-width: $max-panel-width;
|
||||
|
||||
&__content {
|
||||
padding: $gap * 2;
|
||||
padding: 3.2rem 2.4rem;
|
||||
}
|
||||
|
||||
&__body {
|
||||
@ -66,7 +64,7 @@
|
||||
}
|
||||
|
||||
&__heading {
|
||||
padding: $gap * 2;
|
||||
padding: 3.2rem 2.4rem;
|
||||
|
||||
@include media($medium-screen) {
|
||||
padding: $gap * 4;
|
||||
|
@ -113,8 +113,8 @@
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&--active {
|
||||
@include h4;
|
||||
|
||||
font-size: $base-font-size;
|
||||
font-weight: $font-bold;
|
||||
background-color: $color-aqua-lightest !important;
|
||||
color: $color-primary-darker !important;
|
||||
box-shadow: inset ($gap / 2) 0 0 0 $color-primary-darker;
|
||||
|
@ -1,12 +1,11 @@
|
||||
.home {
|
||||
margin: $gap * 3;
|
||||
.sticky-cta {
|
||||
margin: -1.6rem -1.6rem 0 -1.6rem;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin: 4rem;
|
||||
max-width: 900px;
|
||||
margin: $large-spacing 0;
|
||||
max-width: $max-panel-width;
|
||||
|
||||
&--descriptions {
|
||||
.col {
|
||||
@ -29,7 +28,7 @@
|
||||
background-color: $color-white;
|
||||
|
||||
.home-container {
|
||||
max-width: 90rem;
|
||||
max-width: $max-panel-width;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 8rem;
|
||||
|
@ -1,3 +1,4 @@
|
||||
{% from "components/alert.html" import Alert %}
|
||||
{% from "components/icon.html" import Icon %}
|
||||
{% from "components/label.html" import Label %}
|
||||
{% from 'components/save_button.html' import SaveButton %}
|
||||
@ -10,10 +11,13 @@
|
||||
new_env_form) %}
|
||||
|
||||
<h3>{{ "portfolios.applications.settings.environments" | translate }}</h3>
|
||||
{% if portfolio.num_task_orders == 0 -%}
|
||||
{{ Alert(message="portfolios.applications.environments.funding_alert"|translate({'name': portfolio.name})) }}
|
||||
{%- endif %}
|
||||
{% if g.matchesPath("application-environments") -%}
|
||||
{% include "fragments/flash.html" %}
|
||||
{%- endif %}
|
||||
<section class="panel" id="application-environments">
|
||||
{% if g.matchesPath("application-environments") -%}
|
||||
{% include "fragments/flash.html" %}
|
||||
{%- endif %}
|
||||
{% if 0 == environments_obj | length -%}
|
||||
<div class="empty-state panel__content">
|
||||
<p class="empty-state__message">
|
||||
@ -30,14 +34,21 @@
|
||||
<li class="accordion-table__item">
|
||||
<div class="accordion-table__item-content">
|
||||
<div class="environment-list__item">
|
||||
<span>
|
||||
<a
|
||||
href='{{ url_for("applications.access_environment", environment_id=env.id)}}'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'>
|
||||
{{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }}
|
||||
</a>
|
||||
</span>
|
||||
{% if not env["pending"] -%}
|
||||
<span>
|
||||
<a
|
||||
href='{{ url_for("applications.access_environment", environment_id=env.id)}}'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'>
|
||||
{{ env['name'] }} {{ Icon('link', classes='icon--medium icon--primary') }}
|
||||
</a>
|
||||
</span>
|
||||
{% else -%}
|
||||
<span>
|
||||
{{ env['name'] }}
|
||||
</span>
|
||||
{{ Label(type="pending_creation", classes='label--below')}}
|
||||
{%- endif %}
|
||||
{% if user_can(permissions.EDIT_ENVIRONMENT) -%}
|
||||
{{
|
||||
ToggleButton(
|
||||
@ -57,10 +68,6 @@
|
||||
classes="environment-list__item__members"
|
||||
)
|
||||
}}
|
||||
<br>
|
||||
{% if env['pending'] -%}
|
||||
{{ Label(type="changes_pending", classes='label--below')}}
|
||||
{%- endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -24,11 +24,8 @@
|
||||
{% if not portfolio.applications %}
|
||||
|
||||
{{ EmptyState(
|
||||
header="portfolios.applications.empty_state.header"|translate,
|
||||
message="portfolios.applications.empty_state.message"|translate,
|
||||
button_text="portfolios.applications.empty_state.button_text"|translate,
|
||||
resource='applications',
|
||||
button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id),
|
||||
view_only_text="portfolios.applications.empty_state.view_only_text"|translate,
|
||||
user_can_create=can_create_applications,
|
||||
) }}
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
||||
<div id='app-root'>
|
||||
{% include 'components/usa_header.html' %}
|
||||
|
||||
{% include 'navigation/topbar.html' %}
|
||||
<div class='login-topbar'>{% include 'navigation/topbar.html' %}</div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
|
@ -1,14 +1,22 @@
|
||||
{% macro EmptyState(header, message, button_text, button_link, view_only_text, user_can_create=True) %}
|
||||
{% macro EmptyState(resource, button_link, user_can_create=False) %}
|
||||
{% if user_can_create %}
|
||||
{% set perms = 'edit' %}
|
||||
{% else %}
|
||||
{% set perms = 'view' %}
|
||||
{% endif %}
|
||||
|
||||
{% set header = "empty_state.{}.header.{}".format(resource, perms) | translate | safe %}
|
||||
{% set message = "empty_state.{}.message.{}".format(resource, perms) | translate | safe %}
|
||||
{% set button_text = "empty_state.{}.button_text".format(resource) | translate | safe %}
|
||||
|
||||
<div class="empty-state">
|
||||
<h3>{{ header }}</h3>
|
||||
<p>{{ message }}</p>
|
||||
<hr>
|
||||
<div class="empty-state__footer">
|
||||
{% if user_can_create %}
|
||||
{% if user_can_create -%}
|
||||
<hr>
|
||||
<div class="empty-state__footer">
|
||||
<a href="{{ button_link }}" class="usa-button usa-button-primary">{{ button_text }}</a>
|
||||
{% else %}
|
||||
<p>{{ view_only_text }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
@ -9,6 +9,11 @@
|
||||
"text": "changes pending",
|
||||
"color": "default",
|
||||
},
|
||||
"pending_creation": {
|
||||
"icon": "clock",
|
||||
"text": "pending creation",
|
||||
"color": "default",
|
||||
},
|
||||
"ppoc": {"text": "primary point of contact"}
|
||||
} %}
|
||||
|
||||
|
@ -11,7 +11,7 @@
|
||||
<div class="topbar__context">
|
||||
{% if g.current_user %}
|
||||
<a href="{{ url_for('users.user') }}" class="topbar__link">
|
||||
{{ Icon('avatar', classes='topbar__link-icon') }}
|
||||
{{ Icon('user', classes='topbar__link-icon') }}
|
||||
<span class="topbar__link-label">{{ g.current_user.first_name + " " + g.current_user.last_name }}</span>
|
||||
</a>
|
||||
<a href="#" class="topbar__link">
|
||||
|
@ -22,7 +22,7 @@
|
||||
{{ TextInput(portfolio_form.name, validation="portfolioName", optional=False) }}
|
||||
{{ TextInput(portfolio_form.description, validation="defaultTextAreaField", paragraph=True) }}
|
||||
<div class='edit-portfolio-name action-group'>
|
||||
{{ SaveButton(text='Save Changes', additional_classes='usa-button-big') }}
|
||||
{{ SaveButton(text='Save Changes') }}
|
||||
</div>
|
||||
</form>
|
||||
</base-form>
|
||||
|
@ -10,10 +10,11 @@
|
||||
|
||||
<main class="usa-section usa-content">
|
||||
{% include "fragments/flash.html" %}
|
||||
<div class='portfolio-header__name'>
|
||||
<p>{{ "portfolios.header" | translate }}</p>
|
||||
<h1>{{ "portfolios.new.title" | translate }}</h1>
|
||||
</div>
|
||||
<div class="portfolio-header-new">
|
||||
<div class='portfolio-header__name'>
|
||||
<p>{{ "portfolios.header" | translate }}</p>
|
||||
<h1>{{ 'portfolios.new.title' | translate }}</h1>
|
||||
</div>
|
||||
{{ StickyCTA(text="portfolios.new.cta_step_1"|translate, context=("portfolios.new.sticky_header_context"|translate({"step": "1"}) )) }}
|
||||
<base-form inline-template>
|
||||
<div class="row">
|
||||
|
@ -6,17 +6,10 @@
|
||||
{% if not portfolio.applications %}
|
||||
|
||||
{% set can_create_applications = user_can(permissions.CREATE_APPLICATION) %}
|
||||
{% set message = ('portfolios.reports.empty_state.sub_message.can_create_applications' | translate)
|
||||
if can_create_applications
|
||||
else ('portfolios.reports.empty_state.sub_message.cannot_create_applications' | translate)
|
||||
%}
|
||||
|
||||
{{ EmptyState(
|
||||
header='portfolios.reports.empty_state.message' | translate,
|
||||
message=message,
|
||||
button_text="portfolios.applications.empty_state.button_text"|translate,
|
||||
resource='applications_reporting',
|
||||
button_link=url_for("applications.view_new_application_step_1", portfolio_id=portfolio.id),
|
||||
view_only_text="portfolios.applications.empty_state.view_only_text"|translate,
|
||||
user_can_create=can_create_applications,
|
||||
) }}
|
||||
|
||||
|
@ -85,11 +85,8 @@
|
||||
{% endcall %}
|
||||
{% else %}
|
||||
{{ EmptyState(
|
||||
header="task_orders.empty_state.header"|translate,
|
||||
message="task_orders.empty_state.message"|translate,
|
||||
resource="task_orders",
|
||||
button_link=url_for('task_orders.form_step_one_add_pdf', portfolio_id=portfolio.id),
|
||||
button_text="task_orders.empty_state.button_text"|translate,
|
||||
view_only_text="task_orders.empty_state.view_only_text"|translate,
|
||||
user_can_create=user_can(permissions.CREATE_TASK_ORDER),
|
||||
) }}
|
||||
{% endif %}
|
||||
|
@ -1,7 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class='col'>
|
||||
<div class='col user-edit'>
|
||||
|
||||
{% include "fragments/flash.html" %}
|
||||
|
||||
|
@ -281,3 +281,4 @@ secrets-tool secrets --keyvault https://ops-jedidev-keyvault.vault.azure.net/ cr
|
||||
|
||||
`terraform apply`
|
||||
|
||||
*[Configure AD for MFA](https://docs.microsoft.com/en-us/azure/vpn-gateway/openvpn-azure-ad-mfa)*
|
@ -11,6 +11,20 @@ resource "azurerm_storage_account" "bucket" {
|
||||
account_replication_type = "LRS"
|
||||
}
|
||||
|
||||
resource "azurerm_storage_account_network_rules" "acls" {
|
||||
resource_group_name = azurerm_resource_group.bucket.name
|
||||
storage_account_name = azurerm_storage_account.bucket.name
|
||||
|
||||
default_action = var.policy
|
||||
|
||||
# Azure Storage CIDR ACLs do not accept /32 CIDR ranges.
|
||||
ip_rules = [
|
||||
for cidr in values(var.whitelist) : cidr
|
||||
]
|
||||
virtual_network_subnet_ids = var.subnet_ids
|
||||
bypass = ["AzureServices"]
|
||||
}
|
||||
|
||||
resource "azurerm_storage_container" "bucket" {
|
||||
name = "content"
|
||||
storage_account_name = azurerm_storage_account.bucket.name
|
||||
|
@ -29,3 +29,20 @@ variable "service_name" {
|
||||
description = "Name of the service using this bucket"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "List of subnet_ids that will have access to this service"
|
||||
type = list
|
||||
}
|
||||
|
||||
variable "policy" {
|
||||
description = "The default policy for the network access rules (Allow/Deny)"
|
||||
default = "Deny"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "whitelist" {
|
||||
type = map
|
||||
description = "A map of whitelisted IPs and CIDR ranges. For single IPs, Azure expects just the IP, NOT a /32."
|
||||
default = {}
|
||||
}
|
||||
|
@ -1,3 +1,7 @@
|
||||
locals {
|
||||
whitelist = values(var.whitelist)
|
||||
}
|
||||
|
||||
resource "azurerm_resource_group" "acr" {
|
||||
name = "${var.name}-${var.environment}-acr"
|
||||
location = var.region
|
||||
@ -10,4 +14,30 @@ resource "azurerm_container_registry" "acr" {
|
||||
sku = var.sku
|
||||
admin_enabled = var.admin_enabled
|
||||
#georeplication_locations = [azurerm_resource_group.acr.location, var.backup_region]
|
||||
|
||||
network_rule_set {
|
||||
default_action = var.policy
|
||||
|
||||
ip_rule = [
|
||||
for cidr in values(var.whitelist) : {
|
||||
action = "Allow"
|
||||
ip_range = cidr
|
||||
}
|
||||
]
|
||||
# Dynamic rule should work, but doesn't - See https://github.com/hashicorp/terraform/issues/22340#issuecomment-518779733
|
||||
#dynamic "ip_rule" {
|
||||
# for_each = values(var.whitelist)
|
||||
# content {
|
||||
# action = "Allow"
|
||||
# ip_range = ip_rule.value
|
||||
# }
|
||||
#}
|
||||
|
||||
virtual_network = [
|
||||
for subnet in var.subnet_ids : {
|
||||
action = "Allow"
|
||||
subnet_id = subnet.value
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -35,3 +35,20 @@ variable "admin_enabled" {
|
||||
default = false
|
||||
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "List of subnet_ids that will have access to this service"
|
||||
type = list
|
||||
}
|
||||
|
||||
variable "policy" {
|
||||
description = "The default policy for the network access rules (Allow/Deny)"
|
||||
default = "Deny"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "whitelist" {
|
||||
type = map
|
||||
description = "A map of whitelisted IPs and CIDR ranges. For single IPs, Azure expects just the IP, NOT a /32."
|
||||
default = {}
|
||||
}
|
||||
|
@ -13,6 +13,13 @@ resource "azurerm_key_vault" "keyvault" {
|
||||
|
||||
sku_name = "premium"
|
||||
|
||||
network_acls {
|
||||
default_action = var.policy
|
||||
bypass = "AzureServices"
|
||||
virtual_network_subnet_ids = var.subnet_ids
|
||||
ip_rules = values(var.whitelist)
|
||||
}
|
||||
|
||||
tags = {
|
||||
environment = var.environment
|
||||
owner = var.owner
|
||||
|
@ -32,3 +32,20 @@ variable "admin_principals" {
|
||||
type = map
|
||||
description = "A list of user principals who need access to manage the keyvault"
|
||||
}
|
||||
|
||||
variable "subnet_ids" {
|
||||
description = "List of subnet_ids that will have access to this service"
|
||||
type = list
|
||||
}
|
||||
|
||||
variable "policy" {
|
||||
description = "The default policy for the network access rules (Allow/Deny)"
|
||||
default = "Deny"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "whitelist" {
|
||||
type = map
|
||||
description = "A map of whitelisted IPs and CIDR ranges. For single IPs, Azure expects just the IP, NOT a /32."
|
||||
default = {}
|
||||
}
|
@ -37,9 +37,9 @@ resource "azurerm_postgresql_virtual_network_rule" "sql" {
|
||||
}
|
||||
|
||||
resource "azurerm_postgresql_database" "db" {
|
||||
name = "${var.environment}-atat"
|
||||
name = "${var.name}-${var.environment}-atat"
|
||||
resource_group_name = azurerm_resource_group.sql.name
|
||||
server_name = azurerm_postgresql_server.sql.name
|
||||
charset = "UTF8"
|
||||
collation = "en_US.utf8"
|
||||
collation = "en-US"
|
||||
}
|
||||
|
@ -93,4 +93,3 @@ variable "ssl_enforcement" {
|
||||
description = "Enforce SSL (Enabled/Disable)"
|
||||
default = "Enabled"
|
||||
}
|
||||
|
||||
|
@ -13,6 +13,7 @@ resource "azurerm_redis_cache" "redis" {
|
||||
sku_name = var.sku_name
|
||||
enable_non_ssl_port = var.enable_non_ssl_port
|
||||
minimum_tls_version = var.minimum_tls_version
|
||||
subnet_id = var.subnet_id
|
||||
|
||||
redis_configuration {
|
||||
enable_authentication = var.enable_authentication
|
||||
|
@ -22,35 +22,30 @@ variable "capacity" {
|
||||
type = string
|
||||
default = 2
|
||||
description = "The capacity of the redis cache"
|
||||
|
||||
}
|
||||
|
||||
variable "family" {
|
||||
type = string
|
||||
default = "C"
|
||||
description = "The subscription family for redis"
|
||||
|
||||
}
|
||||
|
||||
variable "sku_name" {
|
||||
type = string
|
||||
default = "Standard"
|
||||
description = "The sku to use"
|
||||
|
||||
}
|
||||
|
||||
variable "enable_non_ssl_port" {
|
||||
type = bool
|
||||
default = false
|
||||
description = "Enable non TLS port (default: false)"
|
||||
|
||||
}
|
||||
|
||||
variable "minimum_tls_version" {
|
||||
type = string
|
||||
default = "1.2"
|
||||
description = "Minimum TLS version to use"
|
||||
|
||||
}
|
||||
|
||||
variable "enable_authentication" {
|
||||
@ -58,3 +53,8 @@ variable "enable_authentication" {
|
||||
default = true
|
||||
description = "Enable or disable authentication (default: true)"
|
||||
}
|
||||
|
||||
variable "subnet_id" {
|
||||
type = string
|
||||
description = "Subnet ID that the service_endpoint should reside"
|
||||
}
|
||||
|
@ -39,6 +39,8 @@ resource "azurerm_subnet" "subnet" {
|
||||
lifecycle {
|
||||
ignore_changes = [route_table_id]
|
||||
}
|
||||
|
||||
service_endpoints = split(",", var.service_endpoints[each.key])
|
||||
#delegation {
|
||||
# name = "acctestdelegation"
|
||||
#
|
||||
@ -108,7 +110,7 @@ resource "azurerm_virtual_network_gateway" "vnet_gateway" {
|
||||
}
|
||||
|
||||
vpn_client_configuration {
|
||||
address_space = ["172.16.1.0/24"]
|
||||
address_space = var.vpn_client_cidr
|
||||
vpn_client_protocols = ["OpenVPN"]
|
||||
}
|
||||
}
|
@ -1,3 +1,9 @@
|
||||
output "subnets" {
|
||||
value = azurerm_subnet.subnet["private"].id #FIXME - output should be a map
|
||||
value = azurerm_subnet.subnet["private"].id #FIXED: this is now legacy, use subnet_list
|
||||
}
|
||||
|
||||
output "subnet_list" {
|
||||
value = {
|
||||
for k, id in azurerm_subnet.subnet : k => id
|
||||
}
|
||||
}
|
@ -46,3 +46,15 @@ variable "gateway_subnet" {
|
||||
type = string
|
||||
description = "The Subnet CIDR that we'll use for the virtual_network_gateway 'GatewaySubnet'"
|
||||
}
|
||||
|
||||
variable "service_endpoints" {
|
||||
type = map
|
||||
description = "A map of the service endpoints and its mapping to subnets"
|
||||
|
||||
}
|
||||
|
||||
variable "vpn_client_cidr" {
|
||||
type = list
|
||||
description = "The CIDR range used for clients on the VPN"
|
||||
default = ["172.16.0.0/16"]
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
# Task order bucket is required to be accessible publicly by the users.
|
||||
# which is why the policy here is "Allow"
|
||||
module "task_order_bucket" {
|
||||
source = "../../modules/bucket"
|
||||
service_name = "jeditasksatat"
|
||||
@ -5,8 +7,15 @@ module "task_order_bucket" {
|
||||
name = var.name
|
||||
environment = var.environment
|
||||
region = var.region
|
||||
policy = "Allow"
|
||||
subnet_ids = [module.vpc.subnets]
|
||||
whitelist = var.storage_admin_whitelist
|
||||
}
|
||||
|
||||
# TF State should be restricted to admins only, but IP protected
|
||||
# This has to be public due to a chicken/egg issue of VPN not
|
||||
# existing until TF is run. If this bucket is private, you would
|
||||
# not be able to access it when running TF without being on a VPN.
|
||||
module "tf_state" {
|
||||
source = "../../modules/bucket"
|
||||
service_name = "jedidevtfstate"
|
||||
@ -14,4 +23,7 @@ module "tf_state" {
|
||||
name = var.name
|
||||
environment = var.environment
|
||||
region = var.region
|
||||
policy = "Deny"
|
||||
subnet_ids = []
|
||||
whitelist = var.storage_admin_whitelist
|
||||
}
|
||||
|
@ -5,4 +5,7 @@ module "container_registry" {
|
||||
environment = var.environment
|
||||
owner = var.owner
|
||||
backup_region = var.backup_region
|
||||
policy = "Deny"
|
||||
subnet_ids = []
|
||||
whitelist = var.admin_user_whitelist
|
||||
}
|
||||
|
@ -7,5 +7,8 @@ module "keyvault" {
|
||||
tenant_id = var.tenant_id
|
||||
principal_id = "f9bcbe58-8b73-4957-aee2-133dc3e58063"
|
||||
admin_principals = var.admin_users
|
||||
policy = "Deny"
|
||||
subnet_ids = [module.vpc.subnets]
|
||||
whitelist = var.admin_user_whitelist
|
||||
}
|
||||
|
||||
|
@ -4,4 +4,7 @@ module "redis" {
|
||||
environment = var.environment
|
||||
region = var.region
|
||||
name = var.name
|
||||
subnet_id = module.vpc.subnet_list["redis"].id
|
||||
sku_name = "Premium"
|
||||
family = "P"
|
||||
}
|
||||
|
@ -7,4 +7,7 @@ module "operator_keyvault" {
|
||||
tenant_id = var.tenant_id
|
||||
principal_id = ""
|
||||
admin_principals = var.admin_users
|
||||
policy = "Deny"
|
||||
subnet_ids = [module.vpc.subnets]
|
||||
whitelist = var.admin_user_whitelist
|
||||
}
|
||||
|
@ -32,7 +32,17 @@ variable "networks" {
|
||||
#format
|
||||
#name = "CIDR, route table, Security Group Name"
|
||||
public = "10.1.1.0/24,public" # LBs
|
||||
private = "10.1.2.0/24,private" # k8s, postgres, redis, dns, ad
|
||||
private = "10.1.2.0/24,private" # k8s, postgres, keyvault
|
||||
redis = "10.1.3.0/24,private" # Redis
|
||||
}
|
||||
}
|
||||
|
||||
variable "service_endpoints" {
|
||||
type = map
|
||||
default = {
|
||||
public = "Microsoft.ContainerRegistry" # Not necessary but added to avoid infinite state loop
|
||||
private = "Microsoft.Storage,Microsoft.KeyVault,Microsoft.ContainerRegistry,Microsoft.Sql"
|
||||
redis = "Microsoft.Storage,Microsoft.Sql" # FIXME: There is no Microsoft.Redis
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,6 +58,7 @@ variable "route_tables" {
|
||||
default = {
|
||||
public = "Internet"
|
||||
private = "Internet"
|
||||
redis = "VnetLocal"
|
||||
#private = "VnetLocal"
|
||||
}
|
||||
}
|
||||
@ -79,3 +90,26 @@ variable "admin_users" {
|
||||
"Dan Corrigan" = "7e852ceb-eb0d-49b1-b71e-e9dcd1082ffc"
|
||||
}
|
||||
}
|
||||
|
||||
variable "admin_user_whitelist" {
|
||||
type = map
|
||||
default = {
|
||||
"Rob Gil" = "66.220.238.246/32"
|
||||
"Dan Corrigan Work" = "108.16.207.173/32"
|
||||
"Dan Corrigan Home" = "71.162.221.27/32"
|
||||
}
|
||||
}
|
||||
|
||||
variable "storage_admin_whitelist" {
|
||||
type = map
|
||||
default = {
|
||||
"Rob Gil" = "66.220.238.246"
|
||||
"Dan Corrigan Work" = "108.16.207.173"
|
||||
"Dan Corrigan Home" = "71.162.221.27"
|
||||
}
|
||||
}
|
||||
|
||||
variable "vpn_client_cidr" {
|
||||
type = list
|
||||
default = ["172.16.255.0/24"]
|
||||
}
|
||||
|
@ -1,13 +1,15 @@
|
||||
module "vpc" {
|
||||
source = "../../modules/vpc/"
|
||||
environment = var.environment
|
||||
region = var.region
|
||||
virtual_network = var.virtual_network
|
||||
networks = var.networks
|
||||
gateway_subnet = var.gateway_subnet
|
||||
route_tables = var.route_tables
|
||||
owner = var.owner
|
||||
name = var.name
|
||||
dns_servers = var.dns_servers
|
||||
source = "../../modules/vpc/"
|
||||
environment = var.environment
|
||||
region = var.region
|
||||
virtual_network = var.virtual_network
|
||||
networks = var.networks
|
||||
gateway_subnet = var.gateway_subnet
|
||||
route_tables = var.route_tables
|
||||
owner = var.owner
|
||||
name = var.name
|
||||
dns_servers = var.dns_servers
|
||||
service_endpoints = var.service_endpoints
|
||||
vpn_client_cidr = var.vpn_client_cidr
|
||||
}
|
||||
|
||||
|
@ -1,22 +1,35 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from tests.factories import ApplicationFactory, EnvironmentFactory
|
||||
from tests.mock_azure import AUTH_CREDENTIALS, mock_azure
|
||||
|
||||
from atst.domain.csp.cloud import AzureCloudProvider
|
||||
from atst.domain.csp.cloud.models import (
|
||||
BillingInstructionCSPPayload,
|
||||
BillingInstructionCSPResult,
|
||||
BillingProfileCreationCSPPayload,
|
||||
BillingProfileCreationCSPResult,
|
||||
BillingProfileTenantAccessCSPPayload,
|
||||
BillingProfileTenantAccessCSPResult,
|
||||
BillingProfileVerificationCSPPayload,
|
||||
BillingProfileVerificationCSPResult,
|
||||
TaskOrderBillingCreationCSPPayload,
|
||||
TaskOrderBillingCreationCSPResult,
|
||||
TaskOrderBillingVerificationCSPPayload,
|
||||
TaskOrderBillingVerificationCSPResult,
|
||||
TenantCSPPayload,
|
||||
TenantCSPResult,
|
||||
)
|
||||
|
||||
from tests.mock_azure import mock_azure, AUTH_CREDENTIALS
|
||||
from tests.factories import EnvironmentFactory, ApplicationFactory
|
||||
creds = {
|
||||
"home_tenant_id": "tenant_id",
|
||||
"client_id": "client_id",
|
||||
"secret_key": "secret_key",
|
||||
}
|
||||
BILLING_ACCOUNT_NAME = "52865e4c-52e8-5a6c-da6b-c58f0814f06f:7ea5de9d-b8ce-4901-b1c5-d864320c7b03_2019-05-31"
|
||||
|
||||
|
||||
# TODO: Directly test create subscription, provide all args √
|
||||
# TODO: Test create environment (create management group with parent)
|
||||
# TODO: Test create application (create manageemnt group with parent)
|
||||
# Create reusable mock for mocking the management group calls for multiple services
|
||||
#
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_create_subscription_succeeds(mock_azure: AzureCloudProvider):
|
||||
environment = EnvironmentFactory.create()
|
||||
|
||||
@ -51,14 +64,12 @@ def test_create_subscription_succeeds(mock_azure: AzureCloudProvider):
|
||||
assert result == subscription_id
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def mock_management_group_create(mock_azure, spec_dict):
|
||||
mock_azure.sdk.managementgroups.ManagementGroupsAPI.return_value.management_groups.create_or_update.return_value.result.return_value = Mock(
|
||||
**spec_dict
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_create_environment_succeeds(mock_azure: AzureCloudProvider):
|
||||
environment = EnvironmentFactory.create()
|
||||
|
||||
@ -71,7 +82,6 @@ def test_create_environment_succeeds(mock_azure: AzureCloudProvider):
|
||||
assert result.id == "Test Id"
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_create_application_succeeds(mock_azure: AzureCloudProvider):
|
||||
application = ApplicationFactory.create()
|
||||
|
||||
@ -82,7 +92,6 @@ def test_create_application_succeeds(mock_azure: AzureCloudProvider):
|
||||
assert result.id == "Test Id"
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_create_atat_admin_user_succeeds(mock_azure: AzureCloudProvider):
|
||||
environment_id = str(uuid4())
|
||||
|
||||
@ -97,7 +106,6 @@ def test_create_atat_admin_user_succeeds(mock_azure: AzureCloudProvider):
|
||||
assert result.get("csp_user_id") == csp_user_id
|
||||
|
||||
|
||||
@pytest.mark.skip()
|
||||
def test_create_policy_definition_succeeds(mock_azure: AzureCloudProvider):
|
||||
subscription_id = str(uuid4())
|
||||
management_group_id = str(uuid4())
|
||||
@ -121,3 +129,287 @@ def test_create_policy_definition_succeeds(mock_azure: AzureCloudProvider):
|
||||
policy_definition_name=properties.get("displayName"),
|
||||
parameters=mock_policy_definition,
|
||||
)
|
||||
|
||||
|
||||
def test_create_tenant(mock_azure: AzureCloudProvider):
|
||||
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
|
||||
"accessToken": "TOKEN"
|
||||
}
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.json.return_value = {
|
||||
"objectId": "0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"tenantId": "60ff9d34-82bf-4f21-b565-308ef0533435",
|
||||
"userId": "1153801116406515559",
|
||||
}
|
||||
mock_result.status_code = 200
|
||||
mock_azure.sdk.requests.post.return_value = mock_result
|
||||
payload = TenantCSPPayload(
|
||||
**dict(
|
||||
creds=creds,
|
||||
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",
|
||||
)
|
||||
)
|
||||
result = mock_azure.create_tenant(payload)
|
||||
body: TenantCSPResult = result.get("body")
|
||||
assert body.tenant_id == "60ff9d34-82bf-4f21-b565-308ef0533435"
|
||||
|
||||
|
||||
def test_create_billing_profile_creation(mock_azure: AzureCloudProvider):
|
||||
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
|
||||
"accessToken": "TOKEN"
|
||||
}
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.headers = {
|
||||
"Location": "http://retry-url",
|
||||
"Retry-After": "10",
|
||||
}
|
||||
mock_result.status_code = 202
|
||||
mock_azure.sdk.requests.post.return_value = mock_result
|
||||
payload = BillingProfileCreationCSPPayload(
|
||||
**dict(
|
||||
address=dict(
|
||||
address_line_1="123 S Broad Street, Suite 2400",
|
||||
company_name="Promptworks",
|
||||
city="Philadelphia",
|
||||
region="PA",
|
||||
country="US",
|
||||
postal_code="19109",
|
||||
),
|
||||
creds=creds,
|
||||
tenant_id="60ff9d34-82bf-4f21-b565-308ef0533435",
|
||||
billing_profile_display_name="Test Billing Profile",
|
||||
billing_account_name=BILLING_ACCOUNT_NAME,
|
||||
)
|
||||
)
|
||||
result = mock_azure.create_billing_profile_creation(payload)
|
||||
body: BillingProfileCreationCSPResult = result.get("body")
|
||||
assert body.billing_profile_retry_after == 10
|
||||
|
||||
|
||||
def test_validate_billing_profile_creation(mock_azure: AzureCloudProvider):
|
||||
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
|
||||
"accessToken": "TOKEN"
|
||||
}
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.status_code = 200
|
||||
mock_result.json.return_value = {
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
|
||||
"name": "KQWI-W2SU-BG7-TGB",
|
||||
"properties": {
|
||||
"address": {
|
||||
"addressLine1": "123 S Broad Street, Suite 2400",
|
||||
"city": "Philadelphia",
|
||||
"companyName": "Promptworks",
|
||||
"country": "US",
|
||||
"postalCode": "19109",
|
||||
"region": "PA",
|
||||
},
|
||||
"currency": "USD",
|
||||
"displayName": "First Portfolio Billing Profile",
|
||||
"enabledAzurePlans": [],
|
||||
"hasReadAccess": True,
|
||||
"invoiceDay": 5,
|
||||
"invoiceEmailOptIn": False,
|
||||
"invoiceSections": [
|
||||
{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/invoiceSections/6HMZ-2HLO-PJA-TGB",
|
||||
"name": "6HMZ-2HLO-PJA-TGB",
|
||||
"properties": {"displayName": "First Portfolio Billing Profile"},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles/invoiceSections",
|
||||
}
|
||||
],
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
|
||||
}
|
||||
mock_azure.sdk.requests.get.return_value = mock_result
|
||||
|
||||
payload = BillingProfileVerificationCSPPayload(
|
||||
**dict(
|
||||
creds=creds,
|
||||
billing_profile_verify_url="https://management.azure.com/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/operationResults/createBillingProfile_478d5706-71f9-4a8b-8d4e-2cbaca27a668?api-version=2019-10-01-preview",
|
||||
)
|
||||
)
|
||||
|
||||
result = mock_azure.create_billing_profile_verification(payload)
|
||||
body: BillingProfileVerificationCSPResult = result.get("body")
|
||||
assert body.billing_profile_name == "KQWI-W2SU-BG7-TGB"
|
||||
assert (
|
||||
body.billing_profile_properties.billing_profile_display_name
|
||||
== "First Portfolio Billing Profile"
|
||||
)
|
||||
|
||||
|
||||
def test_create_billing_profile_tenant_access(mock_azure: AzureCloudProvider):
|
||||
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
|
||||
"accessToken": "TOKEN"
|
||||
}
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.status_code = 201
|
||||
mock_result.json.return_value = {
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleAssignments/40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"name": "40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"properties": {
|
||||
"createdOn": "2020-01-14T14:39:26.3342192+00:00",
|
||||
"createdByPrincipalId": "82e2b376-3297-4096-8743-ed65b3be0b03",
|
||||
"principalId": "0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
"principalTenantId": "60ff9d34-82bf-4f21-b565-308ef0533435",
|
||||
"roleDefinitionId": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/billingRoleDefinitions/40000000-aaaa-bbbb-cccc-100000000000",
|
||||
"scope": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
|
||||
},
|
||||
"type": "Microsoft.Billing/billingRoleAssignments",
|
||||
}
|
||||
|
||||
mock_azure.sdk.requests.post.return_value = mock_result
|
||||
|
||||
payload = BillingProfileTenantAccessCSPPayload(
|
||||
**dict(
|
||||
creds=creds,
|
||||
tenant_id="60ff9d34-82bf-4f21-b565-308ef0533435",
|
||||
user_object_id="0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d",
|
||||
billing_account_name="7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31",
|
||||
billing_profile_name="KQWI-W2SU-BG7-TGB",
|
||||
)
|
||||
)
|
||||
|
||||
result = mock_azure.create_billing_profile_tenant_access(payload)
|
||||
body: BillingProfileTenantAccessCSPResult = result.get("body")
|
||||
assert (
|
||||
body.billing_role_assignment_name
|
||||
== "40000000-aaaa-bbbb-cccc-100000000000_0a5f4926-e3ee-4f47-a6e3-8b0a30a40e3d"
|
||||
)
|
||||
|
||||
|
||||
def test_create_task_order_billing_creation(mock_azure: AzureCloudProvider):
|
||||
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
|
||||
"accessToken": "TOKEN"
|
||||
}
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.status_code = 202
|
||||
mock_result.headers = {
|
||||
"Location": "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/operationResults/patchBillingProfile_KQWI-W2SU-BG7-TGB:02715576-4118-466c-bca7-b1cd3169ff46?api-version=2019-10-01-preview",
|
||||
"Retry-After": "10",
|
||||
}
|
||||
|
||||
mock_azure.sdk.requests.patch.return_value = mock_result
|
||||
|
||||
payload = TaskOrderBillingCreationCSPPayload(
|
||||
**dict(
|
||||
creds=creds,
|
||||
billing_account_name="7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31",
|
||||
billing_profile_name="KQWI-W2SU-BG7-TGB",
|
||||
)
|
||||
)
|
||||
|
||||
result = mock_azure.create_task_order_billing_creation(payload)
|
||||
body: TaskOrderBillingCreationCSPResult = result.get("body")
|
||||
assert (
|
||||
body.task_order_billing_verify_url
|
||||
== "https://management.azure.com/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/operationResults/patchBillingProfile_KQWI-W2SU-BG7-TGB:02715576-4118-466c-bca7-b1cd3169ff46?api-version=2019-10-01-preview"
|
||||
)
|
||||
|
||||
|
||||
def test_create_task_order_billing_verification(mock_azure):
|
||||
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
|
||||
"accessToken": "TOKEN"
|
||||
}
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.status_code = 200
|
||||
mock_result.json.return_value = {
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB",
|
||||
"name": "KQWI-W2SU-BG7-TGB",
|
||||
"properties": {
|
||||
"address": {
|
||||
"addressLine1": "123 S Broad Street, Suite 2400",
|
||||
"city": "Philadelphia",
|
||||
"companyName": "Promptworks",
|
||||
"country": "US",
|
||||
"postalCode": "19109",
|
||||
"region": "PA",
|
||||
},
|
||||
"currency": "USD",
|
||||
"displayName": "Test Billing Profile",
|
||||
"enabledAzurePlans": [
|
||||
{
|
||||
"productId": "DZH318Z0BPS6",
|
||||
"skuId": "0001",
|
||||
"skuDescription": "Microsoft Azure Plan",
|
||||
}
|
||||
],
|
||||
"hasReadAccess": True,
|
||||
"invoiceDay": 5,
|
||||
"invoiceEmailOptIn": False,
|
||||
"invoiceSections": [
|
||||
{
|
||||
"id": "/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/billingProfiles/KQWI-W2SU-BG7-TGB/invoiceSections/CHCO-BAAR-PJA-TGB",
|
||||
"name": "CHCO-BAAR-PJA-TGB",
|
||||
"properties": {"displayName": "Test Billing Profile"},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles/invoiceSections",
|
||||
}
|
||||
],
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles",
|
||||
}
|
||||
mock_azure.sdk.requests.get.return_value = mock_result
|
||||
|
||||
payload = TaskOrderBillingVerificationCSPPayload(
|
||||
**dict(
|
||||
creds=creds,
|
||||
task_order_billing_verify_url="https://management.azure.com/providers/Microsoft.Billing/billingAccounts/7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31/operationResults/createBillingProfile_478d5706-71f9-4a8b-8d4e-2cbaca27a668?api-version=2019-10-01-preview",
|
||||
)
|
||||
)
|
||||
|
||||
result = mock_azure.create_task_order_billing_verification(payload)
|
||||
body: TaskOrderBillingVerificationCSPResult = result.get("body")
|
||||
assert body.billing_profile_name == "KQWI-W2SU-BG7-TGB"
|
||||
assert (
|
||||
body.billing_profile_enabled_plan_details.enabled_azure_plans[0].get("skuId")
|
||||
== "0001"
|
||||
)
|
||||
|
||||
|
||||
def test_create_billing_instruction(mock_azure: AzureCloudProvider):
|
||||
mock_azure.sdk.adal.AuthenticationContext.return_value.context.acquire_token_with_client_credentials.return_value = {
|
||||
"accessToken": "TOKEN"
|
||||
}
|
||||
|
||||
mock_result = Mock()
|
||||
mock_result.status_code = 200
|
||||
mock_result.json.return_value = {
|
||||
"name": "TO1:CLIN001",
|
||||
"properties": {
|
||||
"amount": 1000.0,
|
||||
"endDate": "2020-03-01T00:00:00+00:00",
|
||||
"startDate": "2020-01-01T00:00:00+00:00",
|
||||
},
|
||||
"type": "Microsoft.Billing/billingAccounts/billingProfiles/billingInstructions",
|
||||
}
|
||||
|
||||
mock_azure.sdk.requests.put.return_value = mock_result
|
||||
|
||||
payload = BillingInstructionCSPPayload(
|
||||
**dict(
|
||||
creds=creds,
|
||||
initial_clin_amount=1000.00,
|
||||
initial_clin_start_date="2020/1/1",
|
||||
initial_clin_end_date="2020/3/1",
|
||||
initial_clin_type="1",
|
||||
initial_task_order_id="TO1",
|
||||
billing_account_name="7c89b735-b22b-55c0-ab5a-c624843e8bf6:de4416ce-acc6-44b1-8122-c87c4e903c91_2019-05-31",
|
||||
billing_profile_name="KQWI-W2SU-BG7-TGB",
|
||||
)
|
||||
)
|
||||
result = mock_azure.create_billing_instruction(payload)
|
||||
body: BillingInstructionCSPResult = result.get("body")
|
||||
assert body.reported_clin_name == "TO1:CLIN001"
|
||||
|
@ -1,4 +1,4 @@
|
||||
from atst.domain.csp.policy import AzurePolicyManager, AzurePolicy
|
||||
from atst.domain.csp.cloud.policy import AzurePolicyManager, AzurePolicy
|
||||
|
||||
|
||||
def test_portfolio_definitions():
|
||||
|
@ -1,16 +1,24 @@
|
||||
import pytest
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
from tests.factories import (
|
||||
PortfolioFactory,
|
||||
PortfolioStateMachineFactory,
|
||||
CLINFactory,
|
||||
)
|
||||
|
||||
from atst.models import FSMStates
|
||||
from atst.models import FSMStates, PortfolioStateMachine, TaskOrder
|
||||
from atst.models.mixins.state_machines import AzureStages, StageStates, compose_state
|
||||
from atst.models.portfolio import Portfolio
|
||||
from atst.models.portfolio_state_machine import get_stage_csp_class
|
||||
|
||||
# TODO: Write failure case tests
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def portfolio():
|
||||
portfolio = PortfolioFactory.create()
|
||||
# TODO: setup clin/to as active/funded/ready
|
||||
portfolio = CLINFactory.create().task_order.portfolio
|
||||
return portfolio
|
||||
|
||||
|
||||
@ -19,18 +27,132 @@ def test_fsm_creation(portfolio):
|
||||
assert sm.portfolio
|
||||
|
||||
|
||||
def test_fsm_transition_start(portfolio):
|
||||
def test_state_machine_trigger_next_transition(portfolio):
|
||||
sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
|
||||
|
||||
sm.trigger_next_transition()
|
||||
assert sm.current_state == FSMStates.STARTING
|
||||
|
||||
sm.trigger_next_transition()
|
||||
assert sm.current_state == FSMStates.STARTED
|
||||
|
||||
|
||||
def test_state_machine_compose_state(portfolio):
|
||||
PortfolioStateMachineFactory.create(portfolio=portfolio)
|
||||
assert (
|
||||
compose_state(AzureStages.TENANT, StageStates.CREATED)
|
||||
== FSMStates.TENANT_CREATED
|
||||
)
|
||||
|
||||
|
||||
def test_state_machine_valid_data_classes_for_stages(portfolio):
|
||||
PortfolioStateMachineFactory.create(portfolio=portfolio)
|
||||
for stage in AzureStages:
|
||||
assert get_stage_csp_class(stage.name.lower(), "payload") is not None
|
||||
assert get_stage_csp_class(stage.name.lower(), "result") is not None
|
||||
|
||||
|
||||
def test_state_machine_initialization(portfolio):
|
||||
|
||||
sm = PortfolioStateMachineFactory.create(portfolio=portfolio)
|
||||
for stage in AzureStages:
|
||||
|
||||
# check that all stages have a 'create' and 'fail' triggers
|
||||
stage_name = stage.name.lower()
|
||||
for trigger_prefix in ["create", "fail"]:
|
||||
assert hasattr(sm, trigger_prefix + "_" + stage_name)
|
||||
|
||||
# check that machine
|
||||
in_progress_triggers = sm.machine.get_triggers(stage.name + "_IN_PROGRESS")
|
||||
assert [
|
||||
"reset",
|
||||
"fail",
|
||||
"finish_" + stage_name,
|
||||
"fail_" + stage_name,
|
||||
] == in_progress_triggers
|
||||
|
||||
started_triggers = sm.machine.get_triggers("STARTED")
|
||||
create_trigger = next(
|
||||
filter(
|
||||
lambda trigger: trigger.startswith("create_"),
|
||||
sm.machine.get_triggers(FSMStates.STARTED.name),
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert ["reset", "fail", create_trigger] == started_triggers
|
||||
|
||||
|
||||
@mock.patch("atst.domain.csp.cloud.MockCloudProvider")
|
||||
def test_fsm_transition_start(mock_cloud_provider, portfolio: Portfolio):
|
||||
mock_cloud_provider._authorize.return_value = None
|
||||
mock_cloud_provider._maybe_raise.return_value = None
|
||||
sm: PortfolioStateMachine = PortfolioStateMachineFactory.create(portfolio=portfolio)
|
||||
assert sm.portfolio
|
||||
assert sm.state == FSMStates.UNSTARTED
|
||||
|
||||
# next_state does not create the trigger callbacks !!!
|
||||
# sm.next_state()
|
||||
|
||||
sm.init()
|
||||
assert sm.state == FSMStates.STARTING
|
||||
|
||||
sm.start()
|
||||
assert sm.state == FSMStates.STARTED
|
||||
sm.create_tenant(a=1, b=2)
|
||||
assert sm.state == FSMStates.TENANT_CREATED
|
||||
|
||||
expected_states = [
|
||||
FSMStates.TENANT_CREATED,
|
||||
FSMStates.BILLING_PROFILE_CREATION_CREATED,
|
||||
FSMStates.BILLING_PROFILE_VERIFICATION_CREATED,
|
||||
FSMStates.BILLING_PROFILE_TENANT_ACCESS_CREATED,
|
||||
FSMStates.TASK_ORDER_BILLING_CREATION_CREATED,
|
||||
FSMStates.TASK_ORDER_BILLING_VERIFICATION_CREATED,
|
||||
FSMStates.BILLING_INSTRUCTION_CREATED,
|
||||
]
|
||||
|
||||
# Should source all creds for portfolio? might be easier to manage than per-step specific ones
|
||||
creds = {"username": "mock-cloud", "password": "shh"} # pragma: allowlist secret
|
||||
if portfolio.csp_data is not None:
|
||||
csp_data = portfolio.csp_data
|
||||
else:
|
||||
csp_data = {}
|
||||
|
||||
ppoc = portfolio.owner
|
||||
user_id = f"{ppoc.first_name[0]}{ppoc.last_name}".lower()
|
||||
domain_name = re.sub("[^0-9a-zA-Z]+", "", portfolio.name).lower()
|
||||
|
||||
initial_task_order: TaskOrder = portfolio.task_orders[0]
|
||||
initial_clin = initial_task_order.sorted_clins[0]
|
||||
|
||||
portfolio_data = {
|
||||
"user_id": user_id,
|
||||
"password": "jklfsdNCVD83nklds2#202", # pragma: allowlist secret
|
||||
"domain_name": domain_name,
|
||||
"first_name": ppoc.first_name,
|
||||
"last_name": ppoc.last_name,
|
||||
"country_code": "US",
|
||||
"password_recovery_email_address": ppoc.email,
|
||||
"address": { # TODO: TBD if we're sourcing this from data or config
|
||||
"company_name": "",
|
||||
"address_line_1": "",
|
||||
"city": "",
|
||||
"region": "",
|
||||
"country": "",
|
||||
"postal_code": "",
|
||||
},
|
||||
"billing_profile_display_name": "My Billing Profile",
|
||||
"initial_clin_amount": initial_clin.obligated_amount,
|
||||
"initial_clin_start_date": initial_clin.start_date.strftime("%Y/%m/%d"),
|
||||
"initial_clin_end_date": initial_clin.end_date.strftime("%Y/%m/%d"),
|
||||
"initial_clin_type": initial_clin.number,
|
||||
"initial_task_order_id": initial_task_order.number,
|
||||
}
|
||||
|
||||
config = {"billing_account_name": "billing_account_name"}
|
||||
|
||||
for expected_state in expected_states:
|
||||
collected_data = dict(
|
||||
list(csp_data.items()) + list(portfolio_data.items()) + list(config.items())
|
||||
)
|
||||
sm.trigger_next_transition(creds=creds, csp_data=collected_data)
|
||||
assert sm.state == expected_state
|
||||
if portfolio.csp_data is not None:
|
||||
csp_data = portfolio.csp_data
|
||||
else:
|
||||
csp_data = {}
|
||||
|
@ -8,6 +8,7 @@ AZURE_CONFIG = {
|
||||
"AZURE_SECRET_KEY": "MOCK",
|
||||
"AZURE_TENANT_ID": "MOCK",
|
||||
"AZURE_POLICY_LOCATION": "policies",
|
||||
"AZURE_VAULT_URL": "http://vault",
|
||||
}
|
||||
|
||||
AUTH_CREDENTIALS = {
|
||||
@ -53,16 +54,38 @@ def mock_policy():
|
||||
return Mock(spec=policy)
|
||||
|
||||
|
||||
def mock_adal():
|
||||
import adal
|
||||
|
||||
return Mock(spec=adal)
|
||||
|
||||
|
||||
def mock_requests():
|
||||
import requests
|
||||
|
||||
return Mock(spec=requests)
|
||||
|
||||
|
||||
def mock_secrets():
|
||||
from azure.keyvault import secrets
|
||||
|
||||
return Mock(spec=secrets)
|
||||
|
||||
|
||||
class MockAzureSDK(object):
|
||||
def __init__(self):
|
||||
from msrestazure.azure_cloud import AZURE_PUBLIC_CLOUD
|
||||
|
||||
self.subscription = mock_subscription()
|
||||
self.authorization = mock_authorization()
|
||||
self.policy = mock_policy()
|
||||
self.adal = mock_adal()
|
||||
self.managementgroups = mock_managementgroups()
|
||||
self.graphrbac = mock_graphrbac()
|
||||
self.credentials = mock_credentials()
|
||||
self.policy = mock_policy()
|
||||
self.secrets = mock_secrets()
|
||||
self.requests = mock_requests()
|
||||
# may change to a JEDI cloud
|
||||
self.cloud = AZURE_PUBLIC_CLOUD
|
||||
|
||||
|
@ -12,7 +12,7 @@ from atst.domain.application_roles import ApplicationRoles
|
||||
from atst.domain.environment_roles import EnvironmentRoles
|
||||
from atst.domain.invitations import ApplicationInvitations
|
||||
from atst.domain.common import Paginator
|
||||
from atst.domain.csp.cloud import GeneralCSPException
|
||||
from atst.domain.csp.cloud.exceptions import GeneralCSPException
|
||||
from atst.domain.permission_sets import PermissionSets
|
||||
from atst.models.application_role import Status as ApplicationRoleStatus
|
||||
from atst.models.environment_role import CSPRole, EnvironmentRole
|
||||
|
@ -84,6 +84,31 @@ email:
|
||||
application_invite: "{inviter_name} has invited you to a JEDI cloud application"
|
||||
portfolio_invite: "{inviter_name} has invited you to a JEDI cloud portfolio"
|
||||
environment_ready: JEDI cloud environment ready
|
||||
empty_state:
|
||||
applications:
|
||||
header:
|
||||
edit: You don’t have any Applications yet
|
||||
view: This portfolio has no Applications
|
||||
message:
|
||||
edit: You can manage multiple Applications within a single Portfolio as long as the funding sources are the same.
|
||||
view: A Portfolio member with <b>Edit Application</b> permissions can add Applications to this Portfolio.
|
||||
button_text: Create Your First Application
|
||||
applications_reporting:
|
||||
header:
|
||||
edit: Nothing to report.
|
||||
view: Nothing to report.
|
||||
message:
|
||||
edit: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started.
|
||||
view: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments.
|
||||
button_text: Add a new application
|
||||
task_orders:
|
||||
header:
|
||||
edit: Add approved task orders
|
||||
view: This Portfolio has no Task Orders
|
||||
message:
|
||||
edit: Upload your approved Task Order here. You are required to confirm you have the appropriate signature. You will have the ability to add additional approved Task Orders with more funding to this Portfolio in the future.
|
||||
view: A Portfolio member with <b>Edit Funding</b> permissions can fund this Portfolio with approved Task Orders.
|
||||
button_text: Add Task Order
|
||||
flash:
|
||||
application:
|
||||
created:
|
||||
@ -370,11 +395,6 @@ portfolios:
|
||||
add_member: Add Team Member
|
||||
add_another_environment: Add another environment
|
||||
create_button: Create Application
|
||||
empty_state:
|
||||
header: You don't have any Applications yet
|
||||
message: You can manage multiple Applications within a single Portfolio as long as the funding sources are the same.
|
||||
button_text: Create Your First Application
|
||||
view_only_text: Contact your portfolio administrator to add an application.
|
||||
new:
|
||||
step_1_header: Name and Describe New Application
|
||||
step_1_button_text: "Next: Add Environments"
|
||||
@ -417,6 +437,7 @@ portfolios:
|
||||
add_subscription: Add new subscription
|
||||
blank_slate: This Application has no environments
|
||||
disabled: ": Access Suspended"
|
||||
funding_alert: "Application environments will not be created until the {name} portfolio is funded."
|
||||
environments_heading: Application Environments
|
||||
existing_application_title: "{application_name} Application Settings"
|
||||
member_count: "{count} Members"
|
||||
@ -482,12 +503,6 @@ portfolios:
|
||||
header: Funding Duration
|
||||
tooltip: Funding duration is the period of time that there is a valid task order funding the portfolio.
|
||||
estimate_warning: Reports displayed in JEDI are estimates and not a system of record.
|
||||
empty_state:
|
||||
message: Nothing to report.
|
||||
sub_message:
|
||||
can_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Create an application with some cloud environments to get started.
|
||||
cannot_create_applications: This portfolio has no cloud environments set up, so there is no spending data to report. Contact the portfolio owner to set up some cloud environments.
|
||||
action_label: "Add a new application"
|
||||
total_value:
|
||||
header: Total Portfolio Value
|
||||
tooltip: Total portfolio value is all obligated and projected funds for all task orders in this portfolio.
|
||||
@ -549,11 +564,6 @@ task_orders:
|
||||
sticky_header_text: "Add a Task Order"
|
||||
sticky_header_review_text: Review Changes
|
||||
sticky_header_context: "Step {step} of 5"
|
||||
empty_state:
|
||||
header: Add approved task orders
|
||||
message: Upload your approved Task Order here. You are required to confirm you have the appropriate signature. You will have the ability to add additional approved Task Orders with more funding to this Portfolio in the future.
|
||||
button_text: Add Task Order
|
||||
view_only_text: Contact your portfolio administrator to add a Task Order.
|
||||
sign:
|
||||
digital_signature_description: I confirm the uploaded Task Order is signed by the appropriate, duly warranted Agency Contracting Officer who authorized me to upload the Task Order.
|
||||
confirmation_description: I confirm that the information entered here in matches that of the submitted Task Order.
|
||||
|
Loading…
x
Reference in New Issue
Block a user