diff --git a/moto/__init__.py b/moto/__init__.py index 0ce5e54d1..2301b7ca1 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -27,6 +27,7 @@ from .glacier import mock_glacier, mock_glacier_deprecated # flake8: noqa from .iam import mock_iam, mock_iam_deprecated # flake8: noqa from .kinesis import mock_kinesis, mock_kinesis_deprecated # flake8: noqa from .kms import mock_kms, mock_kms_deprecated # flake8: noqa +from .organizations import mock_organizations # flake8: noqa from .opsworks import mock_opsworks, mock_opsworks_deprecated # flake8: noqa from .polly import mock_polly # flake8: noqa from .rds import mock_rds, mock_rds_deprecated # flake8: noqa diff --git a/moto/backends.py b/moto/backends.py index cd8fe174f..25fcec09a 100644 --- a/moto/backends.py +++ b/moto/backends.py @@ -26,6 +26,7 @@ from moto.kinesis import kinesis_backends from moto.kms import kms_backends from moto.logs import logs_backends from moto.opsworks import opsworks_backends +from moto.organizations import organizations_backends from moto.polly import polly_backends from moto.rds2 import rds2_backends from moto.redshift import redshift_backends @@ -72,6 +73,7 @@ BACKENDS = { 'kinesis': kinesis_backends, 'kms': kms_backends, 'opsworks': opsworks_backends, + 'organizations': organizations_backends, 'polly': polly_backends, 'redshift': redshift_backends, 'rds': rds2_backends, diff --git a/moto/organizations/__init__.py b/moto/organizations/__init__.py new file mode 100644 index 000000000..372782dd3 --- /dev/null +++ b/moto/organizations/__init__.py @@ -0,0 +1,6 @@ +from __future__ import unicode_literals +from .models import organizations_backend +from ..core.models import base_decorator + +organizations_backends = {"global": organizations_backend} +mock_organizations = base_decorator(organizations_backends) diff --git a/moto/organizations/models.py b/moto/organizations/models.py new file mode 100644 index 000000000..50212af29 --- /dev/null +++ b/moto/organizations/models.py @@ -0,0 +1,131 @@ +from __future__ import unicode_literals + +import datetime +import time + +from moto.core import BaseBackend, BaseModel +from moto.core.utils import unix_time +from moto.organizations import utils + +MASTER_ACCOUNT_ID = '123456789012' +MASTER_ACCOUNT_EMAIL = 'fakeorg@moto-example.com' +ORGANIZATION_ARN_FORMAT = 'arn:aws:organizations::{0}:organization/{1}' +MASTER_ACCOUNT_ARN_FORMAT = 'arn:aws:organizations::{0}:account/{1}/{0}' +ACCOUNT_ARN_FORMAT = 'arn:aws:organizations::{0}:account/{1}/{2}' + + +class FakeOrganization(BaseModel): + + def __init__(self, feature_set): + self.id = utils.make_random_org_id() + self.feature_set = feature_set + self.master_account_id = MASTER_ACCOUNT_ID + self.master_account_email = MASTER_ACCOUNT_EMAIL + self.available_policy_types = [{ + 'Type': 'SERVICE_CONTROL_POLICY', + 'Status': 'ENABLED' + }] + + @property + def arn(self): + return ORGANIZATION_ARN_FORMAT.format(self.master_account_id, self.id) + + @property + def master_account_arn(self): + return MASTER_ACCOUNT_ARN_FORMAT.format(self.master_account_id, self.id) + + def _describe(self): + return { + 'Organization': { + 'Id': self.id, + 'Arn': self.arn, + 'FeatureSet': self.feature_set, + 'MasterAccountArn': self.master_account_arn, + 'MasterAccountId': self.master_account_id, + 'MasterAccountEmail': self.master_account_email, + 'AvailablePolicyTypes': self.available_policy_types, + } + } + + +class FakeAccount(BaseModel): + + def __init__(self, organization, **kwargs): + self.organization_id = organization.id + self.master_account_id = organization.master_account_id + self.create_account_status_id = utils.make_random_create_account_status_id() + self.account_id = utils.make_random_account_id() + self.account_name = kwargs['AccountName'] + self.email = kwargs['Email'] + self.create_time = datetime.datetime.utcnow() + self.status = 'ACTIVE' + self.joined_method = 'CREATED' + + @property + def arn(self): + return ACCOUNT_ARN_FORMAT.format( + self.master_account_id, + self.organization_id, + self.account_id + ) + + @property + def create_account_status(self): + return { + 'CreateAccountStatus': { + 'Id': self.create_account_status_id, + 'AccountName': self.account_name, + 'State': 'SUCCEEDED', + 'RequestedTimestamp': unix_time(self.create_time), + 'CompletedTimestamp': unix_time(self.create_time), + 'AccountId': self.account_id, + } + } + + def describe(self): + return { + 'Account': { + 'Id': self.account_id, + 'Arn': self.arn, + 'Email': self.email, + 'Name': self.account_name, + 'Status': self.status, + 'JoinedMethod': self.joined_method, + 'JoinedTimestamp': unix_time(self.create_time), + } + } + + +class OrganizationsBackend(BaseBackend): + + def __init__(self): + self.org = None + self.accounts = [] + + def create_organization(self, **kwargs): + self.org = FakeOrganization(kwargs['FeatureSet']) + return self.org._describe() + + def describe_organization(self): + return self.org._describe() + + def create_account(self, **kwargs): + new_account = FakeAccount(self.org, **kwargs) + self.accounts.append(new_account) + return new_account.create_account_status + + def describe_account(self, **kwargs): + account = [account for account in self.accounts + if account.account_id == kwargs['AccountId']][0] + return account.describe() + + def list_accounts(self): + return dict( + Accounts=[account.describe()['Account'] for account in self.accounts] + ) + + +organizations_backend = OrganizationsBackend() + + + diff --git a/moto/organizations/responses.py b/moto/organizations/responses.py new file mode 100644 index 000000000..10288dedb --- /dev/null +++ b/moto/organizations/responses.py @@ -0,0 +1,48 @@ +from __future__ import unicode_literals +import json + +from moto.core.responses import BaseResponse +from .models import organizations_backend + + +class OrganizationsResponse(BaseResponse): + + @property + def organizations_backend(self): + return organizations_backend + + @property + def request_params(self): + try: + return json.loads(self.body) + except ValueError: + return {} + + def _get_param(self, param, default=None): + return self.request_params.get(param, default) + + def create_organization(self): + return json.dumps( + self.organizations_backend.create_organization(**self.request_params) + ) + + def describe_organization(self): + return json.dumps( + self.organizations_backend.describe_organization() + ) + + def create_account(self): + return json.dumps( + self.organizations_backend.create_account(**self.request_params) + ) + + def describe_account(self): + return json.dumps( + self.organizations_backend.describe_account(**self.request_params) + ) + + def list_accounts(self): + return json.dumps( + self.organizations_backend.list_accounts() + ) + diff --git a/moto/organizations/urls.py b/moto/organizations/urls.py new file mode 100644 index 000000000..7911f5b53 --- /dev/null +++ b/moto/organizations/urls.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals +from .responses import OrganizationsResponse + +url_bases = [ + "https?://organizations.(.+).amazonaws.com", +] + +url_paths = { + '{0}/$': OrganizationsResponse.dispatch, +} diff --git a/moto/organizations/utils.py b/moto/organizations/utils.py new file mode 100644 index 000000000..5916dc5ea --- /dev/null +++ b/moto/organizations/utils.py @@ -0,0 +1,34 @@ +from __future__ import unicode_literals + +import random +import string + +CHARSET=string.ascii_lowercase + string.digits +ORG_ID_SIZE = 10 +ROOT_ID_SIZE = 4 +ACCOUNT_ID_SIZE = 12 +CREATE_ACCOUNT_STATUS_ID_SIZE = 8 + + +def make_random_org_id(): + # The regex pattern for an organization ID string requires "o-" + # followed by from 10 to 32 lower-case letters or digits. + # e.g. 'o-vipjnq5z86' + return 'o-' + ''.join(random.choice(CHARSET) for x in range(ORG_ID_SIZE)) + +def make_random_root_id(): + # The regex pattern for a root ID string requires "r-" followed by + # from 4 to 32 lower-case letters or digits. + # e.g. 'r-3zwx' + return 'r-' + ''.join(random.choice(CHARSET) for x in range(ROOT_ID_SIZE)) + +def make_random_account_id(): + # The regex pattern for an account ID string requires exactly 12 digits. + # e.g. '488633172133' + return ''.join([random.choice(string.digits) for n in range(ACCOUNT_ID_SIZE)]) + +def make_random_create_account_status_id(): + # The regex pattern for an create account request ID string requires + # "car-" followed by from 8 to 32 lower-case letters or digits. + # e.g. 'car-35gxzwrp' + return 'car-' + ''.join(random.choice(CHARSET) for x in range(CREATE_ACCOUNT_STATUS_ID_SIZE)) diff --git a/tests/test_organizations/__init__.py b/tests/test_organizations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_organizations/object_syntax.py b/tests/test_organizations/object_syntax.py new file mode 100644 index 000000000..35437be62 --- /dev/null +++ b/tests/test_organizations/object_syntax.py @@ -0,0 +1,24 @@ +""" +Temporary functions for checking object structures while specing out +models. This module will go away. +""" + +import yaml +import moto +from moto import organizations as orgs + + +# utils +print(orgs.utils.make_random_org_id()) +print(orgs.utils.make_random_root_id()) +print(orgs.utils.make_random_account_id()) +print(orgs.utils.make_random_create_account_id()) + +# models +my_org = orgs.models.FakeOrganization(feature_set = 'ALL') +print(yaml.dump(my_org._describe())) +#assert False + +my_account = orgs.models.FakeAccount(my_org, AccountName='blee01', Email='blee01@moto-example.org') +print(yaml.dump(my_account)) +#assert False diff --git a/tests/test_organizations/test_organizations_boto3.py b/tests/test_organizations/test_organizations_boto3.py new file mode 100644 index 000000000..fa70f3306 --- /dev/null +++ b/tests/test_organizations/test_organizations_boto3.py @@ -0,0 +1,193 @@ +from __future__ import unicode_literals + +import boto3 +import botocore.exceptions +import sure # noqa +import yaml +import re +import datetime + +import moto +from moto import mock_organizations +from moto.organizations.models import ( + MASTER_ACCOUNT_ID, + MASTER_ACCOUNT_EMAIL, + ORGANIZATION_ARN_FORMAT, + MASTER_ACCOUNT_ARN_FORMAT, + ACCOUNT_ARN_FORMAT, +) +from .test_organizations_utils import ( + ORG_ID_REGEX, + ROOT_ID_REGEX, + ACCOUNT_ID_REGEX, + CREATE_ACCOUNT_STATUS_ID_REGEX, +) + +EMAIL_REGEX = "^.+@[a-zA-Z0-9-.]+.[a-zA-Z]{2,3}|[0-9]{1,3}$" + + +def validate_organization(response): + org = response['Organization'] + sorted(org.keys()).should.equal([ + 'Arn', + 'AvailablePolicyTypes', + 'FeatureSet', + 'Id', + 'MasterAccountArn', + 'MasterAccountEmail', + 'MasterAccountId', + ]) + org['Id'].should.match(ORG_ID_REGEX) + org['MasterAccountId'].should.equal(MASTER_ACCOUNT_ID) + org['MasterAccountArn'].should.equal(MASTER_ACCOUNT_ARN_FORMAT.format( + org['MasterAccountId'], + org['Id'], + )) + org['Arn'].should.equal(ORGANIZATION_ARN_FORMAT.format( + org['MasterAccountId'], + org['Id'], + )) + org['MasterAccountEmail'].should.equal(MASTER_ACCOUNT_EMAIL) + org['FeatureSet'].should.be.within(['ALL', 'CONSOLIDATED_BILLING']) + org['AvailablePolicyTypes'].should.equal([{ + 'Type': 'SERVICE_CONTROL_POLICY', + 'Status': 'ENABLED' + }]) + # + #'Organization': { + # 'Id': 'string', + # 'Arn': 'string', + # 'FeatureSet': 'ALL'|'CONSOLIDATED_BILLING', + # 'MasterAccountArn': 'string', + # 'MasterAccountId': 'string', + # 'MasterAccountEmail': 'string', + # 'AvailablePolicyTypes': [ + # { + # 'Type': 'SERVICE_CONTROL_POLICY', + # 'Status': 'ENABLED'|'PENDING_ENABLE'|'PENDING_DISABLE' + # }, + # ] + #} + +def validate_account(org, account): + sorted(account.keys()).should.equal([ + 'Arn', + 'Email', + 'Id', + 'JoinedMethod', + 'JoinedTimestamp', + 'Name', + 'Status', + ]) + account['Id'].should.match(ACCOUNT_ID_REGEX) + account['Arn'].should.equal(ACCOUNT_ARN_FORMAT.format( + org['MasterAccountId'], + org['Id'], + account['Id'], + )) + account['Email'].should.match(EMAIL_REGEX) + account['JoinedMethod'].should.be.within(['INVITED', 'CREATED']) + account['Status'].should.be.within(['ACTIVE', 'SUSPENDED']) + account['Name'].should.be.a(str) + account['JoinedTimestamp'].should.be.a(datetime.datetime) + #'Account': { + # 'Id': 'string', + # 'Arn': 'string', + # 'Email': 'string', + # 'Name': 'string', + # 'Status': 'ACTIVE'|'SUSPENDED', + # 'JoinedMethod': 'INVITED'|'CREATED', + # 'JoinedTimestamp': datetime(2015, 1, 1) + #} + +def validate_create_account_status(create_status): + sorted(create_status.keys()).should.equal([ + 'AccountId', + 'AccountName', + 'CompletedTimestamp', + 'Id', + 'RequestedTimestamp', + 'State', + ]) + create_status['Id'].should.match(CREATE_ACCOUNT_STATUS_ID_REGEX) + create_status['AccountId'].should.match(ACCOUNT_ID_REGEX) + create_status['AccountName'].should.be.a(str) + create_status['State'].should.equal('SUCCEEDED') + create_status['RequestedTimestamp'].should.be.a(datetime.datetime) + create_status['CompletedTimestamp'].should.be.a(datetime.datetime) + #'CreateAccountStatus': { + # 'Id': 'string', + # 'AccountName': 'string', + # 'State': 'IN_PROGRESS'|'SUCCEEDED'|'FAILED', + # 'RequestedTimestamp': datetime(2015, 1, 1), + # 'CompletedTimestamp': datetime(2015, 1, 1), + # 'AccountId': 'string', + # 'FailureReason': 'ACCOUNT_LIMIT_EXCEEDED'|'EMAIL_ALREADY_EXISTS'|'INVALID_ADDRESS'|'INVALID_EMAIL'|'CONCURRENT_ACCOUNT_MODIFICATION'|'INTERNAL_FAILURE' + #} + +@mock_organizations +def test_create_organization(): + client = boto3.client('organizations', region_name='us-east-1') + response = client.create_organization(FeatureSet='ALL') + #print(yaml.dump(response)) + validate_organization(response) + response['Organization']['FeatureSet'].should.equal('ALL') + #assert False + +@mock_organizations +def test_describe_organization(): + client = boto3.client('organizations', region_name='us-east-1') + client.create_organization(FeatureSet='ALL') + response = client.describe_organization() + #print(yaml.dump(response)) + validate_organization(response) + #assert False + + +mockname = 'mock-account' +mockdomain = 'moto-example.org' +mockemail = '@'.join([mockname, mockdomain]) + +@mock_organizations +def test_create_account(): + client = boto3.client('organizations', region_name='us-east-1') + client.create_organization(FeatureSet='ALL') + create_status = client.create_account( + AccountName=mockname, Email=mockemail)['CreateAccountStatus'] + #print(yaml.dump(create_status, default_flow_style=False)) + validate_create_account_status(create_status) + create_status['AccountName'].should.equal(mockname) + #assert False + +@mock_organizations +def test_describe_account(): + client = boto3.client('organizations', region_name='us-east-1') + org = client.create_organization(FeatureSet='ALL')['Organization'] + account_id = client.create_account( + AccountName=mockname, Email=mockemail)['CreateAccountStatus']['AccountId'] + response = client.describe_account(AccountId=account_id) + #print(yaml.dump(response, default_flow_style=False)) + validate_account(org, response['Account']) + response['Account']['Name'].should.equal(mockname) + response['Account']['Email'].should.equal(mockemail) + #assert False + +@mock_organizations +def test_list_accounts(): + client = boto3.client('organizations', region_name='us-east-1') + org = client.create_organization(FeatureSet='ALL')['Organization'] + for i in range(5): + name = mockname + str(i) + email = name + '@' + mockdomain + client.create_account(AccountName=name, Email=email) + response = client.list_accounts() + #print(yaml.dump(response, default_flow_style=False)) + response.should.have.key('Accounts') + accounts = response['Accounts'] + len(accounts).should.equal(5) + for account in accounts: + validate_account(org, account) + accounts[3]['Name'].should.equal(mockname + '3') + accounts[2]['Email'].should.equal(mockname + '2' + '@' + mockdomain) + #assert False + diff --git a/tests/test_organizations/test_organizations_utils.py b/tests/test_organizations/test_organizations_utils.py new file mode 100644 index 000000000..8144c327f --- /dev/null +++ b/tests/test_organizations/test_organizations_utils.py @@ -0,0 +1,26 @@ +from __future__ import unicode_literals + +import sure # noqa +import moto +from moto.organizations import utils + +ORG_ID_REGEX = r'o-[a-z0-9]{%s}' % utils.ORG_ID_SIZE +ROOT_ID_REGEX = r'r-[a-z0-9]{%s}' % utils.ROOT_ID_SIZE +ACCOUNT_ID_REGEX = r'[0-9]{%s}' % utils.ACCOUNT_ID_SIZE +CREATE_ACCOUNT_STATUS_ID_REGEX = r'car-[a-z0-9]{%s}' % utils.CREATE_ACCOUNT_STATUS_ID_SIZE + +def test_make_random_org_id(): + org_id = utils.make_random_org_id() + org_id.should.match(ORG_ID_REGEX) + +def test_make_random_root_id(): + org_id = utils.make_random_root_id() + org_id.should.match(ROOT_ID_REGEX) + +def test_make_random_account_id(): + account_id = utils.make_random_account_id() + account_id.should.match(ACCOUNT_ID_REGEX) + +def test_make_random_create_account_status_id(): + create_account_status_id = utils.make_random_create_account_status_id() + create_account_status_id.should.match(CREATE_ACCOUNT_STATUS_ID_REGEX)