Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
sueastside committed Oct 19, 2021
1 parent 242fb97 commit 08f9738
Show file tree
Hide file tree
Showing 12 changed files with 371 additions and 0 deletions.
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## Abdicate

Abdicate is an opinionated technology-agnostic specification to model applications and their resource dependency relationships.
It makes no assumptions about the technical implementation of the deployment or target system,
allowing the same model throughout the entire life of the applications and without having to commit to a technology/tooling stack.

Knowledge encapsulation

Application model

Convention


## Help

See [examples](tree/master/examples) for more details.

## Installation

Install using `pip install -U abdicate-spec`.

Or `pip install git+https://github.com/abdicate-io/abdicate-spec.git`


## A Simple Example

```yaml
version: "1.0"

friendlyName: petstore-ws
domains:
- ecommerce
components:
- pets
requires:
databases:
orm:
alias: db
interface: mysql:5
provides:
rest:
interface: http
x-url: /v1/
x-swagger-url: /swagger-ui.html
```
## Inspiration
Docker compose, Helm charts, Terraform, Juju, ...
19 changes: 19 additions & 0 deletions examples/django.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# yaml-language-server: $schema=abdicate-1.0.schema.json
version: "1.0"

friendlyName: my-website
requires:
databases:
orm:
alias: db
interface: postgres:10
celery_broker:
alias: redis
interface: redis:5
services:
com.org.backend:celery_worker:
alias: celery
provides:
website:
interface: http
x-port: 8000
19 changes: 19 additions & 0 deletions examples/petstore.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# yaml-language-server: $abdicate-1.0.schema.json
version: "1.0"

baseImage: gcr.io/distroless/java
friendlyName: petstore-ws
domains:
- ecommerce
components:
- pets
requires:
databases:
orm:
alias: db
interface: mysql:5
provides:
rest:
interface: http
x-url: /v1/
x-swagger-url: /swagger-ui.html
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[build-system]
requires = [
"setuptools>=42",
"wheel"
]
build-backend = "setuptools.build_meta"
27 changes: 27 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import setuptools

with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()

setuptools.setup(
name="abdicate-spec",
version="0.0.1",
author="Jelle Hellemans",
author_email="author@example.com",
description="Abdicate is a specification to declare an application and its resource dependency relationships.",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/abdicate-io",
project_urls={
"Bug Tracker": "https://github.com/abdicate-io/abdicate-spec/issues",
},
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
package_dir={"": "src"},
packages=setuptools.find_packages(where="src"),
python_requires=">=3.7",
test_suite="tests",
)
13 changes: 13 additions & 0 deletions src/abdicate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from pydantic import parse_obj_as

from abdicate import model_1_0

_VERSIONS = {
'1.0': model_1_0.Application,
}

def parse_object(object):
version = object.get('version')
if version not in _VERSIONS:
raise ValueError('Version "{}" not supported, choice from: {}'.format(version, ', '.join(_VERSIONS.keys())))
return parse_obj_as(_VERSIONS.get(version), object)
10 changes: 10 additions & 0 deletions src/abdicate/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import argparse

from abdicate import _VERSIONS

if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Produce a json schema.')
parser.add_argument('--version', help='version of the schema.', required=True, choices=_VERSIONS.keys())

args = parser.parse_args()
print(_VERSIONS.get(args.version).schema_json(indent=2))
128 changes: 128 additions & 0 deletions src/abdicate/model_1_0.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import re
from enum import Enum
from typing import Dict, Optional, List
from pydantic import BaseModel, constr, Extra, root_validator, Field

DNSNAME_REGEX = r'^(?![0-9]+$)(?!-)[a-zA-Z0-9-]{,63}(?<!-)$'
ARTIFACT_REGEX = r'^([a-z\-_\.]+)(:([a-z\-_]+))?$'
IMAGE_TAG_REGEX = r'^(?:(?=[^:\/]{4,253})(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-))*(?::[0-9]{1,5})?/)?((?![._-])(?:[a-z0-9._-]*)(?<![._-])(?:/(?![._-])[a-z0-9._-]*(?<![._-]))*)((?::(?![.-])[a-zA-Z0-9_.-]{1,128})|@sha256:[a-z0-9]+)?$'


class MountPermissionEnum(str, Enum):
read = 'read'
write = 'write'
read_write = 'read_write'


class DSPermissionEnum(str, Enum):
read = 'read'
read_write = 'read_write'


class ExBaseModel(BaseModel):
class Config:
extra = Extra.allow
schema_extra = {
'patternProperties': {'^x-': {}},
}
@root_validator
def check_extra_fields(cls, values):
"""Make sure extra fields are only valid fields matching regex"""
pattern_regex = r'^x-'

for k, v in list(values.items()):
if k in cls.__fields__:
continue

assert re.match(pattern_regex, k), 'extra field "{}" not allowed (custom properties can be set with prefix x-)'.format(k)
values[re.sub(pattern_regex, 'x_', k)] = values.pop(k)

return values


class Resource(ExBaseModel):
alias: Optional[str] = None


class Functional(ExBaseModel):
domains: Optional[List[str]] = Field(description='List of functional domains this application belongs to.')
components: Optional[List[str]] = Field(description='List of functional components this application belongs to.')


class Database(Resource):
"""
A Database that will be utilized by the application
These are databases for which the schema is managed by the application.
"""
interface: Optional[str] = None


class DataStore(Resource):
"""
A dataStore that will be utilized by the application
These are databases for which the application isn't the owner of the schemas.
"""
permission: DSPermissionEnum = DSPermissionEnum.read


class Service(Resource):
"""
A service that will be utilized by the application
"""
pass


class Queue(Resource):
"""
A queue that will be utilized by the application
"""
pass


class Queues(Resource):
receive: Optional[Dict[constr(regex=ARTIFACT_REGEX), Queue]] = Field(description='List queues with incoming messages')
send: Optional[Dict[constr(regex=ARTIFACT_REGEX), Queue]] = Field(description='List queues with outgoing messages')


class Property(Resource):
"""
A property that will be utilized by the application
"""
pass


class Mount(Resource):
"""
A file or directory that will be utilized by the application
"""
permission: MountPermissionEnum = MountPermissionEnum.read


class Requires(ExBaseModel):
databases: Optional[Dict[constr(regex=ARTIFACT_REGEX), Database]]
datastores: Optional[Dict[constr(regex=ARTIFACT_REGEX), DataStore]]
services: Optional[Dict[constr(regex=ARTIFACT_REGEX), Service]]
queues: Optional[Queues]
properties: Optional[Dict[constr(regex=ARTIFACT_REGEX), Property]]
mounts: Optional[Dict[constr(regex=ARTIFACT_REGEX), Mount]]


class Provided(ExBaseModel):
"""
A provided interface that will be exposed by the application
"""
interface: Optional[str] = None


class Application(ExBaseModel):
"""
Dependencies and description of the application.
"""
version: str

baseImage: Optional[constr(regex=IMAGE_TAG_REGEX)]
friendlyName: Optional[constr(regex=DNSNAME_REGEX)]
functional: Optional[Functional]

requires: Optional[Requires]
provides: Optional[Dict[constr(regex=ARTIFACT_REGEX), Provided]]
Empty file added tests/__init__.py
Empty file.
63 changes: 63 additions & 0 deletions tests/constraint_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import unittest

import re

from pydantic import parse_obj_as
from pydantic.error_wrappers import ValidationError
from abdicate.model_1_0 import ARTIFACT_REGEX, DNSNAME_REGEX, IMAGE_TAG_REGEX

class ConstraintTests(unittest.TestCase):
def test_valid_artifact_ids(self):
for i, string in enumerate(['com.org.package:artifact', 'artifact', 'test:test', 'test_test', 'test-test']):
with self.subTest(i=i, msg=string):
self.assertTrue(re.match(ARTIFACT_REGEX, string), '{} does not match {}'.format(string, ARTIFACT_REGEX))


def test_invalid_artifact_ids(self):
for i, string in enumerate(['com.org.package/artifact', 'Artifact', 'test@test']):
with self.subTest(i=i, msg=string):
self.assertFalse(re.match(ARTIFACT_REGEX, string), '{} does match {}'.format(string, ARTIFACT_REGEX))


def test_dnsnames(self):
tests = [
('01010', False),
('abc', True),
('A0c', True),
('A0c-', False),
('-A0c', False),
('A-0c', True),
('o123456701234567012345670123456701234567012345670123456701234567', False),
('o12345670123456701234567012345670123456701234567012345670123456', True),
('', True),
('a', True),
('0--0', True),
]

for i, (string, expected) in enumerate(tests):
with self.subTest(i=i, msg=string):
self.assertEquals(re.match(DNSNAME_REGEX, string) is not None, expected, '{} {} match {}'.format(string, ("doesn't" if expected else "does"), DNSNAME_REGEX))


def test_imagetags(self):
tests = [
('FOO', False),
('myregistryhost:5000/fedora/httpd:version1.0', True),
('fedora/httpd:version1.0.test', True),
('fedora/httpd:version1.0', True),
('rabbit:3', True),
('rabbit', True),
('registry/rabbit:3', True),
('registry/rabbit', True),
('rabbit@sha256:3235326357dfb65f1781dbc4df3b834546d8bf914e82cce58e6e6b6', True),
('registry/rabbit@sha256:3235326357dfb65f1781dbc4df3b834546d8bf914e82cce58e6e6b6', True),
('rabbit@sha256:3235326357dfb65f1781dbc4df3b834546d8bf914e82cce58e.6e6b6', False),
]

for i, (string, expected) in enumerate(tests):
with self.subTest(i=i, msg=string):
self.assertEquals(re.match(IMAGE_TAG_REGEX, string) is not None, expected, '{} {} match {}'.format(string, ("doesn't" if expected else "does"), IMAGE_TAG_REGEX))


if __name__ == '__main__':
unittest.main()
19 changes: 19 additions & 0 deletions tests/model_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import unittest

from pydantic import parse_obj_as
from pydantic.error_wrappers import ValidationError
from abdicate.model_1_0 import Application

class ModelTests(unittest.TestCase):
def test_application_allows_extenions(self):
instance = {'version': '1.0', 'x-test': 'something'}
item = parse_obj_as(Application, instance)
self.assertEqual(item.x_test, 'something')

def test_application_does_not_allows_unknown_properties(self):
instance = {'version': '1.0', 'unknown': True}
with self.assertRaises(ValidationError):
item = parse_obj_as(Application, instance)

if __name__ == '__main__':
unittest.main()
19 changes: 19 additions & 0 deletions tests/parse_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import unittest


from pydantic.error_wrappers import ValidationError
from abdicate import parse_object

class ParseTests(unittest.TestCase):
def test_parse_databases(self):
instance = {'version': '1.0', 'requires': {'databases': {'orm': {'alias': 'db'}}}}
item = parse_object(instance)
self.assertEqual(item.requires.databases.get('orm').alias, 'db')

def test_parse_queues(self):
instance = {'version': '1.0', 'requires': {'queues': {'send': {'io.abdicate.queues:functional_queue_name': {'alias': 'receiveQueue'}}}}}
item = parse_object(instance)
self.assertEqual(item.requires.queues.send.get('io.abdicate.queues:functional_queue_name').alias, 'receiveQueue')

if __name__ == '__main__':
unittest.main()

0 comments on commit 08f9738

Please sign in to comment.