From 38e58cfd30200aa4156856f7622fe7e3640a9f23 Mon Sep 17 00:00:00 2001 From: Bryan Strassner Date: Fri, 25 Aug 2017 17:57:27 -0500 Subject: [PATCH] Add Action API This change introduces a large section of the API for the next major version of Shipyard - the action api. By interfacing with Airflow, Shipyard will invoke workflows and allow for controlling and querying status of those workflows. Foundationally, this patchset introduces a lot of framework code for other apis, including error handling to a common output format, database interaction for persistence of action information, and use of oslo_config for configuration support. Add GET all actions primary code - db connection not yet impl Update base classes to have more structure Add POST actions framework Add GET action by id Add GET of validations and steps Add control api Add unit tests of action api methods Re-Removed duplicate deps from test reqs Add routes for API Removed a lot of code better handled by falcon directly Cleaned up error flows- handlers and defaults Refactored existing airflow tests to match standard output format Updated json validation to be more specific Added basic start for alembic Added alembic upgrade at startup Added table creation definitions Added base revision for alembic upgrade Bug fixes - DB queries, airflow comm, logic issues, logging issues Bug fixes - date formats and alignment of keys between systems Exclusions to bandit / tox.ini Resolved merge conflicts with integration of auth Update to use oslo config and PBR Update the context middleware to check uuid in a less contentious way Removed routes and resources for regions endpoint - not used Add auth policies for action api Restructure execptions to be consistent class hierarchy and common handler Add generation of config and policy examples Update tests to init configs Update database configs to not use env. vars Removed examples directory, it was no longer accurate Addressed/removed several TODOs - left some behind as well Aligned input to DAGs with action: header Retrieved all sub-steps for dags Expanded step information Refactored auth handling for better logging rename create_actions policy to create_action removed some templated file comments in env.py generated by alembic updated inconsistent exception parameters updated to use ulid instead of uuid for action ids added action control audit code per review suggestion Fixed correlation date betwen dags/actions by more string parsing Change-Id: I2f9ea5250923f45456aa86826e344fc055bba762 --- .gitignore | 3 + AUTHORS | 13 + Dockerfile | 3 - alembic.ini | 69 +++ alembic/README | 1 + alembic/env.py | 81 ++++ alembic/script.py.mako | 24 + .../51b92375e5c4_initial_shipyard_base.py | 82 ++++ docs/API.md | 2 +- entrypoint.sh | 9 +- .../control => etc/shipyard}/api-paste.ini | 0 etc/shipyard/policy.yaml.sample | 27 ++ etc/shipyard/shipyard.conf.sample | 310 +++++++++++++ examples/manifests/README.md | 60 --- examples/manifests/hostprofile.yaml | 151 ------- examples/manifests/hwdefinition.yaml | 58 --- examples/manifests/manifest_hierarchy.png | Bin 112581 -> 0 bytes examples/manifests/network.yml | 230 ---------- examples/manifests/region_manifest.yml | 60 --- examples/manifests/servers.yaml | 420 ------------------ generator/config-generator.conf | 5 + generator/policy-generator.conf | 3 + requirements.txt | 24 +- setup.cfg | 28 ++ setup.py | 29 +- shipyard_airflow/airflow_client.py | 17 - shipyard_airflow/conf/__init__.py | 0 shipyard_airflow/conf/config.py | 250 +++++++++++ shipyard_airflow/conf/opts.py | 89 ++++ shipyard_airflow/config.py | 202 --------- shipyard_airflow/control/__init__.py | 13 - shipyard_airflow/control/action_helper.py | 63 +++ shipyard_airflow/control/actions_api.py | 330 ++++++++++++++ .../control/actions_control_api.py | 129 ++++++ shipyard_airflow/control/actions_id_api.py | 117 +++++ .../control/actions_steps_id_api.py | 84 ++++ .../control/actions_validations_id_api.py | 77 ++++ shipyard_airflow/control/api.py | 58 ++- shipyard_airflow/control/base.py | 179 ++++---- shipyard_airflow/control/health.py | 11 +- shipyard_airflow/control/json_schemas.py | 126 ++++++ shipyard_airflow/control/middleware.py | 37 +- .../control/shipyard.conf.example | 320 ------------- shipyard_airflow/db/__init__.py | 0 shipyard_airflow/db/airflow_db.py | 234 ++++++++++ shipyard_airflow/db/common_db.py | 121 +++++ .../db/db.py | 19 +- .../{control/regions.py => db/errors.py} | 23 +- shipyard_airflow/db/shipyard_db.py | 254 +++++++++++ shipyard_airflow/errors.py | 247 ++++++++-- shipyard_airflow/policy.py | 161 +++++-- shipyard_airflow/setup.py | 32 -- shipyard_airflow/shipyard.py | 45 +- tests/unit/control/__init__.py | 23 + tests/unit/control/test_actions_api.py | 239 ++++++++++ .../unit/control/test_actions_control_api.py | 164 +++++++ tests/unit/control/test_actions_id_api.py | 152 +++++++ .../unit/control/test_actions_steps_id_api.py | 116 +++++ .../test_actions_validations_id_api.py | 87 ++++ tox.ini | 16 +- 60 files changed, 3883 insertions(+), 1844 deletions(-) create mode 100644 AUTHORS create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/51b92375e5c4_initial_shipyard_base.py rename {shipyard_airflow/control => etc/shipyard}/api-paste.ini (100%) create mode 100644 etc/shipyard/policy.yaml.sample create mode 100644 etc/shipyard/shipyard.conf.sample delete mode 100644 examples/manifests/README.md delete mode 100644 examples/manifests/hostprofile.yaml delete mode 100644 examples/manifests/hwdefinition.yaml delete mode 100644 examples/manifests/manifest_hierarchy.png delete mode 100644 examples/manifests/network.yml delete mode 100644 examples/manifests/region_manifest.yml delete mode 100644 examples/manifests/servers.yaml create mode 100644 generator/config-generator.conf create mode 100644 generator/policy-generator.conf create mode 100644 setup.cfg delete mode 100644 shipyard_airflow/airflow_client.py create mode 100644 shipyard_airflow/conf/__init__.py create mode 100644 shipyard_airflow/conf/config.py create mode 100644 shipyard_airflow/conf/opts.py delete mode 100644 shipyard_airflow/config.py create mode 100644 shipyard_airflow/control/action_helper.py create mode 100644 shipyard_airflow/control/actions_api.py create mode 100644 shipyard_airflow/control/actions_control_api.py create mode 100644 shipyard_airflow/control/actions_id_api.py create mode 100644 shipyard_airflow/control/actions_steps_id_api.py create mode 100644 shipyard_airflow/control/actions_validations_id_api.py create mode 100644 shipyard_airflow/control/json_schemas.py delete mode 100644 shipyard_airflow/control/shipyard.conf.example create mode 100644 shipyard_airflow/db/__init__.py create mode 100644 shipyard_airflow/db/airflow_db.py create mode 100644 shipyard_airflow/db/common_db.py rename examples/manifests/services.yaml => shipyard_airflow/db/db.py (59%) rename shipyard_airflow/{control/regions.py => db/errors.py} (58%) create mode 100644 shipyard_airflow/db/shipyard_db.py delete mode 100644 shipyard_airflow/setup.py create mode 100644 tests/unit/control/test_actions_api.py create mode 100644 tests/unit/control/test_actions_control_api.py create mode 100644 tests/unit/control/test_actions_id_api.py create mode 100644 tests/unit/control/test_actions_steps_id_api.py create mode 100644 tests/unit/control/test_actions_validations_id_api.py diff --git a/.gitignore b/.gitignore index 7bbc71c0..67c4c471 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,6 @@ ENV/ # mypy .mypy_cache/ + +# Generated bogus docs +ChangeLog \ No newline at end of file diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 00000000..963b8e3c --- /dev/null +++ b/AUTHORS @@ -0,0 +1,13 @@ +Alan Meadows +Anthony Lin +Bryan Strassner +Felipe Monteiro +Mark Burnett +One-Fine-Day +Pete Birley +Rodolfo +Scott Hussey +Stacey Fletcher +Tin Lam +Vamsi Krishna Surapureddi +eanylin diff --git a/Dockerfile b/Dockerfile index d13ce6e8..6b1d8d3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,9 +60,6 @@ COPY ./ /home/shipyard/shipyard # Copy entrypoint.sh to /home/shipyard COPY entrypoint.sh /home/shipyard/entrypoint.sh -# Copy shipyard.conf to /home/shipyard -COPY ./shipyard_airflow/control/shipyard.conf /home/shipyard/shipyard.conf - # Change permissions RUN chown -R shipyard: /home/shipyard \ && chmod +x /home/shipyard/entrypoint.sh diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 00000000..a9e77cc9 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,69 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +#Uses the envrionment variable instead: DB_CONN_SHIPYARD +sqlalchemy.url = NOT_APPLICABLE + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 00000000..5c0bb68f --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,81 @@ +from __future__ import with_statement + +import os +from logging.config import fileConfig + +from alembic import context +from oslo_config import cfg +from sqlalchemy import create_engine, pool + +# this is the shipyard config object +CONF = cfg.CONF + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.attributes.get('configure_logger', True): + fileConfig(config.config_file_name) + +target_metadata = None + + +def get_url(): + """ + Returns the url to use instead of using the alembic configuration + file + """ + return CONF.base.postgresql_db + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = get_url() + # Default code: url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = create_engine(get_url()) + # Default/generated code: + # connectable = engine_from_config( + # config.get_section(config.config_ini_section), + # prefix='sqlalchemy.', + # poolclass=pool.NullPool) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/51b92375e5c4_initial_shipyard_base.py b/alembic/versions/51b92375e5c4_initial_shipyard_base.py new file mode 100644 index 00000000..d21a1a4a --- /dev/null +++ b/alembic/versions/51b92375e5c4_initial_shipyard_base.py @@ -0,0 +1,82 @@ +"""initial shipyard base + +Revision ID: 51b92375e5c4 +Revises: +Create Date: 2017-09-12 11:12:23.768269 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import (types, func) +from sqlalchemy.dialects import postgresql as pg + + +# revision identifiers, used by Alembic. +revision = '51b92375e5c4' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + """ + Create the initial tables needed by shipyard + 26 character IDs are ULIDs. See: https://github.com/mdipierro/ulid + """ + op.create_table( + 'actions', + # ULID key for the action + sa.Column('id', types.String(26), primary_key=True), + # The name of the action invoked + sa.Column('name', types.String(50), nullable=False), + # The parameters passed by the user to the action + sa.Column('parameters', pg.JSONB, nullable=True), + # The DAG/workflow name used in airflow, if applicable + sa.Column('dag_id', sa.Text, nullable=True), + # The DAG/workflow execution time string from airflow, if applicable + sa.Column('dag_execution_date', sa.Text, nullable=True), + # The invoking user + sa.Column('user', sa.Text, nullable=False), + # Timestamp of when an action was invoked + sa.Column('datetime', + types.TIMESTAMP(timezone=True), + server_default=func.now()), + # The user provided or shipayrd generated context marker + sa.Column('context_marker', types.String(36), nullable=False) + ) + + op.create_table( + 'preflight_validation_failures', + # ID (ULID) of the preflight validation failure + sa.Column('id', types.String(26), primary_key=True), + # The ID of action this failure is associated with + sa.Column('action_id', types.String(26), nullable=False), + # The common language name of the validation that failed + sa.Column('validation_name', sa.Text, nullable=True), + # The text indicating details of the failure + sa.Column('details', sa.Text, nullable=True), + ) + + op.create_table( + 'action_command_audit', + # ID (ULID) of the audit + sa.Column('id', types.String(26), primary_key=True), + # The ID of action this audit record + sa.Column('action_id', types.String(26), nullable=False), + # The text indicating command invoked + sa.Column('command', sa.Text, nullable=False), + # The user that invoked the command + sa.Column('user', sa.Text, nullable=False), + # Timestamp of when the command was invoked + sa.Column('datetime', + types.TIMESTAMP(timezone=True), + server_default=func.now()), + ) + +def downgrade(): + """ + Remove the database objects created by this revision + """ + op.drop_table('actions') + op.drop_table('preflight_validation_failures') + op.drop_table('action_command_audit') diff --git a/docs/API.md b/docs/API.md index 4f5f41f2..e238187a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -276,7 +276,7 @@ Returns the details for a step by id for the given action by Id. * 200 OK --- -### /v1.0/actions/{action_id}/{control_verb} +### /v1.0/actions/{action_id}/control/{control_verb} Allows for issuing DAG controls against an action. #### Payload Structure diff --git a/entrypoint.sh b/entrypoint.sh index 83b05ab6..4b59c5f1 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -14,7 +14,10 @@ # See the License for the specific language governing permissions and # limitations under the License. - # Start shipyard application -exec uwsgi --http :9000 -w shipyard_airflow.shipyard --callable shipyard --enable-threads -L - +exec uwsgi \ + --http :9000 \ + --paste config:/etc/shipyard/api-paste.ini \ + --enable-threads \ + -L \ + --pyargv "--config-file /etc/shipyard/shipyard.conf" \ No newline at end of file diff --git a/shipyard_airflow/control/api-paste.ini b/etc/shipyard/api-paste.ini similarity index 100% rename from shipyard_airflow/control/api-paste.ini rename to etc/shipyard/api-paste.ini diff --git a/etc/shipyard/policy.yaml.sample b/etc/shipyard/policy.yaml.sample new file mode 100644 index 00000000..c78259c0 --- /dev/null +++ b/etc/shipyard/policy.yaml.sample @@ -0,0 +1,27 @@ +# Actions requiring admin authority +#"admin_required": "role:admin" + +# List workflow actions invoked by users +# GET /api/v1.0/actions +#"workflow_orchestrator:list_actions": "rule:admin_required" + +# Create a workflow action +# POST /api/v1.0/actions +#"workflow_orchestrator:create_actions": "rule:admin_required" + +# Retreive an action by its id +# GET /api/v1.0/actions/{action_id} +#"workflow_orchestrator:get_action": "rule:admin_required" + +# Retreive an action step by its id +# GET /api/v1.0/actions/{action_id}/steps/{step_id} +#"workflow_orchestrator:get_action_step": "rule:admin_required" + +# Retreive an action validation by its id +# GET /api/v1.0/actions/{action_id}/validations/{validation_id} +#"workflow_orchestrator:get_action_validation": "rule:admin_required" + +# Send a control to an action +# POST /api/v1.0/actions/{action_id}/control/{control_verb} +#"workflow_orchestrator:invoke_action_control": "rule:admin_required" + diff --git a/etc/shipyard/shipyard.conf.sample b/etc/shipyard/shipyard.conf.sample new file mode 100644 index 00000000..74b58ab5 --- /dev/null +++ b/etc/shipyard/shipyard.conf.sample @@ -0,0 +1,310 @@ +[DEFAULT] + + +[armada] + +# +# From shipyard_airflow +# + +# FQDN for the armada service (string value) +#host = armada-int.ucp + +# Port for the armada service (integer value) +#port = 8000 + + +[base] + +# +# From shipyard_airflow +# + +# The web server for Airflow (string value) +#web_server = http://localhost:32080 + +# The database for shipyard (string value) +#postgresql_db = postgresql+psycopg2://shipyard:changeme@postgresql.ucp:5432/shipyard + +# The database for airflow (string value) +#postgresql_airflow_db = postgresql+psycopg2://shipyard:changeme@postgresql.ucp:5432/airflow + +# The direcotry containing the alembic.ini file (string value) +#alembic_ini_path = /home/shipyard/shipyard + +# Upgrade the database on startup (boolean value) +#upgrade_db = true + + +[deckhand] + +# +# From shipyard_airflow +# + +# FQDN for the deckhand service (string value) +#host = deckhand-int.ucp + +# Port for the deckhand service (integer value) +#port = 80 + + +[drydock] + +# +# From shipyard_airflow +# + +# FQDN for the drydock service (string value) +#host = drydock-int.ucp + +# Port for the drydock service (integer value) +#port = 9000 + +# TEMPORARY: password for drydock (string value) +#token = bigboss + +# TEMPORARY: location of drydock yaml file (string value) +#site_yaml = /usr/local/airflow/plugins/drydock.yaml + +# TEMPORARY: location of promenade yaml file (string value) +#prom_yaml = /usr/local/airflow/plugins/promenade.yaml + + +[healthcheck] + +# +# From shipyard_airflow +# + +# Schema to perform health check with (string value) +#schema = http + +# Health check standard endpoint (string value) +#endpoint = /api/v1.0/health + + +[keystone] + +# +# From shipyard_airflow +# + +# The url for OpenStack Authentication (string value) +#OS_AUTH_URL = http://keystone-api.ucp:80/v3 + +# OpenStack project name (string value) +#OS_PROJECT_NAME = service + +# The OpenStack user domain name (string value) +#OS_USER_DOMAIN_NAME = Default + +# The OpenStack username (string value) +#OS_USERNAME = shipyard + +# THe OpenStack password for the shipyard svc acct (string value) +#OS_PASSWORD = password + +# The OpenStack user domain name (string value) +#OS_REGION_NAME = Regionone + +# The OpenStack identity api version (integer value) +#OS_IDENTITY_API_VERSION = 3 + + +[keystone_authtoken] + +# +# From keystonemiddleware.auth_token +# + +# Complete "public" Identity API endpoint. This endpoint should not be an +# "admin" endpoint, as it should be accessible by all end users. +# Unauthenticated clients are redirected to this endpoint to authenticate. +# Although this endpoint should ideally be unversioned, client support in the +# wild varies. If you're using a versioned v2 endpoint here, then this should +# *not* be the same endpoint the service user utilizes for validating tokens, +# because normal end users may not be able to reach that endpoint. (string +# value) +#auth_uri = + +# API version of the admin Identity API endpoint. (string value) +#auth_version = + +# Do not handle authorization requests within the middleware, but delegate the +# authorization decision to downstream WSGI components. (boolean value) +#delay_auth_decision = false + +# Request timeout value for communicating with Identity API server. (integer +# value) +#http_connect_timeout = + +# How many times are we trying to reconnect when communicating with Identity +# API Server. (integer value) +#http_request_max_retries = 3 + +# Request environment key where the Swift cache object is stored. When +# auth_token middleware is deployed with a Swift cache, use this option to have +# the middleware share a caching backend with swift. Otherwise, use the +# ``memcached_servers`` option instead. (string value) +#cache = + +# Required if identity server requires client certificate (string value) +#certfile = + +# Required if identity server requires client certificate (string value) +#keyfile = + +# A PEM encoded Certificate Authority to use when verifying HTTPs connections. +# Defaults to system CAs. (string value) +#cafile = + +# Verify HTTPS connections. (boolean value) +#insecure = false + +# The region in which the identity server can be found. (string value) +#region_name = + +# DEPRECATED: Directory used to cache files related to PKI tokens. This option +# has been deprecated in the Ocata release and will be removed in the P +# release. (string value) +# This option is deprecated for removal since Ocata. +# Its value may be silently ignored in the future. +# Reason: PKI token format is no longer supported. +#signing_dir = + +# Optionally specify a list of memcached server(s) to use for caching. If left +# undefined, tokens will instead be cached in-process. (list value) +# Deprecated group/name - [keystone_authtoken]/memcache_servers +#memcached_servers = + +# In order to prevent excessive effort spent validating tokens, the middleware +# caches previously-seen tokens for a configurable duration (in seconds). Set +# to -1 to disable caching completely. (integer value) +#token_cache_time = 300 + +# DEPRECATED: Determines the frequency at which the list of revoked tokens is +# retrieved from the Identity service (in seconds). A high number of revocation +# events combined with a low cache duration may significantly reduce +# performance. Only valid for PKI tokens. This option has been deprecated in +# the Ocata release and will be removed in the P release. (integer value) +# This option is deprecated for removal since Ocata. +# Its value may be silently ignored in the future. +# Reason: PKI token format is no longer supported. +#revocation_cache_time = 10 + +# (Optional) If defined, indicate whether token data should be authenticated or +# authenticated and encrypted. If MAC, token data is authenticated (with HMAC) +# in the cache. If ENCRYPT, token data is encrypted and authenticated in the +# cache. If the value is not one of these options or empty, auth_token will +# raise an exception on initialization. (string value) +# Allowed values: None, MAC, ENCRYPT +#memcache_security_strategy = None + +# (Optional, mandatory if memcache_security_strategy is defined) This string is +# used for key derivation. (string value) +#memcache_secret_key = + +# (Optional) Number of seconds memcached server is considered dead before it is +# tried again. (integer value) +#memcache_pool_dead_retry = 300 + +# (Optional) Maximum total number of open connections to every memcached +# server. (integer value) +#memcache_pool_maxsize = 10 + +# (Optional) Socket timeout in seconds for communicating with a memcached +# server. (integer value) +#memcache_pool_socket_timeout = 3 + +# (Optional) Number of seconds a connection to memcached is held unused in the +# pool before it is closed. (integer value) +#memcache_pool_unused_timeout = 60 + +# (Optional) Number of seconds that an operation will wait to get a memcached +# client connection from the pool. (integer value) +#memcache_pool_conn_get_timeout = 10 + +# (Optional) Use the advanced (eventlet safe) memcached client pool. The +# advanced pool will only work under python 2.x. (boolean value) +#memcache_use_advanced_pool = false + +# (Optional) Indicate whether to set the X-Service-Catalog header. If False, +# middleware will not ask for service catalog on token validation and will not +# set the X-Service-Catalog header. (boolean value) +#include_service_catalog = true + +# Used to control the use and type of token binding. Can be set to: "disabled" +# to not check token binding. "permissive" (default) to validate binding +# information if the bind type is of a form known to the server and ignore it +# if not. "strict" like "permissive" but if the bind type is unknown the token +# will be rejected. "required" any form of token binding is needed to be +# allowed. Finally the name of a binding method that must be present in tokens. +# (string value) +#enforce_token_bind = permissive + +# DEPRECATED: If true, the revocation list will be checked for cached tokens. +# This requires that PKI tokens are configured on the identity server. (boolean +# value) +# This option is deprecated for removal since Ocata. +# Its value may be silently ignored in the future. +# Reason: PKI token format is no longer supported. +#check_revocations_for_cached = false + +# DEPRECATED: Hash algorithms to use for hashing PKI tokens. This may be a +# single algorithm or multiple. The algorithms are those supported by Python +# standard hashlib.new(). The hashes will be tried in the order given, so put +# the preferred one first for performance. The result of the first hash will be +# stored in the cache. This will typically be set to multiple values only while +# migrating from a less secure algorithm to a more secure one. Once all the old +# tokens are expired this option should be set to a single value for better +# performance. (list value) +# This option is deprecated for removal since Ocata. +# Its value may be silently ignored in the future. +# Reason: PKI token format is no longer supported. +#hash_algorithms = md5 + +# A choice of roles that must be present in a service token. Service tokens are +# allowed to request that an expired token can be used and so this check should +# tightly control that only actual services should be sending this token. Roles +# here are applied as an ANY check so any role in this list must be present. +# For backwards compatibility reasons this currently only affects the +# allow_expired check. (list value) +#service_token_roles = service + +# For backwards compatibility reasons we must let valid service tokens pass +# that don't pass the service_token_roles check as valid. Setting this true +# will become the default in a future release and should be enabled if +# possible. (boolean value) +#service_token_roles_required = false + +# Authentication type to load (string value) +# Deprecated group/name - [keystone_authtoken]/auth_plugin +#auth_type = + +# Config Section from which to load plugin specific options (string value) +#auth_section = + + +[logging] + +# +# From shipyard_airflow +# + +# The default logging level for the root logger. ERROR=40, WARNING=30, INFO=20, +# DEBUG=10 (integer value) +#log_level = 10 + + +[shipyard] + +# +# From shipyard_airflow +# + +# FQDN for the shipyard service (string value) +#host = shipyard-int.ucp + +# Port for the shipyard service (integer value) +#port = 9000 diff --git a/examples/manifests/README.md b/examples/manifests/README.md deleted file mode 100644 index e2746a0e..00000000 --- a/examples/manifests/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# Shipyard Manifests - ----- - -Shipyard manifests contain the examination of the payloads that the shipyard api will receive. -A complete manifest will consist of multiple yaml file's assembled in some way. Each yaml file will follow -Kubernetes style artifact definition. - -The high level expectation of what the data on this manifests will define is pictured here : - - - ----- - -## region_manifest.yaml - -Region is the largest resource shipyard can understand. -A region manifest will need to define : - -- Identity of the Region. Perhaps a name will suffice, but a UUID generated by shipyard might be applicable as well. -- Cloud : The type of cloud this region is running on. i.e. AIC, or AWS, or Google etc. -- deployOn : Whether the region UCP ( undercloud) is been deployed on VM's or Baremetal - ----- -## servers.yaml - ----- -## network.yaml - ----- -## hw_definition.yaml - ----- -## host_profile.yaml - ----- -## services.yaml - -Will define high level needs for all the services that need to run above the undercloud - -It relates to the files : - -## core_services.yaml -## clcp_services.yaml -## onap_services.yaml -## cdp_services.yaml - - ----- -## undercloud.yaml - -This file will incude the configuration aspects of the undercloud that are tunnables. -Such as : -i.e. --Security --RBAC definitions --Certificates --UCP Tunnables --Kernel Tunnables, etc --Agent Tunnables diff --git a/examples/manifests/hostprofile.yaml b/examples/manifests/hostprofile.yaml deleted file mode 100644 index 6ffb440d..00000000 --- a/examples/manifests/hostprofile.yaml +++ /dev/null @@ -1,151 +0,0 @@ -# Copyright 2017 AT&T Intellectual Property. All other rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -#################### -# -# bootstrap_seed.yaml - Site server design definition for physical layer -# -#################### -# version the schema in this file so consumers can rationally parse it - ---- -apiVersion: 'v1.0' -kind: HostProfile -metadata: - name: default - region: sitename - date: 17-FEB-2017 - author: sh8121@att.com - description: Describe layer 2/3 attributes. Primarily CIs used for configuring server interfaces - # No magic to this host_profile, it just provides a way to specify - # sitewide settings. If it is absent from a node's inheritance chain - # then these values will NOT be applied -spec: - # OOB (iLO, iDRAC, etc...) settings. Should prefer open standards such - # as IPMI over vender-specific when possible. - oob: - type: ipmi - # OOB networking should be preconfigured, but we can include a network - # definition for validation or enhancement (DNS registration) - network: oob - account: admin - credential: admin - # Specify storage layout of base OS. Ceph out of scope - storage: - # How storage should be carved up: lvm (logical volumes), flat - # (single partition) - layout: lvm - # Info specific to the boot and root disk/partitions - bootdisk: - # Device will specify an alias defined in hwdefinition.yaml - device: primary_boot - # For LVM, the size of the partition added to VG as a PV - # For flat, the size of the partition formatted as ext4 - root_size: 50g - # The /boot partition. If not specified, /boot will in root - boot_size: 2g - # Info for additional partitions. Need to balance between - # flexibility and complexity - partitions: - - name: logs - device: primary_boot - # Partition uuid if needed - part_uuid: 84db9664-f45e-11e6-823d-080027ef795a - size: 10g - # Optional, can carve up unformatted block devices - mountpoint: /var/log - fstype: ext4 - mount_options: defaults - # Filesystem UUID or label can be specified. UUID recommended - fs_uuid: cdb74f1c-9e50-4e51-be1d-068b0e9ff69e - fs_label: logs - # Platform (Operating System) settings - platform: - image: ubuntu_16.04_hwe - kernel_params: default - # Additional metadata to apply to a node - metadata: - # Base URL of the introspection service - may go in curtin data - introspection_url: http://172.16.1.10:9090 ---- -apiVersion: 'v1.0' -kind: HostProfile -metadata: - name: k8-node - region: sitename - date: 17-FEB-2017 - author: sh8121@att.com - description: Describe layer 2/3 attributes. Primarily CIs used for configuring server interfaces -spec: - # host_profile inheritance allows for deduplication of common CIs - # Inheritance is additive for CIs that are lists of multiple items - # To remove an inherited list member, prefix the primary key value - # with '!'. - host_profile: defaults - # Hardware profile will map hardware specific details to the abstract - # names uses in the host profile as well as specify hardware specific - # configs. A viable model should be to build a host profile without a - # hardware_profile and then for each node inherit the host profile and - # specify a hardware_profile to map that node's hardware to the abstract - # settings of the host_profile - hardware_profile: HPGen9v3 - # Network interfaces. - interfaces: - # Keyed on device_name - # pxe is a special marker indicating which device should be used for pxe boot - - device_name: pxe - # The network link attached to this - network_link: pxe - # Slaves will specify aliases from hwdefinition.yaml - slaves: - - prim_nic01 - # Which networks will be configured on this interface - networks: - - name: pxe - - device_name: bond0 - network_link: gp - # If multiple slaves are specified, but no bonding config - # is applied to the link, design validation will fail - slaves: - - prim_nic01 - - prim_nic02 - # If multiple networks are specified, but no trunking - # config is applied to the link, design validation will fail - networks: - - name: mgmt - - name: private - metadata: - # Explicit tag assignment - tags: - - 'test' - # MaaS supports key/value pairs. Not sure of the use yet - owner_data: - foo: bar ---- -apiVersion: 'v1.0' -kind: HostProfile -metadata: - name: k8-node-public - region: sitename - date: 17-FEB-2017 - author: sh8121@att.com - description: Describe layer 2/3 attributes. Primarily CIs used for configuring server interfaces -spec: - host_profile: k8-node - interfaces: - - device_name: bond0 - networks: - # This is additive, so adds a network to those defined in the host_profile - # inheritance chain - - name: public ---- \ No newline at end of file diff --git a/examples/manifests/hwdefinition.yaml b/examples/manifests/hwdefinition.yaml deleted file mode 100644 index d7daa741..00000000 --- a/examples/manifests/hwdefinition.yaml +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2017 AT&T Intellectual Property. All other rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -############################################################################# -# -# bootstrap_hwdefinition.yaml - Definitions of server hardware layout -# -############################################################################# -# version the schema in this file so consumers can rationally parse it ---- -apiVersion: 'v1.0' -kind: HardwareProfile -metadata: - name: HPGen8v3 - region: sitename - date: 17-FEB-2017 - description: Sample hardware definition - author: Scott Hussey -spec: - # Vendor of the server chassis - vendor: HP - # Generation of the chassis model - generation: '8' - # Version of the chassis model within its generation - not version of the hardware definition - hw_version: '3' - # The certified version of the chassis BIOS - bios_version: '2.2.3' - # Mode of the default boot of hardware - bios, uefi - boot_mode: bios - # Protocol of boot of the hardware - pxe, usb, hdd - bootstrap_protocol: pxe - # Which interface to use for network booting within the OOB manager, not OS device - pxe_interface: 0 - # Map hardware addresses to aliases/roles to allow a mix of hardware configs - # in a site to result in a consistent configuration - device_aliases: - pci: - - address: pci@0000:00:03.0 - alias: prim_nic01 - # type could identify expected hardware - used for hardware manifest validation - type: '82540EM Gigabit Ethernet Controller' - - address: pci@0000:00:04.0 - alias: prim_nic02 - type: '82540EM Gigabit Ethernet Controller' - scsi: - - address: scsi@2:0.0.0 - alias: primary_boot - type: 'VBOX HARDDISK' \ No newline at end of file diff --git a/examples/manifests/manifest_hierarchy.png b/examples/manifests/manifest_hierarchy.png deleted file mode 100644 index b571f21feab65475508c24c3b75693395d058778..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 112581 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.0.0 # Apache-2.0 +psycopg2==2.7.3.1 +python-dateutil==2.6.1 +python-openstackclient==3.11.0 +requests==2.18.4 +SQLAlchemy==1.1.13 +ulid==1.1 +uwsgi==2.0.15 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..96b41e17 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,28 @@ +[metadata] +name = shipyard +summary = Directed acyclic graph controller for Kubernetes and OpenStack control plane life cycle management +description-file = README.md + +author = undercloud team +home-page = https://github.com/att-comdev/shipyard +classifier = + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: Apache Software License + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + +[files] +packages = + shipyard_airflow + +[entry_points] +oslo.config.opts = + shipyard_airflow = shipyard_airflow.conf.opts:list_opts +oslo.policy.policies = + shipyard_airflow = shipyard_airflow.policy:list_policies + +[build_sphinx] +warning-is-error = True diff --git a/setup.py b/setup.py index f6d4d9b8..0cbf35b6 100644 --- a/setup.py +++ b/setup.py @@ -11,28 +11,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import setuptools -from setuptools import setup - -setup( - name='shipyard_airflow', - version='0.1a1', - description='API for managing Airflow-based orchestration', - url='http://github.com/att-comdev/shipyard', - author='Anthony Lin - AT&T', - author_email='al498u@att.com', - license='Apache 2.0', - packages=['shipyard_airflow', 'shipyard_airflow.control'], - entry_points={ - "oslo.policy.policies": - ["shipyard = shipyard.common.policies:list_rules"], - "oslo.config.opts": ["shipyard = shipyard.conf.opts:list_opts"] - }, - install_requires=[ - 'falcon', - 'requests', - 'configparser', - 'uwsgi>1.4', - 'python-dateutil', - 'oslo.config', - ]) +setuptools.setup( + setup_requires=['pbr>=2.0.0'], + pbr=True +) diff --git a/shipyard_airflow/airflow_client.py b/shipyard_airflow/airflow_client.py deleted file mode 100644 index bb1feabf..00000000 --- a/shipyard_airflow/airflow_client.py +++ /dev/null @@ -1,17 +0,0 @@ -import requests - -from shipyard_airflow.errors import AirflowError - - -class AirflowClient(object): - def __init__(self, url): - self.url = url - - def get(self): - response = requests.get(self.url).json() - - # This gives us more freedom to handle the responses from airflow - if response["output"]["stderr"]: - raise AirflowError(response["output"]["stderr"]) - else: - return response["output"]["stdout"] diff --git a/shipyard_airflow/conf/__init__.py b/shipyard_airflow/conf/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shipyard_airflow/conf/config.py b/shipyard_airflow/conf/config.py new file mode 100644 index 00000000..5cd98c67 --- /dev/null +++ b/shipyard_airflow/conf/config.py @@ -0,0 +1,250 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import logging + +import keystoneauth1.loading as ks_loading +from oslo_config import cfg + +from shipyard_airflow.conf.opts import ConfigSection + +CONF = cfg.CONF +SECTIONS = [ + ConfigSection( + name='base', + title='Base Configuration', + options=[ + cfg.StrOpt( + 'web_server', + default='http://localhost:32080', + help='The web server for Airflow' + ), + cfg.StrOpt( + 'postgresql_db', + default=( + 'postgresql+psycopg2://shipyard:changeme' + '@postgresql.ucp:5432/shipyard' + ), + help='The database for shipyard' + ), + cfg.StrOpt( + 'postgresql_airflow_db', + default=( + 'postgresql+psycopg2://shipyard:changeme' + '@postgresql.ucp:5432/airflow' + ), + help='The database for airflow' + ), + cfg.StrOpt( + 'alembic_ini_path', + default='/home/shipyard/shipyard', + help='The direcotry containing the alembic.ini file' + ), + cfg.BoolOpt( + 'upgrade_db', + default=True, + help='Upgrade the database on startup' + ) + ] + ), + ConfigSection( + name='logging', + title='Logging Options', + options=[ + cfg.IntOpt( + 'log_level', + default=logging.DEBUG, + help=('The default logging level for the root logger. ' + 'ERROR=40, WARNING=30, INFO=20, DEBUG=10') + ), + ] + ), + ConfigSection( + name='shipyard', + title='Shipyard connection info', + options=[ + cfg.StrOpt( + 'host', + default='shipyard-int.ucp', + help='FQDN for the shipyard service' + ), + cfg.IntOpt( + 'port', + default=9000, + help='Port for the shipyard service' + ), + ] + ), + ConfigSection( + name='deckhand', + title='Deckhand connection info', + options=[ + cfg.StrOpt( + 'host', + default='deckhand-int.ucp', + help='FQDN for the deckhand service' + ), + cfg.IntOpt( + 'port', + default=80, + help='Port for the deckhand service' + ), + ] + ), + ConfigSection( + name='armada', + title='Armada connection info', + options=[ + cfg.StrOpt( + 'host', + default='armada-int.ucp', + help='FQDN for the armada service' + ), + cfg.IntOpt( + 'port', + default=8000, + help='Port for the armada service' + ), + ] + ), + ConfigSection( + name='drydock', + title='Drydock connection info', + options=[ + cfg.StrOpt( + 'host', + default='drydock-int.ucp', + help='FQDN for the drydock service' + ), + cfg.IntOpt( + 'port', + default=9000, + help='Port for the drydock service' + ), + # TODO(Bryan Strassner) Remove this when integrated + cfg.StrOpt( + 'token', + default='bigboss', + help='TEMPORARY: password for drydock' + ), + # TODO(Bryan Strassner) Remove this when integrated + cfg.StrOpt( + 'site_yaml', + default='/usr/local/airflow/plugins/drydock.yaml', + help='TEMPORARY: location of drydock yaml file' + ), + # TODO(Bryan Strassner) Remove this when integrated + cfg.StrOpt( + 'prom_yaml', + default='/usr/local/airflow/plugins/promenade.yaml', + help='TEMPORARY: location of promenade yaml file' + ), + ] + ), + ConfigSection( + name='healthcheck', + title='Healthcheck connection info', + options=[ + cfg.StrOpt( + 'schema', + default='http', + help='Schema to perform health check with' + ), + cfg.StrOpt( + 'endpoint', + default='/api/v1.0/health', + help='Health check standard endpoint' + ), + ] + ), + # TODO (Bryan Strassner) This section is in use by the operators we send + # to the airflow pod(s). Needs to be refactored out + # when those operators are updated. + ConfigSection( + name='keystone', + title='Keystone connection and credential information', + options=[ + cfg.StrOpt( + 'OS_AUTH_URL', + default='http://keystone-api.ucp:80/v3', + help='The url for OpenStack Authentication' + ), + cfg.StrOpt( + 'OS_PROJECT_NAME', + default='service', + help='OpenStack project name' + ), + cfg.StrOpt( + 'OS_USER_DOMAIN_NAME', + default='Default', + help='The OpenStack user domain name' + ), + cfg.StrOpt( + 'OS_USERNAME', + default='shipyard', + help='The OpenStack username' + ), + cfg.StrOpt( + 'OS_PASSWORD', + default='password', + help='THe OpenStack password for the shipyard svc acct' + ), + cfg.StrOpt( + 'OS_REGION_NAME', + default='Regionone', + help='The OpenStack user domain name' + ), + cfg.IntOpt( + 'OS_IDENTITY_API_VERSION', + default=3, + help='The OpenStack identity api version' + ), + ] + ), +] + +def register_opts(conf): + """ + Registers all the sections in this module. + """ + for section in SECTIONS: + conf.register_group( + cfg.OptGroup(name=section.name, + title=section.title, + help=section.help)) + conf.register_opts(section.options, group=section.name) + + # TODO (Bryan Strassner) is there a better, more general way to do this, + # or is password enough? Probably need some guidance + # from someone with more experience in this space. + conf.register_opts( + ks_loading.get_auth_plugin_conf_options('password'), + group='keystone_authtoken' + ) + + +def list_opts(): + return { + section.name: section.options for section in SECTIONS + } + + +def parse_args(args=None, usage=None, default_config_files=None): + CONF(args=args, + project='shipyard', + usage=usage, + default_config_files=default_config_files) + + +register_opts(CONF) diff --git a/shipyard_airflow/conf/opts.py b/shipyard_airflow/conf/opts.py new file mode 100644 index 00000000..f02f8e71 --- /dev/null +++ b/shipyard_airflow/conf/opts.py @@ -0,0 +1,89 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import collections +import importlib +import os +import pkgutil + +LIST_OPTS_FUNC_NAME = "list_opts" +IGNORED_MODULES = ('opts', 'constants', 'utils') +CONFIG_PATH = 'shipyard_airflow.conf' + + +class ConfigSection(object): + """ + Defines a configuration section + """ + def __init__(self, name, title, options, help=None): + self.name = name + self.title = title + self.help = help + self.options = options + + +def _tupleize(dct): + """Take the dict of options and convert to the 2-tuple format.""" + return [(key, val) for key, val in dct.items()] + + +def list_opts(): + """Entry point used only in the context of sample file generation. + This is the single point of entry to generate the sample configuration + file. It collects all the necessary info from the other modules in this + package. It is assumed that: + * every other module in this package has a 'list_opts' function which + return a dict where + * the keys are strings which are the group names + * the value of each key is a list of config options for that group + * the {program}.conf package doesn't have further packages with config + options + """ + opts = collections.defaultdict(list) + module_names = _list_module_names() + imported_modules = _import_modules(module_names) + _append_config_options(imported_modules, opts) + return _tupleize(opts) + + +def _list_module_names(): + module_names = [] + package_path = os.path.dirname(os.path.abspath(__file__)) + for _, modname, ispkg in pkgutil.iter_modules(path=[package_path]): + if modname in IGNORED_MODULES or ispkg: + continue + else: + module_names.append(modname) + return module_names + + +def _import_modules(module_names): + imported_modules = [] + for modname in module_names: + mod = importlib.import_module(CONFIG_PATH + '.' + modname) + if not hasattr(mod, LIST_OPTS_FUNC_NAME): + msg = "The module '%s.%s' should have a '%s' "\ + "function which returns the config options." % \ + (CONFIG_PATH, modname, LIST_OPTS_FUNC_NAME) + raise Exception(msg) + else: + imported_modules.append(mod) + return imported_modules + + +def _append_config_options(imported_modules, config_options): + for mod in imported_modules: + configs = mod.list_opts() + for key, val in configs.items(): + config_options[key].extend(val) diff --git a/shipyard_airflow/config.py b/shipyard_airflow/config.py deleted file mode 100644 index 820027dc..00000000 --- a/shipyard_airflow/config.py +++ /dev/null @@ -1,202 +0,0 @@ -# Copyright 2017 AT&T Intellectual Property. All other rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -"""Single point of entry to generate the sample configuration file. -This module collects all the necessary info from the other modules in this -package. It is assumed that: -* Every other module in this package has a 'list_opts' function which - returns a dict where: - * The keys are strings which are the group names. - * The value of each key is a list of config options for that group. -* The conf package doesn't have further packages with config options. -* This module is only used in the context of sample file generation. -""" -import importlib -import os -import pkgutil - -from oslo_config import cfg -import keystoneauth1.loading as loading - -IGNORED_MODULES = ('shipyard', 'config') - -if (os.path.exists('etc/shipyard/shipyard.conf')): - cfg.CONF(['--config-file', 'etc/shipyard/shipyard.conf']) - -class ShipyardConfig(object): - """ - Initialize all the core options - """ - # Default options - options = [ - cfg.IntOpt( - 'poll_interval', - default=10, - help=[ - '''Polling interval in seconds for checking subtask or - downstream status''' - ]), - ] - - # Logging options - logging_options = [ - cfg.StrOpt( - 'log_level', default='INFO', help='Global log level for Shipyard'), - cfg.StrOpt( - 'global_logger_name', - default='shipyard', - help='Logger name for the top-level logger'), - ] - - # Enabled plugins - plugin_options = [ - cfg.MultiStrOpt( - 'ingester', - default=['shipyard_airflow.ingester.plugins.yaml.YamlIngester'], - help='Module path string of a input ingester to enable'), - cfg.MultiStrOpt( - 'oob_driver', - default=[ - 'shipyard_airflow.drivers.oob.pyghmi_driver.PyghmiDriver' - ], - help='Module path string of a OOB driver to enable'), - cfg.StrOpt( - 'node_driver', - default=[ - '''shipyard_airflow.drivers.node.maasdriver.driver - .MaasNodeDriver''' - ], - help='Module path string of the Node driver to enable'), - # TODO Network driver not yet implemented - cfg.StrOpt( - 'network_driver', - default=None, - help='Module path string of the Network driver enable'), - ] - - # Timeouts for various tasks specified in minutes - timeout_options = [ - cfg.IntOpt( - 'shipyard_timeout', - default=5, - help='Fallback timeout when a specific one is not configured'), - cfg.IntOpt( - 'create_network_template', - default=2, - help='Timeout in minutes for creating site network templates'), - cfg.IntOpt( - 'configure_user_credentials', - default=2, - help='Timeout in minutes for creating user credentials'), - cfg.IntOpt( - 'identify_node', - default=10, - help='Timeout in minutes for initial node identification'), - cfg.IntOpt( - 'configure_hardware', - default=30, - help=[ - '''Timeout in minutes for node commissioning and - hardware configuration''' - ]), - cfg.IntOpt( - 'apply_node_networking', - default=5, - help='Timeout in minutes for configuring node networking'), - cfg.IntOpt( - 'apply_node_platform', - default=5, - help='Timeout in minutes for configuring node platform'), - cfg.IntOpt( - 'deploy_node', - default=45, - help='Timeout in minutes for deploying a node'), - ] - - def __init__(self): - self.conf = cfg.CONF - - def register_options(self): - self.conf.register_opts(ShipyardConfig.options) - self.conf.register_opts( - ShipyardConfig.logging_options, group='logging') - self.conf.register_opts(ShipyardConfig.plugin_options, group='plugins') - self.conf.register_opts( - ShipyardConfig.timeout_options, group='timeouts') - self.conf.register_opts( - loading.get_auth_plugin_conf_options('password'), - group='keystone_authtoken') - - -config_mgr = ShipyardConfig() - - -def list_opts(): - opts = { - 'DEFAULT': ShipyardConfig.options, - 'logging': ShipyardConfig.logging_options, - 'plugins': ShipyardConfig.plugin_options, - 'timeouts': ShipyardConfig.timeout_options - } - - package_path = os.path.dirname(os.path.abspath(__file__)) - parent_module = ".".join(__name__.split('.')[:-1]) - module_names = _list_module_names(package_path, parent_module) - imported_modules = _import_modules(module_names) - _append_config_options(imported_modules, opts) - # Assume we'll use the password plugin, - # so include those options in the configuration template - opts['keystone_authtoken'] = loading.get_auth_plugin_conf_options( - 'password') - return _tupleize(opts) - - -def _tupleize(d): - """Convert a dict of options to the 2-tuple format.""" - return [(key, value) for key, value in d.items()] - - -def _list_module_names(pkg_path, parent_module): - module_names = [] - for _, module_name, ispkg in pkgutil.iter_modules(path=[pkg_path]): - if module_name in IGNORED_MODULES: - # Skip this module. - continue - elif ispkg: - module_names.extend( - _list_module_names(pkg_path + "/" + module_name, - parent_module + "." + module_name)) - else: - module_names.append(parent_module + "." + module_name) - return module_names - - -def _import_modules(module_names): - imported_modules = [] - for module_name in module_names: - module = importlib.import_module(module_name) - if hasattr(module, 'list_opts'): - print("Pulling options from module %s" % module.__name__) - imported_modules.append(module) - return imported_modules - - -def _append_config_options(imported_modules, config_options): - for module in imported_modules: - configs = module.list_opts() - for key, val in configs.items(): - if key not in config_options: - config_options[key] = val - else: - config_options[key].extend(val) diff --git a/shipyard_airflow/control/__init__.py b/shipyard_airflow/control/__init__.py index f10bbbf6..e69de29b 100644 --- a/shipyard_airflow/control/__init__.py +++ b/shipyard_airflow/control/__init__.py @@ -1,13 +0,0 @@ -# Copyright 2017 AT&T Intellectual Property. All other rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/shipyard_airflow/control/action_helper.py b/shipyard_airflow/control/action_helper.py new file mode 100644 index 00000000..09855d8a --- /dev/null +++ b/shipyard_airflow/control/action_helper.py @@ -0,0 +1,63 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Common methods for use by action api classes as necessary +""" + +DAG_STATE_MAPPING = { + 'QUEUED': 'Pending', + 'RUNNING': 'Processing', + 'SUCCESS': 'Complete', + 'SHUTDOWN': 'Failed', + 'FAILED': 'Failed', + 'UP_FOR_RETRY': 'Processing', + 'UPSTREAM_FAILED': 'Failed', + 'SKIPPED': 'Failed', + 'REMOVED': 'Failed', + 'SCHEDULED': 'Pending', + 'NONE': 'Pending', + 'PAUSED': 'Paused' +} + +def determine_lifecycle(dag_status=None): + """ + Convert a dag_status to an action_lifecycle value + """ + if dag_status is None: + dag_status = 'NONE' + return DAG_STATE_MAPPING.get(dag_status.upper()) + +def format_action_steps(action_id, steps): + """ + Converts a list of action step database records to desired format + """ + if not steps: + return [] + steps_response = [] + for idx, step in enumerate(steps): + steps_response.append(format_step(action_id=action_id, + step=step, + index=idx + 1)) + return steps_response + +def format_step(action_id, step, index): + """ + reformat a step (dictionary) into a common response format + """ + return { + 'url': '/actions/{}/steps/{}'.format(action_id, step.get('task_id')), + 'state': step.get('state'), + 'id': step.get('task_id'), + 'index': index + } diff --git a/shipyard_airflow/control/actions_api.py b/shipyard_airflow/control/actions_api.py new file mode 100644 index 00000000..5cbbdd63 --- /dev/null +++ b/shipyard_airflow/control/actions_api.py @@ -0,0 +1,330 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from datetime import datetime + +import falcon +import requests +from requests.exceptions import RequestException +from dateutil.parser import parse +from oslo_config import cfg +import ulid + +from shipyard_airflow import policy +from shipyard_airflow.control.action_helper import (determine_lifecycle, + format_action_steps) +from shipyard_airflow.control.base import BaseResource +from shipyard_airflow.control.json_schemas import ACTION +from shipyard_airflow.db.db import AIRFLOW_DB, SHIPYARD_DB +from shipyard_airflow.errors import ApiError + +CONF = cfg.CONF + +# Mappings of actions to dags +SUPPORTED_ACTION_MAPPINGS = { + # action : dag, validation + 'deploy_site': { + 'dag': 'deploy_site', + 'validator': None + }, + 'update_site': { + 'dag': 'update_site', + 'validator': None + }, + 'redeploy_server': { + 'dag': 'redeploy_sever', + # TODO (Bryan Strassner) This should have a validator method + # Needs to be revisited when defined + 'validator': None + } +} + + +# /api/v1.0/actions +class ActionsResource(BaseResource): + """ + The actions resource represent the asyncrhonous invocations of shipyard + """ + + @policy.ApiEnforcer('workflow_orchestrator:list_actions') + def on_get(self, req, resp, **kwargs): + """ + Return actions that have been invoked through shipyard. + :returns: a json array of action entities + """ + resp.body = self.to_json(self.get_all_actions()) + resp.status = falcon.HTTP_200 + self.info(req.context, 'response data is %s' % resp.body) + + @policy.ApiEnforcer('workflow_orchestrator:create_action') + def on_post(self, req, resp, **kwargs): + """ + Accept an action into shipyard + """ + input_action = self.req_json(req, validate_json_schema=ACTION) + action = self.create_action(action=input_action, context=req.context) + self.info(req.context, "Id %s generated for action %s " % + (action['id'], action['name'])) + # respond with the action and location for checking status + resp.status = falcon.HTTP_201 + resp.body = self.to_json(action) + # TODO (Bryan Strassner) figure out the right way to do this: + resp.location = '/api/v1.0/actions/{}'.format(action['id']) + + def create_action(self, action, context): + # use uuid assigned for this request as the id of the action. + action['id'] = ulid.ulid() + # the invoking user + action['user'] = context.user + # add current timestamp (UTC) to the action. + action['timestamp'] = str(datetime.utcnow()) + # validate that action is supported. + self.info(context, "Attempting action: %s" % action['name']) + if action['name'] not in SUPPORTED_ACTION_MAPPINGS: + raise ApiError( + title='Unable to start action', + description='Unsupported Action: {}'.format(action['name'])) + + dag = SUPPORTED_ACTION_MAPPINGS.get(action['name'])['dag'] + action['dag_id'] = dag + + # populate action parameters if they are not set + if 'parameters' not in action: + action['parameters'] = {} + + # validate if there is any validation to do + validator = SUPPORTED_ACTION_MAPPINGS.get(action['name'])['validator'] + if validator is not None: + # validators will raise ApiError if they are not validated. + validator(action) + + # invoke airflow, get the dag's date + dag_execution_date = self.invoke_airflow_dag( + dag_id=dag, action=action, context=context) + # set values on the action + action['dag_execution_date'] = dag_execution_date + action['dag_status'] = 'SCHEDULED' + + # context_marker is the uuid from the request context + action['context_marker'] = context.request_id + + # insert the action into the shipyard db + self.insert_action(action=action) + self.audit_control_command_db({ + 'id': ulid.ulid(), + 'action_id': action['id'], + 'command': 'invoke', + 'user': context.user + }) + + return action + + def get_all_actions(self): + """ + Interacts with airflow and the shipyard database to return the list of + actions invoked through shipyard. + """ + # fetch actions from the shipyard db + all_actions = self.get_action_map() + # fetch the associated dags, steps from the airflow db + all_dag_runs = self.get_dag_run_map() + all_tasks = self.get_all_tasks_db() + + # correlate the actions and dags into a list of action entites + actions = [] + + for action_id, action in all_actions.items(): + dag_key = action['dag_id'] + action['dag_execution_date'] + dag_key_id = action['dag_id'] + dag_key_date = action['dag_execution_date'] + # locate the dag run associated + dag_state = all_dag_runs.get(dag_key, {}).get('state', None) + # get the dag status from the dag run state + action['dag_status'] = dag_state + action['action_lifecycle'] = determine_lifecycle(dag_state) + # get the steps summary + action_tasks = [ + step for step in all_tasks + if step['dag_id'].startswith(dag_key_id) and + step['execution_date'].strftime( + '%Y-%m-%dT%H:%M:%S') == dag_key_date + ] + action['steps'] = format_action_steps(action_id, action_tasks) + actions.append(action) + + return actions + + def get_action_map(self): + """ + maps an array of dictionaries to a dictonary of the same results by id + :returns: a dictionary of dictionaries keyed by action id + """ + return {action['id']: action for action in self.get_all_actions_db()} + + def get_all_actions_db(self): + """ + Wrapper for call to the shipyard database to get all actions + :returns: a dictionary of dictionaries keyed by action id + """ + return SHIPYARD_DB.get_all_submitted_actions() + + def get_dag_run_map(self): + """ + Maps an array of dag runs to a keyed dictionary + :returns: a dictionary of dictionaries keyed by dag_id and + execution_date + """ + return { + run['dag_id'] + + run['execution_date'].strftime('%Y-%m-%dT%H:%M:%S'): run + for run in self.get_all_dag_runs_db() + } + + def get_all_dag_runs_db(self): + """ + Wrapper for call to the airflow db to get all dag runs + :returns: a dictionary of dictionaries keyed by dag_id and + execution_date + """ + return AIRFLOW_DB.get_all_dag_runs() + + def get_all_tasks_db(self): + """ + Wrapper for call to the airflow db to get all tasks + :returns: a list of task dictionaries + """ + return AIRFLOW_DB.get_all_tasks() + + def insert_action(self, action): + """ + Wrapper for call to the shipyard db to insert an action + """ + return SHIPYARD_DB.insert_action(action) + + def audit_control_command_db(self, action_audit): + """ + Wrapper for the shipyard db call to record an audit of the + action control taken + """ + return SHIPYARD_DB.insert_action_command_audit(action_audit) + + def invoke_airflow_dag(self, dag_id, action, context): + """ + Call airflow, and invoke a dag + :param dag_id: the name of the dag to invoke + :param action: the action structure to invoke the dag with + """ + # Retrieve URL + web_server_url = CONF.base.web_server + + if 'Error' in web_server_url: + raise ApiError( + title='Unable to invoke workflow', + description=('Airflow URL not found by Shipyard. ' + 'Shipyard configuration is missing web_server ' + 'value'), + status=falcon.HTTP_503, + retry=True, ) + + else: + conf_value = {'action': action} + # "conf" - JSON string that gets pickled into the DagRun's + # conf attribute + req_url = ('{}admin/rest_api/api?api=trigger_dag&dag_id={}' + '&conf={}'.format(web_server_url, + dag_id, self.to_json(conf_value))) + + try: + resp = requests.get(req_url, timeout=15) + self.info(context, + 'Response code from Airflow trigger_dag: %s' % + resp.status_code) + resp.raise_for_status() + response = resp.json() + self.info(context, + 'Response from Airflow trigger_dag: %s' % + response) + except (RequestException) as rex: + self.error(context, "Request to airflow failed: %s" % rex.args) + raise ApiError( + title='Unable to complete request to Airflow', + description=( + 'Airflow could not be contacted properly by Shipyard.' + ), + status=falcon.HTTP_503, + error_list=[{ + 'message': str(type(rex)) + }], + retry=True, ) + + # Returns error response if API call returns + # response code other than 200 + if response["http_response_code"] != 200: + raise ApiError( + title='Unable to invoke workflow', + description=( + 'Airflow URL not found by Shipyard.', + 'Shipyard configuration is missing web_server value'), + status=falcon.HTTP_503, + error_list=[{ + 'message': response['output'] + }], + retry=True, ) + else: + dag_time = self._exhume_date(dag_id, + response['output']['stdout']) + dag_execution_date = dag_time.strftime('%Y-%m-%dT%H:%M:%S') + return dag_execution_date + + def _exhume_date(self, dag_id, log_string): + # we are unable to use the response time because that + # does not match the time when the dag was recorded. + # We have to parse the stdout returned to find the + # Created - -# Do not handle authorization requests within the middleware, but delegate the -# authorization decision to downstream WSGI components. (boolean value) -# from .keystone_authtoken.keystonemiddleware.auth_token.delay_auth_decision -delay_auth_decision = true - -# Request timeout value for communicating with Identity API server. (integer -# value) -# from .keystone_authtoken.keystonemiddleware.auth_token.http_connect_timeout -#http_connect_timeout = - -# How many times are we trying to reconnect when communicating with Identity API -# Server. (integer value) -# from .keystone_authtoken.keystonemiddleware.auth_token.http_request_max_retries -#http_request_max_retries = 3 - -# Request environment key where the Swift cache object is stored. When -# auth_token middleware is deployed with a Swift cache, use this option to have -# the middleware share a caching backend with swift. Otherwise, use the -# ``memcached_servers`` option instead. (string value) -# from .keystone_authtoken.keystonemiddleware.auth_token.cache -#cache = - -# Required if identity server requires client certificate (string value) -# from .keystone_authtoken.keystonemiddleware.auth_token.certfile -#certfile = - -# Required if identity server requires client certificate (string value) -# from .keystone_authtoken.keystonemiddleware.auth_token.keyfile -#keyfile = - -# A PEM encoded Certificate Authority to use when verifying HTTPs connections. -# Defaults to system CAs. (string value) -# from .keystone_authtoken.keystonemiddleware.auth_token.cafile -#cafile = - -# Verify HTTPS connections. (boolean value) -# from .keystone_authtoken.keystonemiddleware.auth_token.insecure -#insecure = false - -# The region in which the identity server can be found. (string value) -# from .keystone_authtoken.keystonemiddleware.auth_token.region_name -#region_name = - -# Directory used to cache files related to PKI tokens. (string value) -# from .keystone_authtoken.keystonemiddleware.auth_token.signing_dir -#signing_dir = - -# Optionally specify a list of memcached server(s) to use for caching. If left -# undefined, tokens will instead be cached in-process. (list value) -# Deprecated group/name - [keystone_authtoken]/memcache_servers -# from .keystone_authtoken.keystonemiddleware.auth_token.memcached_servers -#memcached_servers = - -# In order to prevent excessive effort spent validating tokens, the middleware -# caches previously-seen tokens for a configurable duration (in seconds). Set to -# -1 to disable caching completely. (integer value) -# from .keystone_authtoken.keystonemiddleware.auth_token.token_cache_time -#token_cache_time = 300 - -# Determines the frequency at which the list of revoked tokens is retrieved from -# the Identity service (in seconds). A high number of revocation events combined -# with a low cache duration may significantly reduce performance. Only valid for -# PKI tokens. (integer value) -# from .keystone_authtoken.keystonemiddleware.auth_token.revocation_cache_time -#revocation_cache_time = 10 - -# (Optional) If defined, indicate whether token data should be authenticated or -# authenticated and encrypted. If MAC, token data is authenticated (with HMAC) -# in the cache. If ENCRYPT, token data is encrypted and authenticated in the -# cache. If the value is not one of these options or empty, auth_token will -# raise an exception on initialization. (string value) -# Allowed values: None, MAC, ENCRYPT -# from .keystone_authtoken.keystonemiddleware.auth_token.memcache_security_strategy -#memcache_security_strategy = None - -# (Optional, mandatory if memcache_security_strategy is defined) This string is -# used for key derivation. (string value) -# from .keystone_authtoken.keystonemiddleware.auth_token.memcache_secret_key -#memcache_secret_key = - -# (Optional) Number of seconds memcached server is considered dead before it is -# tried again. (integer value) -# from .keystone_authtoken.keystonemiddleware.auth_token.memcache_pool_dead_retry -#memcache_pool_dead_retry = 300 - -# (Optional) Maximum total number of open connections to every memcached server. -# (integer value) -# from .keystone_authtoken.keystonemiddleware.auth_token.memcache_pool_maxsize -#memcache_pool_maxsize = 10 - -# (Optional) Socket timeout in seconds for communicating with a memcached -# server. (integer value) -# from .keystone_authtoken.keystonemiddleware.auth_token.memcache_pool_socket_timeout -#memcache_pool_socket_timeout = 3 - -# (Optional) Number of seconds a connection to memcached is held unused in the -# pool before it is closed. (integer value) -# from .keystone_authtoken.keystonemiddleware.auth_token.memcache_pool_unused_timeout -#memcache_pool_unused_timeout = 60 - -# (Optional) Number of seconds that an operation will wait to get a memcached -# client connection from the pool. (integer value) -# from .keystone_authtoken.keystonemiddleware.auth_token.memcache_pool_conn_get_timeout -#memcache_pool_conn_get_timeout = 10 - -# (Optional) Use the advanced (eventlet safe) memcached client pool. The -# advanced pool will only work under python 2.x. (boolean value) -# from .keystone_authtoken.keystonemiddleware.auth_token.memcache_use_advanced_pool -#memcache_use_advanced_pool = false - -# (Optional) Indicate whether to set the X-Service-Catalog header. If False, -# middleware will not ask for service catalog on token validation and will not -# set the X-Service-Catalog header. (boolean value) -# from .keystone_authtoken.keystonemiddleware.auth_token.include_service_catalog -#include_service_catalog = true - -# Used to control the use and type of token binding. Can be set to: "disabled" -# to not check token binding. "permissive" (default) to validate binding -# information if the bind type is of a form known to the server and ignore it if -# not. "strict" like "permissive" but if the bind type is unknown the token will -# be rejected. "required" any form of token binding is needed to be allowed. -# Finally the name of a binding method that must be present in tokens. (string -# value) -# from .keystone_authtoken.keystonemiddleware.auth_token.enforce_token_bind -#enforce_token_bind = permissive - -# If true, the revocation list will be checked for cached tokens. This requires -# that PKI tokens are configured on the identity server. (boolean value) -# from .keystone_authtoken.keystonemiddleware.auth_token.check_revocations_for_cached -#check_revocations_for_cached = false - -# Hash algorithms to use for hashing PKI tokens. This may be a single algorithm -# or multiple. The algorithms are those supported by Python standard -# hashlib.new(). The hashes will be tried in the order given, so put the -# preferred one first for performance. The result of the first hash will be -# stored in the cache. This will typically be set to multiple values only while -# migrating from a less secure algorithm to a more secure one. Once all the old -# tokens are expired this option should be set to a single value for better -# performance. (list value) -# from .keystone_authtoken.keystonemiddleware.auth_token.hash_algorithms -#hash_algorithms = md5 - -# Authentication type to load (string value) -# Deprecated group/name - [keystone_authtoken]/auth_plugin -# from .keystone_authtoken.keystonemiddleware.auth_token.auth_type -auth_type = password - -# Config Section from which to load plugin specific options (string value) -# from .keystone_authtoken.keystonemiddleware.auth_token.auth_section -auth_section = keystone_authtoken - - - -# -# From shipyard_orchestrator -# - -# Authentication URL (string value) -# from .keystone_authtoken.shipyard_orchestrator.auth_url -auth_url = http://keystone-api.openstack:80/v3 - -# Domain ID to scope to (string value) -# from .keystone_authtoken.shipyard_orchestrator.domain_id -#domain_id = - -# Domain name to scope to (string value) -# from .keystone_authtoken.shipyard_orchestrator.domain_name -#domain_name = - -# Project ID to scope to (string value) -# Deprecated group/name - [keystone_authtoken]/tenant-id -# from .keystone_authtoken.shipyard_orchestrator.project_id -#project_id = - -# Project name to scope to (string value) -# Deprecated group/name - [keystone_authtoken]/tenant-name -# from .keystone_authtoken.shipyard_orchestrator.project_name -project_name = service - -# Domain ID containing project (string value) -# from .keystone_authtoken.shipyard_orchestrator.project_domain_id -#project_domain_id = - -# Domain name containing project (string value) -# from .keystone_authtoken.shipyard_orchestrator.project_domain_name -project_domain_name = default - -# Trust ID (string value) -# from .keystone_authtoken.shipyard_orchestrator.trust_id -#trust_id = - -# Optional domain ID to use with v3 and v2 parameters. It will be used for both -# the user and project domain in v3 and ignored in v2 authentication. (string -# value) -# from .keystone_authtoken.shipyard_orchestrator.default_domain_id -#default_domain_id = - -# Optional domain name to use with v3 API and v2 parameters. It will be used for -# both the user and project domain in v3 and ignored in v2 authentication. -# (string value) -# from .keystone_authtoken.shipyard_orchestrator.default_domain_name -#default_domain_name = - -# User id (string value) -# from .keystone_authtoken.shipyard_orchestrator.user_id -#user_id = - -# Username (string value) -# Deprecated group/name - [keystone_authtoken]/user-name -# from .keystone_authtoken.shipyard_orchestrator.username -username = shipyard - -# User's domain id (string value) -# from .keystone_authtoken.shipyard_orchestrator.user_domain_id -#user_domain_id = - -# User's domain name (string value) -# from .keystone_authtoken.shipyard_orchestrator.user_domain_name -user_domain_name = default - -# User's password (string value) -# from .keystone_authtoken.shipyard_orchestrator.password -password = password - - -[oslo_policy] - -# -# From oslo.policy -# - -# The file that defines policies. (string value) -# Deprecated group/name - [DEFAULT]/policy_file -# from .oslo_policy.oslo.policy.policy_file -#policy_file = policy.json - -# Default rule. Enforced when a requested rule is not found. (string value) -# Deprecated group/name - [DEFAULT]/policy_default_rule -# from .oslo_policy.oslo.policy.policy_default_rule -#policy_default_rule = default - -# Directories where policy configuration files are stored. They can be relative -# to any directory in the search path defined by the config_dir option, or -# absolute paths. The file defined by policy_file must exist for these -# directories to be searched.  Missing or empty directories are ignored. (multi -# valued) -# Deprecated group/name - [DEFAULT]/policy_dirs -# from .oslo_policy.oslo.policy.policy_dirs (multiopt) -#policy_dirs = policy.d diff --git a/shipyard_airflow/db/__init__.py b/shipyard_airflow/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shipyard_airflow/db/airflow_db.py b/shipyard_airflow/db/airflow_db.py new file mode 100644 index 00000000..72c1d207 --- /dev/null +++ b/shipyard_airflow/db/airflow_db.py @@ -0,0 +1,234 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Airflow database access - see db.py for instances to use +""" +import sqlalchemy +from oslo_config import cfg + +from shipyard_airflow.db.common_db import DbAccess +from shipyard_airflow.db.errors import AirflowStateError + + +CONF = cfg.CONF + + +class AirflowDbAccess(DbAccess): + """ + Airflow database access + WARNING: This is a large set of assumptions based on the way airflow + arranges its database and are subject to change with airflow future + releases - i.e. we're leveraging undocumented/non-exposed interfaces + for airflow to work around lack of API and feature functionality. + """ + + SELECT_ALL_DAG_RUNS = sqlalchemy.sql.text(''' + SELECT + "dag_id", + "execution_date", + "state", + "run_id", + "external_trigger", + "start_date", + "end_date" + FROM + dag_run + ''') + + SELECT_DAG_RUNS_BY_ID = sqlalchemy.sql.text(''' + SELECT + "dag_id", + "execution_date", + "state", + "run_id", + "external_trigger", + "start_date", + "end_date" + FROM + dag_run + WHERE + dag_id = :dag_id + AND + execution_date = :execution_date + ''') + + SELECT_ALL_TASKS = sqlalchemy.sql.text(''' + SELECT + "task_id", + "dag_id", + "execution_date", + "start_date", + "end_date", + "duration", + "state", + "try_number", + "operator", + "queued_dttm" + FROM + task_instance + ORDER BY + priority_weight desc, + start_date + ''') + + SELECT_TASKS_BY_ID = sqlalchemy.sql.text(''' + SELECT + "task_id", + "dag_id", + "execution_date", + "start_date", + "end_date", + "duration", + "state", + "try_number", + "operator", + "queued_dttm" + FROM + task_instance + WHERE + dag_id LIKE :dag_id + AND + execution_date = :execution_date + ORDER BY + priority_weight desc, + start_date + ''') + + UPDATE_DAG_RUN_STATUS = sqlalchemy.sql.text(''' + UPDATE + dag_run + SET + state = :state + WHERE + dag_id = :dag_id + AND + execution_date = :execution_date + ''') + + def __init__(self): + DbAccess.__init__(self) + + def get_connection_string(self): + """ + Returns the connection string for this db connection + """ + return CONF.base.postgresql_airflow_db + + def get_all_dag_runs(self): + """ + Retrieves all dag runs. + """ + return self.get_as_dict_array(AirflowDbAccess.SELECT_ALL_DAG_RUNS) + + def get_dag_runs_by_id(self, dag_id, execution_date): + """ + Retrieves dag runs by dag id and execution date + """ + return self.get_as_dict_array( + AirflowDbAccess.SELECT_DAG_RUNS_BY_ID, + dag_id=dag_id, + execution_date=execution_date) + + def get_all_tasks(self): + """ + Retrieves all tasks. + """ + return self.get_as_dict_array(AirflowDbAccess.SELECT_ALL_TASKS) + + def get_tasks_by_id(self, dag_id, execution_date): + """ + Retrieves tasks by dag id and execution date + """ + return self.get_as_dict_array( + AirflowDbAccess.SELECT_TASKS_BY_ID, + dag_id=dag_id + '%', + execution_date=execution_date) + + def stop_dag_run(self, dag_id, execution_date): + """ + Triggers an update to set a dag_run to failed state + causing dag_run to be stopped + running -> failed + """ + self._control_dag_run( + dag_id=dag_id, + execution_date=execution_date, + expected_state='running', + desired_state='failed') + + def pause_dag_run(self, dag_id, execution_date): + """ + Triggers an update to set a dag_run to paused state + causing dag_run to be paused + running -> paused + """ + self._control_dag_run( + dag_id=dag_id, + execution_date=execution_date, + expected_state='running', + desired_state='paused') + + def unpause_dag_run(self, dag_id, execution_date): + """ + Triggers an update to set a dag_run to running state + causing dag_run to be unpaused + paused -> running + """ + self._control_dag_run( + dag_id=dag_id, + execution_date=execution_date, + expected_state='paused', + desired_state='running') + + def check_dag_run_state(self, dag_id, execution_date, expected_state): + """ + Examines a dag_run for state. Throws execption if it's not right + """ + dag_run_list = self.get_dag_runs_by_id( + dag_id=dag_id, execution_date=execution_date) + if dag_run_list: + dag_run = dag_run_list[0] + if dag_run['state'] != expected_state: + raise AirflowStateError( + message='dag_run state must be running, but is {}'.format( + dag_run['state'])) + else: + # not found + raise AirflowStateError(message='dag_run does not exist') + + def _control_dag_run(self, dag_id, execution_date, expected_state, + desired_state): + """ + checks a dag_run's state for expected state, and sets it to the + desired state + """ + self.check_dag_run_state( + dag_id=dag_id, + execution_date=execution_date, + expected_state=expected_state) + self._set_dag_run_state( + state=desired_state, dag_id=dag_id, execution_date=execution_date) + + def _set_dag_run_state(self, state, dag_id, execution_date): + """ + Sets a dag run to the specified state. + WARNING: this assumes that airflow works by reading state from the + dag_run table dynamically, is not caching results, and doesn't + start to use the states we're using in a new way. + """ + self.perform_insert( + AirflowDbAccess.UPDATE_DAG_RUN_STATUS, + state=state, + dag_id=dag_id, + execution_date=execution_date) diff --git a/shipyard_airflow/db/common_db.py b/shipyard_airflow/db/common_db.py new file mode 100644 index 00000000..8238120f --- /dev/null +++ b/shipyard_airflow/db/common_db.py @@ -0,0 +1,121 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import logging + +import sqlalchemy + +from shipyard_airflow.errors import DatabaseError + +LOG = logging.getLogger(__name__) + +class DbAccess: + """ + Base class for simple database access + """ + + def __init__(self): + self.engine = None + + def get_connection_string(self): + """ + Override to return the connection string. This allows for + lazy initialization + """ + raise NotImplementedError() + + def update_db(self): + """ + Unimplemented method for use in overriding to peform db updates + """ + LOG.info('No databse version updates specified for %s', + self.__class__.__name__) + + def get_engine(self): + """ + Returns the engine for airflow + """ + try: + connection_string = self.get_connection_string() + if connection_string is not None and self.engine is None: + self.engine = sqlalchemy.create_engine(connection_string) + if self.engine is None: + self._raise_invalid_db_config( + connection_string=connection_string + ) + LOG.info('Connected with <%s>, returning engine', + connection_string) + return self.engine + except sqlalchemy.exc.ArgumentError as exc: + self._raise_invalid_db_config( + exception=exc, + connection_string=connection_string + ) + + def _raise_invalid_db_config(self, + connection_string, + exception=None): + """ + Common handler for an invalid DB connection + """ + LOG.error('Connection string <%s> prevents database operation', + connection_string) + if exception is not None: + LOG.error("Associated exception: %s", exception) + raise DatabaseError( + title='No database connection', + description='Invalid database configuration' + ) + + def get_as_dict_array(self, query, **kwargs): + """ + executes the supplied query and returns the array of dictionaries of + the row results + """ + LOG.info('Query: %s', query) + result_dict_list = [] + if query is not None: + with self.get_engine().connect() as connection: + result_set = connection.execute(query, **kwargs) + result_dict_list = [dict(row) for row in result_set] + LOG.info('Result has %s rows', len(result_dict_list)) + for dict_row in result_dict_list: + LOG.info('Result: %s', dict_row) + return result_dict_list + + def perform_insert(self, query, **kwargs): + """ + Performs a parameterized insert + """ + self.perform_change_dml(query, **kwargs) + + def perform_update(self, query, **kwargs): + """ + Performs a parameterized update + """ + self.perform_change_dml(query, **kwargs) + + def perform_delete(self, query, **kwargs): + """ + Performs a parameterized delete + """ + self.perform_change_dml(query, **kwargs) + + def perform_change_dml(self, query, **kwargs): + """ + Performs an update/insert/delete + """ + LOG.debug('Query: %s', query) + if query is not None: + with self.get_engine().connect() as connection: + connection.execute(query, **kwargs) diff --git a/examples/manifests/services.yaml b/shipyard_airflow/db/db.py similarity index 59% rename from examples/manifests/services.yaml rename to shipyard_airflow/db/db.py index 358010c6..d4606bf7 100644 --- a/examples/manifests/services.yaml +++ b/shipyard_airflow/db/db.py @@ -11,17 +11,10 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -############################################################################# -# -# services.yaml - Definitions of server hardware layout -# -############################################################################# -# version the schema in this file so consumers can rationally parse it ---- -# -# Is this where I include a list of files per service ? -# -# -# Assuming something like this works for the insertion +""" +The Application scope instances of db access classes +""" +from shipyard_airflow.db import airflow_db, shipyard_db -imports: \ No newline at end of file +SHIPYARD_DB = shipyard_db.ShipyardDbAccess() +AIRFLOW_DB = airflow_db.AirflowDbAccess() diff --git a/shipyard_airflow/control/regions.py b/shipyard_airflow/db/errors.py similarity index 58% rename from shipyard_airflow/control/regions.py rename to shipyard_airflow/db/errors.py index 2209805c..ded955e6 100644 --- a/shipyard_airflow/control/regions.py +++ b/shipyard_airflow/db/errors.py @@ -11,19 +11,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import falcon -from .base import BaseResource -from shipyard_airflow import policy - -class RegionsResource(BaseResource): - - @policy.ApiEnforcer('workflow_orchestrator:get_regions') - def on_get(self, req, resp): - resp.status = falcon.HTTP_200 - -class RegionResource(BaseResource): - - @policy.ApiEnforcer('workflow_orchestrator:get_regions') - def on_get(self, req, resp, region_id): - resp.status = falcon.HTTP_200 +class AirflowStateError(Exception): + def __init__(self, message=""): + """ + An error to convey that an attempt to modify airflow data cannot + be accomplished due to existing state. + :param message: Optional message for consumer + """ + self.message = message diff --git a/shipyard_airflow/db/shipyard_db.py b/shipyard_airflow/db/shipyard_db.py new file mode 100644 index 00000000..35524d43 --- /dev/null +++ b/shipyard_airflow/db/shipyard_db.py @@ -0,0 +1,254 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Shipyard database access - see db.py for instances to use +""" +import json +import logging +import os + +import sqlalchemy +from alembic import command as alembic_command +from alembic.config import Config +from oslo_config import cfg + +from shipyard_airflow.db.common_db import DbAccess + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + +class ShipyardDbAccess(DbAccess): + """ + Shipyard database access + """ + + SELECT_ALL_ACTIONS = sqlalchemy.sql.text(''' + SELECT + "id", + "name", + "parameters", + "dag_id", + "dag_execution_date", + "user", + "datetime", + "context_marker" + FROM + actions + ''') + + SELECT_ACTION_BY_ID = sqlalchemy.sql.text(''' + SELECT + "id", + "name", + "parameters", + "dag_id", + "dag_execution_date", + "user", + "datetime", + "context_marker" + FROM + actions + WHERE + id = :action_id + ''') + + INSERT_ACTION = sqlalchemy.sql.text(''' + INSERT INTO + actions ( + "id", + "name", + "parameters", + "dag_id", + "dag_execution_date", + "user", + "datetime", + "context_marker" + ) + VALUES ( + :id, + :name, + :parameters, + :dag_id, + :dag_execution_date, + :user, + :timestamp, + :context_marker ) + ''') + + SELECT_VALIDATIONS = sqlalchemy.sql.text(''' + SELECT + "id", + "action_id", + "validation_name" + FROM + preflight_validation_failures + ''') + + SELECT_VALIDATION_BY_ID = sqlalchemy.sql.text(''' + SELECT + "id", + "action_id", + "validation_name", + "details" + FROM + preflight_validation_failures + WHERE + id = :validation_id + ''') + + SELECT_VALIDATION_BY_ACTION_ID = sqlalchemy.sql.text(''' + SELECT + "id", + "action_id", + "validation_name", + "details" + FROM + preflight_validation_failures + WHERE + action_id = :action_id + ''') + + SELECT_CMD_AUDIT_BY_ACTION_ID = sqlalchemy.sql.text(''' + SELECT + "id", + "action_id", + "command", + "user", + "datetime" + FROM + action_command_audit + WHERE + action_id = :action_id + ''') + + INSERT_ACTION_COMMAND_AUDIT = sqlalchemy.sql.text(''' + INSERT INTO + action_command_audit ( + "id", + "action_id", + "command", + "user" + ) + VALUES ( + :id, + :action_id, + :command, + :user ) + ''') + + def __init__(self): + DbAccess.__init__(self) + + def get_connection_string(self): + """ + Returns the connection string for this db connection + """ + return CONF.base.postgresql_db + + def update_db(self): + """ + Trigger Alembic to upgrade to the latest version of the DB + """ + try: + LOG.info("Checking for shipyard database upgrade") + cwd = os.getcwd() + os.chdir(CONF.base.alembic_ini_path) + config = Config('alembic.ini', + attributes={'configure_logger': False}) + alembic_command.upgrade(config, 'head') + os.chdir(cwd) + except Exception as exception: + LOG.error('***\n' + 'Failed Alembic DB upgrade. Check the config: %s\n' + '***', + exception) + # don't let things continue... + raise exception + + def get_all_submitted_actions(self): + """ + Retrieves all actions. + """ + return self.get_as_dict_array(ShipyardDbAccess.SELECT_ALL_ACTIONS) + + def get_action_by_id(self, action_id): + """ + Get a single action + :param action_id: the id of the action to retrieve + """ + actions_array = self.get_as_dict_array( + ShipyardDbAccess.SELECT_ACTION_BY_ID, action_id=action_id) + if actions_array: + return actions_array[0] + else: + # Not found + return None + + def get_preflight_validation_fails(self): + """ + Retrieves the summary set of preflight validation failures + """ + return self.get_as_dict_array(ShipyardDbAccess.SELECT_VALIDATIONS) + + def get_validation_by_id(self, validation_id): + """ + Retreives a single validation for a given validation id + """ + validation_array = self.get_as_dict_array( + ShipyardDbAccess.SELECT_VALIDATION_BY_ID, + validation_id=validation_id) + if validation_array: + return validation_array[0] + else: + return None + + def get_validation_by_action_id(self, action_id): + """ + Retreives the validations for a given action id + """ + return self.get_as_dict_array( + ShipyardDbAccess.SELECT_VALIDATION_BY_ACTION_ID, + action_id=action_id) + + def insert_action(self, action): + """ + Inserts a single action row + """ + self.perform_insert(ShipyardDbAccess.INSERT_ACTION, + id=action['id'], + name=action['name'], + parameters=json.dumps(action['parameters']), + dag_id=action['dag_id'], + dag_execution_date=action['dag_execution_date'], + user=action['user'], + timestamp=action['timestamp'], + context_marker=action['context_marker']) + + def get_command_audit_by_action_id(self, action_id): + """ + Retreives the action audit records for a given action id + """ + return self.get_as_dict_array( + ShipyardDbAccess.SELECT_CMD_AUDIT_BY_ACTION_ID, + action_id=action_id) + + def insert_action_command_audit(self, ac_audit): + """ + Inserts a single action command audit + """ + self.perform_insert(ShipyardDbAccess.INSERT_ACTION_COMMAND_AUDIT, + id=ac_audit['id'], + action_id=ac_audit['action_id'], + command=ac_audit['command'], + user=ac_audit['user']) diff --git a/shipyard_airflow/errors.py b/shipyard_airflow/errors.py index deb7bacd..d44c62e2 100644 --- a/shipyard_airflow/errors.py +++ b/shipyard_airflow/errors.py @@ -1,49 +1,224 @@ -# -*- coding: utf-8 -*- - +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. import json +import logging +import traceback + import falcon -try: - from collections import OrderedDict -except ImportError: - OrderedDict = dict -ERR_UNKNOWN = {'status': falcon.HTTP_500, 'title': 'Internal Server Error'} +def get_version_from_request(req): + """ + Attempt to extract the api version string + """ + for part in req.path.split('/'): + if '.' in part and part.startswith('v'): + return part + return 'N/A' -ERR_AIRFLOW_RESPONSE = { - 'status': falcon.HTTP_400, - 'title': 'Error response from Airflow' -} + +# Standard error handler +def format_resp(req, + resp, + status_code, + message="", + reason="", + error_type="Unspecified Exception", + retry=False, + error_list=None): + """ + Write a error message body and throw a Falcon exception to trigger + an HTTP status + :param req: Falcon request object + :param resp: Falcon response object to update + :param status_code: Falcon status_code constant + :param message: Optional error message to include in the body + :param reason: Optional reason code to include in the body + :param retry: Optional flag whether client should retry the operation. + :param error_list: option list of errors + Can ignore if we rely solely on 4XX vs 5xx status codes + """ + if error_list is None: + error_list = [{'message': 'An error ocurred, but was not specified'}] + error_response = { + 'kind': 'status', + 'apiVersion': get_version_from_request(req), + 'metadata': {}, + 'status': 'Failure', + 'message': message, + 'reason': reason, + 'details': { + 'errorType': error_type, + 'errorCount': len(error_list), + 'errorList': error_list + }, + 'code': status_code + } + + resp.body = json.dumps(error_response, default=str) + resp.content_type = 'application/json' + resp.status = status_code + +def default_error_serializer(req, resp, exception): + """ + Writes the default error message body, when we don't handle it otherwise + """ + format_resp( + req, + resp, + status_code=exception.status, + message=exception.description, + reason=exception.title, + error_type=exception.__class__.__name__, + error_list=[{'message': exception.description}] + ) + +def default_exception_handler(ex, req, resp, params): + """ + Catch-all execption handler for standardized output. + If this is a standard falcon HTTPError, rethrow it for handling + """ + if isinstance(ex, falcon.HTTPError): + # allow the falcon http errors to bubble up and get handled + raise ex + else: + # take care of the uncaught stuff + exc_string = traceback.format_exc() + logging.error('Unhanded Exception being handled: \n%s', exc_string) + format_resp( + req, + resp, + falcon.HTTP_500, + error_type=ex.__class__.__name__, + message="Unhandled Exception raised: %s" % str(ex), + retry=True + ) class AppError(Exception): - def __init__(self, error=ERR_UNKNOWN, description=None): - self.error = error - self.error['description'] = description - - @property - def title(self): - return self.error['title'] - - @property - def status(self): - return self.error['status'] - - @property - def description(self): - return self.error['description'] + """ + Base error containing enough information to make a shipyard formatted error + """ + def __init__(self, + title='Internal Server Error', + description=None, + error_list=None, + status=falcon.HTTP_500, + retry=False): + """ + :param description: The internal error description + :param error_list: The list of errors + :param status: The desired falcon HTTP resposne code + :param title: The title of the error message + :param retry: Optional retry directive for the consumer + """ + self.title = title + self.description = description + self.error_list = massage_error_list(error_list, description) + self.status = status + self.retry = retry @staticmethod - def handle(exception, req, res, error=None): - res.status = exception.status - meta = OrderedDict() - meta['message'] = exception.title - if exception.description: - meta['description'] = exception.description - res.body = json.dumps(meta) + def handle(ex, req, resp, params): + format_resp( + req, + resp, + ex.status, + message=ex.title, + reason=ex.description, + error_list=ex.error_list, + error_type=ex.__class__.__name__, + retry=ex.retry) class AirflowError(AppError): - def __init__(self, description=None): - super().__init__(ERR_AIRFLOW_RESPONSE) - self.error['description'] = description + """ + An error to handle errors returned by the Airflow API + """ + def __init__(self, description=None, error_list=None): + super().__init__( + title='Error response from Airflow', + description=description, + error_list=error_list, + status=falcon.HTTP_400, + retry=False + ) + +class DatabaseError(AppError): + """ + An error to handle general api errors. + """ + def __init__(self, + description=None, + error_list=None, + status=falcon.HTTP_500, + title='Database Access Error', + retry=False): + super().__init__( + status=status, + title=title, + description=description, + error_list=error_list, + retry=retry + ) + + +class ApiError(AppError): + """ + An error to handle general api errors. + """ + def __init__(self, + description="", + error_list=None, + status=falcon.HTTP_400, + title="", + retry=False): + super().__init__( + status=status, + title=title, + description=description, + error_list=error_list, + retry=retry + ) + + +class InvalidFormatError(AppError): + """ + An exception to cover invalid input formatting + """ + def __init__(self, title, description="Not Specified", error_list=None): + + super().__init__( + title=title, + description='Validation has failed', + error_list=error_list, + status=falcon.HTTP_400, + retry=False + ) + + +def massage_error_list(error_list, placeholder_description): + """ + Returns a best-effort attempt to make a nice error list + """ + output_error_list = [] + if error_list: + for error in error_list: + if not error['message']: + output_error_list.append({'message': error}) + else: + output_error_list.append(error) + if not output_error_list: + output_error_list.append({'message': placeholder_description}) + return output_error_list diff --git a/shipyard_airflow/policy.py b/shipyard_airflow/policy.py index ab0d3f38..307d8d16 100644 --- a/shipyard_airflow/policy.py +++ b/shipyard_airflow/policy.py @@ -12,13 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. # -import logging import functools -import falcon +import logging +import falcon from oslo_config import cfg from oslo_policy import policy +from shipyard_airflow.errors import ApiError, AppError + +CONF = cfg.CONF +LOG = logging.getLogger(__name__) policy_engine = None @@ -26,6 +30,9 @@ class ShipyardPolicy(object): """ Initialize policy defaults """ + + RULE_ADMIN_REQUIRED = 'rule:admin_required' + # Base Policy base_rules = [ policy.RuleDefault( @@ -36,18 +43,61 @@ class ShipyardPolicy(object): # Orchestrator Policy task_rules = [ - policy.DocumentedRuleDefault('workflow_orchestrator:get_regions', - 'role:admin', 'Get region information', [{ - 'path': - '/api/v1.0/regions', - 'method': - 'GET' - }, { - 'path': - '/api/v1.0/regions/{region_id}', - 'method': - 'GET' - }]) + policy.DocumentedRuleDefault( + 'workflow_orchestrator:list_actions', + RULE_ADMIN_REQUIRED, + 'List workflow actions invoked by users', + [{ + 'path': '/api/v1.0/actions', + 'method': 'GET' + }] + ), + policy.DocumentedRuleDefault( + 'workflow_orchestrator:create_action', + RULE_ADMIN_REQUIRED, + 'Create a workflow action', + [{ + 'path': '/api/v1.0/actions', + 'method': 'POST' + }] + ), + policy.DocumentedRuleDefault( + 'workflow_orchestrator:get_action', + RULE_ADMIN_REQUIRED, + 'Retreive an action by its id', + [{ + 'path': '/api/v1.0/actions/{action_id}', + 'method': 'GET' + }] + ), + policy.DocumentedRuleDefault( + 'workflow_orchestrator:get_action_step', + RULE_ADMIN_REQUIRED, + 'Retreive an action step by its id', + [{ + 'path': '/api/v1.0/actions/{action_id}/steps/{step_id}', + 'method': 'GET' + }] + ), + policy.DocumentedRuleDefault( + 'workflow_orchestrator:get_action_validation', + RULE_ADMIN_REQUIRED, + 'Retreive an action validation by its id', + [{ + 'path': + '/api/v1.0/actions/{action_id}/validations/{validation_id}', + 'method': 'GET' + }] + ), + policy.DocumentedRuleDefault( + 'workflow_orchestrator:invoke_action_control', + RULE_ADMIN_REQUIRED, + 'Send a control to an action', + [{ + 'path': '/api/v1.0/actions/{action_id}/control/{control_verb}', + 'method': 'POST' + }] + ), ] # Regions Policy @@ -61,7 +111,6 @@ class ShipyardPolicy(object): def authorize(self, action, ctx): target = {'project_id': ctx.project_id, 'user_id': ctx.user_id} - self.enforcer.authorize(action, target, ctx.to_policy_view()) return self.enforcer.authorize(action, target, ctx.to_policy_view()) @@ -72,44 +121,68 @@ class ApiEnforcer(object): def __init__(self, action): self.action = action - self.logger = logging.getLogger('shipyard.policy') + self.logger = LOG def __call__(self, f): @functools.wraps(f) def secure_handler(slf, req, resp, *args, **kwargs): ctx = req.context - policy_engine = ctx.policy_engine - self.logger.debug("Enforcing policy %s on request %s" % - (self.action, ctx.request_id)) + policy_eng = ctx.policy_engine + slf.info(ctx, "Policy Engine: %s" % policy_eng.__class__.__name__) + # perform auth + slf.info(ctx, "Enforcing policy %s on request %s" % + (self.action, ctx.request_id)) + # policy engine must be configured + if policy_eng is None: + slf.error( + ctx, + "Error-Policy engine required-action: %s" % self.action) + raise AppError( + title="Auth is not being handled by any policy engine", + status=falcon.HTTP_500, + retry=False + ) + authorized = False try: - if policy_engine is not None and policy_engine.authorize( - self.action, ctx): - return f(slf, req, resp, *args, **kwargs) - else: - if ctx.authenticated: - slf.info(ctx, "Error - Forbidden access - action: %s" % - self.action) - slf.return_error( - resp, - falcon.HTTP_403, - message="Forbidden", - retry=False) - else: - slf.info(ctx, "Error - Unauthenticated access") - slf.return_error( - resp, - falcon.HTTP_401, - message="Unauthenticated", - retry=False) + if policy_eng.authorize(self.action, ctx): + # authorized + slf.info(ctx, "Request is authorized") + authorized = True except: - slf.info( + # couldn't service the auth request + slf.error( ctx, "Error - Expectation Failed - action: %s" % self.action) - slf.return_error( - resp, - falcon.HTTP_417, - message="Expectation Failed", - retry=False) + raise ApiError( + title="Expectation Failed", + status=falcon.HTTP_417, + retry=False + ) + if authorized: + return f(slf, req, resp, *args, **kwargs) + else: + slf.error(ctx, + "Auth check failed. Authenticated:%s" % + ctx.authenticated) + # raise the appropriate response exeception + if ctx.authenticated: + slf.error(ctx, + "Error: Forbidden access - action: %s" % + self.action) + raise ApiError( + title="Forbidden", + status=falcon.HTTP_403, + description="Credentials do not permit access", + retry=False + ) + else: + slf.error(ctx, "Error - Unauthenticated access") + raise ApiError( + title="Unauthenticated", + status=falcon.HTTP_401, + description="Credentials are not established", + retry=False + ) return secure_handler diff --git a/shipyard_airflow/setup.py b/shipyard_airflow/setup.py deleted file mode 100644 index 630bbde7..00000000 --- a/shipyard_airflow/setup.py +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright 2017 AT&T Intellectual Property. All other rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from setuptools import setup - -setup(name='shipyard_airflow', - version='0.1a1', - description='API for managing Airflow-based orchestration', - url='http://github.com/att-comdev/shipyard', - author='Anthony Lin - AT&T', - author_email='al498u@att.com', - license='Apache 2.0', - packages=['shipyard_airflow', - 'shipyard_airflow.control'], - install_requires=[ - 'falcon', - 'requests', - 'configparser', - 'uwsgi>1.4', - 'python-dateutil' - ]) diff --git a/shipyard_airflow/shipyard.py b/shipyard_airflow/shipyard.py index 95f5df7c..70c4d1c1 100755 --- a/shipyard_airflow/shipyard.py +++ b/shipyard_airflow/shipyard.py @@ -15,28 +15,30 @@ import logging from oslo_config import cfg -from shipyard_airflow import policy import shipyard_airflow.control.api as api -# We need to import config so the initializing code can run for oslo config -import shipyard_airflow.config as config # noqa: F401 +from shipyard_airflow import policy +from shipyard_airflow.conf import config +from shipyard_airflow.db import db + +CONF = cfg.CONF def start_shipyard(): - - # Setup configuration parsing - cli_options = [ - cfg.BoolOpt( - 'debug', short='d', default=False, help='Enable debug logging'), - ] + # Trigger configuration resolution. + config.parse_args() # Setup root logger - logger = logging.getLogger('shipyard') + base_console_handler = logging.StreamHandler() - logger.setLevel('DEBUG') - ch = logging.StreamHandler() - formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') - ch.setFormatter(formatter) - logger.addHandler(ch) + logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[base_console_handler]) + logging.getLogger().info("Setting logging level to: %s", + logging.getLevelName(CONF.logging.log_level)) + + logging.basicConfig(level=CONF.logging.log_level, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[base_console_handler]) # Specalized format for API logging logger = logging.getLogger('shipyard.control') @@ -45,14 +47,21 @@ def start_shipyard(): ('%(asctime)s - %(levelname)s - %(user)s - %(req_id)s - ' '%(external_ctx)s - %(message)s')) - ch = logging.StreamHandler() - ch.setFormatter(formatter) - logger.addHandler(ch) + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) # Setup the RBAC policy enforcer policy.policy_engine = policy.ShipyardPolicy() policy.policy_engine.register_policy() + # Upgrade database + if CONF.base.upgrade_db: + # this is a reasonable place to put any online alembic upgrades + # desired. Currently only shipyard db is under shipyard control. + db.SHIPYARD_DB.update_db() + + # Start the API return api.start_api() diff --git a/tests/unit/control/__init__.py b/tests/unit/control/__init__.py index e69de29b..1ff35516 100644 --- a/tests/unit/control/__init__.py +++ b/tests/unit/control/__init__.py @@ -0,0 +1,23 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pytest + +from shipyard_airflow.conf import config + +@pytest.fixture +def setup_config(): + """ + Initialize shipyard config - this is needed so that CONF resolves. + """ + config.parse_args() diff --git a/tests/unit/control/test_actions_api.py b/tests/unit/control/test_actions_api.py new file mode 100644 index 00000000..36144888 --- /dev/null +++ b/tests/unit/control/test_actions_api.py @@ -0,0 +1,239 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import json +import os +from datetime import datetime + +from shipyard_airflow.control.actions_api import ActionsResource +from shipyard_airflow.control.base import ShipyardRequestContext +from shipyard_airflow.errors import ApiError + +DATE_ONE = datetime(2017, 9, 13, 11, 13, 3, 57000) +DATE_TWO = datetime(2017, 9, 13, 11, 13, 5, 57000) +DATE_ONE_STR = DATE_ONE.strftime('%Y-%m-%dT%H:%M:%S') +DATE_TWO_STR = DATE_TWO.strftime('%Y-%m-%dT%H:%M:%S') + + +def actions_db(): + """ + replaces the actual db call + """ + return [ + { + 'id': 'aaaaaa', + 'name': 'dag_it', + 'parameters': None, + 'dag_id': 'did1', + 'dag_execution_date': DATE_ONE_STR, + 'user': 'robot1', + 'timestamp': DATE_ONE, + 'context_marker': '8-4-4-4-12a' + }, + { + 'id': 'bbbbbb', + 'name': 'dag2', + 'parameters': { + 'p1': 'p1val' + }, + 'dag_id': 'did2', + 'dag_execution_date': DATE_ONE_STR, + 'user': 'robot2', + 'timestamp': DATE_ONE, + 'context_marker': '8-4-4-4-12b' + }, + ] + + +def dag_runs_db(): + """ + replaces the actual db call + """ + return [ + { + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'SUCCESS', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_ONE, + 'end_date': DATE_TWO + }, + { + 'dag_id': 'did1', + 'execution_date': DATE_ONE, + 'state': 'FAILED', + 'run_id': '99', + 'external_trigger': 'something', + 'start_date': DATE_ONE, + 'end_date': DATE_ONE + }, + ] + + +def tasks_db(): + """ + replaces the actual db call + """ + return [ + { + 'task_id': '1a', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'SUCCESS', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_ONE, + 'end_date': DATE_TWO, + 'duration': '20mins', + 'try_number': '1', + 'operator': 'smooth', + 'queued_dttm': DATE_TWO + }, + { + 'task_id': '1b', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'SUCCESS', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_ONE, + 'end_date': DATE_TWO, + 'duration': '1minute', + 'try_number': '1', + 'operator': 'smooth', + 'queued_dttm': DATE_TWO + }, + { + 'task_id': '1c', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'SUCCESS', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_ONE, + 'end_date': DATE_TWO, + 'duration': '1day', + 'try_number': '3', + 'operator': 'smooth', + 'queued_dttm': DATE_TWO + }, + { + 'task_id': '2a', + 'dag_id': 'did1', + 'execution_date': DATE_ONE, + 'state': 'FAILED', + 'start_date': DATE_ONE, + 'end_date': DATE_ONE, + 'duration': '1second', + 'try_number': '2', + 'operator': 'smooth', + 'queued_dttm': DATE_TWO + }, + ] + +def airflow_stub(**kwargs): + """ + asserts that the airflow invocation method was called with the right + parameters + """ + assert kwargs['dag_id'] + assert kwargs['action'] + print(kwargs) + return '2017-09-06 14:10:08.528402' + +def insert_action_stub(**kwargs): + """ + asserts that the insert action was called with the right parameters + """ + assert kwargs['action'] + +def audit_control_command_db(action_audit): + """ + Stub for inserting the invoke record + """ + assert action_audit['command'] == 'invoke' + + +context = ShipyardRequestContext() + +def test_get_all_actions(): + """ + Tests the main response from get all actions + """ + action_resource = ActionsResource() + action_resource.get_all_actions_db = actions_db + action_resource.get_all_dag_runs_db = dag_runs_db + action_resource.get_all_tasks_db = tasks_db + os.environ['DB_CONN_AIRFLOW'] = 'nothing' + os.environ['DB_CONN_SHIPYARD'] = 'nothing' + result = action_resource.get_all_actions() + print(result) + assert len(result) == len(actions_db()) + for action in result: + if action['name'] == 'dag_it': + assert len(action['steps']) == 1 + assert action['dag_status'] == 'FAILED' + if action['name'] == 'dag2': + assert len(action['steps']) == 3 + assert action['dag_status'] == 'SUCCESS' + +def test_create_action(): + action_resource = ActionsResource() + action_resource.get_all_actions_db = actions_db + action_resource.get_all_dag_runs_db = dag_runs_db + action_resource.get_all_tasks_db = tasks_db + action_resource.invoke_airflow_dag = airflow_stub + action_resource.insert_action = insert_action_stub + action_resource.audit_control_command_db = audit_control_command_db + + # with invalid input. fail. + try: + action = action_resource.create_action( + action={'name': 'broken', 'parameters': {'a': 'aaa'}}, + context=context + ) + assert False, 'Should throw an ApiError' + except ApiError: + # expected + pass + + # with valid input and some parameters + try: + action = action_resource.create_action( + action={'name': 'deploy_site', 'parameters': {'a': 'aaa'}}, + context=context + ) + assert action['timestamp'] + assert action['id'] + assert len(action['id']) == 26 + assert action['dag_execution_date'] == '2017-09-06 14:10:08.528402' + assert action['dag_status'] == 'SCHEDULED' + except ApiError: + assert False, 'Should not raise an ApiError' + print(json.dumps(action, default=str)) + + # with valid input and no parameters + try: + action = action_resource.create_action( + action={'name': 'deploy_site'}, + context=context + ) + assert action['timestamp'] + assert action['id'] + assert len(action['id']) == 26 + assert action['dag_execution_date'] == '2017-09-06 14:10:08.528402' + assert action['dag_status'] == 'SCHEDULED' + except ApiError: + assert False, 'Should not raise an ApiError' + print(json.dumps(action, default=str)) diff --git a/tests/unit/control/test_actions_control_api.py b/tests/unit/control/test_actions_control_api.py new file mode 100644 index 00000000..157a18c3 --- /dev/null +++ b/tests/unit/control/test_actions_control_api.py @@ -0,0 +1,164 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from shipyard_airflow.control.actions_control_api import ActionsControlResource +from shipyard_airflow.control.base import ShipyardRequestContext +from shipyard_airflow.db.errors import AirflowStateError +from shipyard_airflow.db.db import AIRFLOW_DB +from shipyard_airflow.errors import ApiError + + +def actions_db(action_id): + """ + replaces the actual db call + """ + if action_id == 'not found': + return None + elif action_id == 'state error': + return { + 'id': 'state error', + 'name': 'dag_it', + 'parameters': None, + 'dag_id': 'state error', + 'dag_execution_date': '2017-09-06 14:10:08.528402', + 'user': 'robot1', + 'timestamp': '2017-09-06 14:10:08.528402', + 'context_marker': '8-4-4-4-12a' + } + else: + return { + 'id': '59bb330a-9e64-49be-a586-d253bb67d443', + 'name': 'dag_it', + 'parameters': None, + 'dag_id': 'did2', + 'dag_execution_date': '2017-09-06 14:10:08.528402', + 'user': 'robot1', + 'timestamp': '2017-09-06 14:10:08.528402', + 'context_marker': '8-4-4-4-12a' + } + +def control_dag_run(dag_id, + execution_date, + expected_state, + desired_state): + if dag_id == 'state error': + raise AirflowStateError(message='test error') + else: + pass + +def audit_control_command_db(action_audit): + pass + +def test_get_action(): + """ + Tests the main response from get all actions + """ + saved_control_dag_run = AIRFLOW_DB._control_dag_run + try: + action_resource = ActionsControlResource() + # stubs for db + action_resource.get_action_db = actions_db + action_resource.audit_control_command_db = audit_control_command_db + + AIRFLOW_DB._control_dag_run = control_dag_run + + # bad action + try: + action_resource.handle_control( + action_id='not found', + control_verb='meep', + context=ShipyardRequestContext() + ) + assert False, "shouldn't find the action" + except ApiError as api_error: + assert api_error.title == 'Action not found' + assert api_error.status == '404 Not Found' + + # bad control + try: + action_resource.handle_control( + action_id='59bb330a-9e64-49be-a586-d253bb67d443', + control_verb='meep', + context=ShipyardRequestContext() + ) + assert False, 'meep is not a valid action' + except ApiError as api_error: + assert api_error.title == 'Control not supported' + assert api_error.status == '404 Not Found' + + # success on each action - pause, unpause, stop + try: + action_resource.handle_control( + action_id='59bb330a-9e64-49be-a586-d253bb67d443', + control_verb='pause', + context=ShipyardRequestContext() + ) + except ApiError as api_error: + assert False, 'Should not raise an ApiError' + + try: + action_resource.handle_control( + action_id='59bb330a-9e64-49be-a586-d253bb67d443', + control_verb='unpause', + context=ShipyardRequestContext() + ) + except ApiError as api_error: + assert False, 'Should not raise an ApiError' + + try: + action_resource.handle_control( + action_id='59bb330a-9e64-49be-a586-d253bb67d443', + control_verb='stop', + context=ShipyardRequestContext() + ) + except ApiError as api_error: + assert False, 'Should not raise an ApiError' + + # pause state conflict + try: + action_resource.handle_control( + action_id='state error', + control_verb='pause', + context=ShipyardRequestContext() + ) + assert False, 'should raise a conflicting state' + except ApiError as api_error: + assert api_error.title == 'Unable to pause action' + assert api_error.status == '409 Conflict' + + # Unpause state conflict + try: + action_resource.handle_control( + action_id='state error', + control_verb='unpause', + context=ShipyardRequestContext() + ) + assert False, 'should raise a conflicting state' + except ApiError as api_error: + assert api_error.title == 'Unable to unpause action' + assert api_error.status == '409 Conflict' + + # Stop state conflict + try: + action_resource.handle_control( + action_id='state error', + control_verb='stop', + context=ShipyardRequestContext() + ) + assert False, 'should raise a conflicting state' + except ApiError as api_error: + assert api_error.title == 'Unable to stop action' + assert api_error.status == '409 Conflict' + finally: + # modified class variable... replace it + AIRFLOW_DB._control_dag_run = saved_control_dag_run diff --git a/tests/unit/control/test_actions_id_api.py b/tests/unit/control/test_actions_id_api.py new file mode 100644 index 00000000..16175c7d --- /dev/null +++ b/tests/unit/control/test_actions_id_api.py @@ -0,0 +1,152 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import json +from datetime import datetime +from shipyard_airflow.control.actions_id_api import (ActionsIdResource) + +DATE_ONE = datetime(2017, 9, 13, 11, 13, 3, 57000) +DATE_TWO = datetime(2017, 9, 13, 11, 13, 5, 57000) +DATE_ONE_STR = DATE_ONE.strftime('%Y-%m-%dT%H:%M:%S') +DATE_TWO_STR = DATE_TWO.strftime('%Y-%m-%dT%H:%M:%S') + +def actions_db(action_id): + """ + replaces the actual db call + """ + return { + 'id': '12345678901234567890123456', + 'name': 'dag_it', + 'parameters': None, + 'dag_id': 'did2', + 'dag_execution_date': DATE_ONE_STR, + 'user': 'robot1', + 'timestamp': DATE_ONE, + 'context_marker': '8-4-4-4-12a' + } + +def dag_runs_db(dag_id, execution_date): + """ + replaces the actual db call + """ + return [{ + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'FAILED', + 'run_id': '99', + 'external_trigger': 'something', + 'start_date': DATE_ONE, + 'end_date': DATE_ONE + }] + +def tasks_db(dag_id, execution_date): + """ + replaces the actual db call + """ + return [ + { + 'task_id': '1a', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'SUCCESS', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_ONE, + 'end_date': DATE_ONE, + 'duration': '20mins', + 'try_number': '1', + 'operator': 'smooth', + 'queued_dttm': DATE_ONE + }, + { + 'task_id': '1b', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'SUCCESS', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_TWO, + 'end_date': DATE_TWO, + 'duration': '1minute', + 'try_number': '1', + 'operator': 'smooth', + 'queued_dttm': DATE_ONE + }, + { + 'task_id': '1c', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'FAILED', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_TWO, + 'end_date': DATE_TWO, + 'duration': '1day', + 'try_number': '3', + 'operator': 'smooth', + 'queued_dttm': DATE_TWO + } + ] + +def get_validations(action_id): + """ + Stub to return validations + """ + return [ + { + 'id': '43', + 'action_id': '12345678901234567890123456', + 'validation_name': 'It has shiny goodness', + 'details': 'This was not very shiny.' + } + ] + +def get_ac_audit(action_id): + """ + Stub to return command audit response + """ + return [ + { + 'id': 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + 'action_id': '12345678901234567890123456', + 'command': 'PAUSE', + 'user': 'Operator 99', + 'datetime': DATE_ONE + }, + { + 'id': 'ABCDEFGHIJKLMNOPQRSTUVWXYA', + 'action_id': '12345678901234567890123456', + 'command': 'UNPAUSE', + 'user': 'Operator 99', + 'datetime': DATE_TWO + } + ] + +def test_get_action(): + """ + Tests the main response from get all actions + """ + action_resource = ActionsIdResource() + # stubs for db + action_resource.get_action_db = actions_db + action_resource.get_dag_run_db = dag_runs_db + action_resource.get_tasks_db = tasks_db + action_resource.get_validations_db = get_validations + action_resource.get_action_command_audit_db = get_ac_audit + + action = action_resource.get_action('12345678901234567890123456') + print(json.dumps(action, default=str)) + if action['name'] == 'dag_it': + assert len(action['steps']) == 3 + assert action['dag_status'] == 'FAILED' + assert len(action['command_audit']) == 2 diff --git a/tests/unit/control/test_actions_steps_id_api.py b/tests/unit/control/test_actions_steps_id_api.py new file mode 100644 index 00000000..ae94d408 --- /dev/null +++ b/tests/unit/control/test_actions_steps_id_api.py @@ -0,0 +1,116 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import json +from datetime import datetime + +from shipyard_airflow.errors import ApiError +from shipyard_airflow.control.actions_steps_id_api import ActionsStepsResource + +DATE_ONE = datetime(2017, 9, 13, 11, 13, 3, 57000) +DATE_TWO = datetime(2017, 9, 13, 11, 13, 5, 57000) +DATE_ONE_STR = DATE_ONE.strftime('%Y-%m-%dT%H:%M:%S') +DATE_TWO_STR = DATE_TWO.strftime('%Y-%m-%dT%H:%M:%S') + + +def actions_db(action_id): + """ + replaces the actual db call + """ + return { + 'id': '59bb330a-9e64-49be-a586-d253bb67d443', + 'name': 'dag_it', + 'parameters': None, + 'dag_id': 'did2', + 'dag_execution_date': DATE_ONE_STR, + 'user': 'robot1', + 'timestamp': DATE_ONE_STR, + 'context_marker': '8-4-4-4-12a' + } + +def tasks_db(dag_id, execution_date): + """ + replaces the actual db call + """ + return [ + { + 'task_id': '1a', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'SUCCESS', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_ONE, + 'end_date': DATE_ONE, + 'duration': '20mins', + 'try_number': '1', + 'operator': 'smooth', + 'queued_dttm': DATE_ONE + }, + { + 'task_id': '1b', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'SUCCESS', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_TWO, + 'end_date': DATE_TWO, + 'duration': '1minute', + 'try_number': '1', + 'operator': 'smooth', + 'queued_dttm': DATE_ONE + }, + { + 'task_id': '1c', + 'dag_id': 'did2', + 'execution_date': DATE_ONE, + 'state': 'FAILED', + 'run_id': '12345', + 'external_trigger': 'something', + 'start_date': DATE_TWO, + 'end_date': DATE_TWO, + 'duration': '1day', + 'try_number': '3', + 'operator': 'smooth', + 'queued_dttm': DATE_TWO + } + ] + +def test_get_action_steps(): + """ + Tests the main response from get all actions + """ + action_resource = ActionsStepsResource() + # stubs for db + action_resource.get_action_db = actions_db + action_resource.get_tasks_db = tasks_db + + step = action_resource.get_action_step( + '59bb330a-9e64-49be-a586-d253bb67d443', + '1c' + ) + assert step['index'] == 3 + assert step['try_number'] == '3' + assert step['operator'] == 'smooth' + print(json.dumps(step, default=str)) + + try: + step = action_resource.get_action_step( + '59bb330a-9e64-49be-a586-d253bb67d443', + 'cheese' + ) + assert False, 'should raise an ApiError' + except ApiError as api_error: + assert api_error.title == 'Step not found' + assert api_error.status == '404 Not Found' diff --git a/tests/unit/control/test_actions_validations_id_api.py b/tests/unit/control/test_actions_validations_id_api.py new file mode 100644 index 00000000..527ce7ad --- /dev/null +++ b/tests/unit/control/test_actions_validations_id_api.py @@ -0,0 +1,87 @@ +# Copyright 2017 AT&T Intellectual Property. All other rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import json +from shipyard_airflow.control.actions_validations_id_api import ( + ActionsValidationsResource +) +from shipyard_airflow.errors import ApiError + +def actions_db(action_id): + """ + replaces the actual db call + """ + if action_id == 'error_it': + return None + else: + return { + 'id': '59bb330a-9e64-49be-a586-d253bb67d443', + 'name': 'dag_it', + 'parameters': None, + 'dag_id': 'did2', + 'dag_execution_date': '2017-09-06 14:10:08.528402', + 'user': 'robot1', + 'timestamp': '2017-09-06 14:10:08.528402', + 'context_marker': '8-4-4-4-12a' + } + +def get_validations(validation_id): + """ + Stub to return validations + """ + if validation_id == '43': + return { + 'id': '43', + 'action_id': '59bb330a-9e64-49be-a586-d253bb67d443', + 'validation_name': 'It has shiny goodness', + 'details': 'This was not very shiny.' + } + else: + return None + +def test_get_action_validation(): + """ + Tests the main response from get all actions + """ + action_resource = ActionsValidationsResource() + # stubs for db + action_resource.get_action_db = actions_db + action_resource.get_validation_db = get_validations + + validation = action_resource.get_action_validation( + action_id='59bb330a-9e64-49be-a586-d253bb67d443', + validation_id='43' + ) + print(json.dumps(validation, default=str)) + assert validation['action_id'] == '59bb330a-9e64-49be-a586-d253bb67d443' + assert validation['validation_name'] == 'It has shiny goodness' + + try: + validation = action_resource.get_action_validation( + action_id='59bb330a-9e64-49be-a586-d253bb67d443', + validation_id='not a chance' + ) + assert False + except ApiError as api_error: + assert api_error.status == '404 Not Found' + assert api_error.title == 'Validation not found' + + try: + validation = action_resource.get_action_validation( + action_id='error_it', + validation_id='not a chance' + ) + assert False + except ApiError as api_error: + assert api_error.status == '404 Not Found' + assert api_error.title == 'Action not found' diff --git a/tox.ini b/tox.ini index db59bd4c..2327bad2 100644 --- a/tox.ini +++ b/tox.ini @@ -14,13 +14,21 @@ commands= commands = flake8 {posargs} [testenv:bandit] -commands = bandit -r shipyard_airflow -x tests -n 5 +# NOTE(Bryan Strassner) ignoring airflow plugin which uses a subexec +# tests are not under the shipyard_airflow directory, not exlcuding those +commands = bandit -r shipyard_airflow -x plugins/rest_api_plugin.py -n 5 + +[testenv:genconfig] +commands = oslo-config-generator --config-file=generator/config-generator.conf + +[testenv:genpolicy] +commands = oslopolicy-sample-generator --config-file=generator/policy-generator.conf [flake8] # NOTE(Bryan Strassner) ignoring F841 because of the airflow example pattern # of naming variables even if they aren't used for DAGs and Operators. # Doing so adds readability and context in this case. -ignore=E302,H306,D100,D101,D102,F841 -# NOTE(Bryan Strassner) excluding 3rd party code that is brought into the +ignore = E302,H306,D100,D101,D102,F841 +# NOTE(Bryan Strassner) excluding 3rd party and generated code that is brought into the # codebase. -exclude=*plugins/rest_api_plugin.py,*lib/python*,*egg,.git*,*.md,.tox* \ No newline at end of file +exclude = *plugins/rest_api_plugin.py,*lib/python*,*egg,.git*,*.md,.tox*,alembic/env.py,build/*