# Copyright (c) 2017 Red Hat, Inc. # # This file is part of ARA: Ansible Run Analysis. # # ARA is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # ARA is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with ARA. If not, see <http://www.gnu.org/licenses/>. import functools import hashlib import uuid import zlib from datetime import datetime from datetime import timedelta from oslo_utils import encodeutils from oslo_serialization import jsonutils # This makes all the exceptions available as "models.<exception_name>". from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm.exc import * # NOQA from sqlalchemy.orm import backref import sqlalchemy.types as types db = SQLAlchemy() def mkuuid(): """ This is used to generate primary keys in the database tables. We were simply passing `default=uuid.uuid4` to `db.Column`, but it turns out that while some database drivers seem to implicitly call `str()`, others may be calling `repr()` which resulted in SQLIte trying to use keys like `UUID('a496d538-c819-4f7c-8926-e3abe317239d')`. """ return str(uuid.uuid4()) def content_sha1(context): """ Used by the FileContent model to automatically compute the sha1 hash of content before storing it to the database. """ try: content = context.current_parameters['content'] except AttributeError: content = context return hashlib.sha1(encodeutils.to_utf8(content)).hexdigest() # Primary key columns are of these type. pkey_type = db.String(36) # This defines the standard primary key column used in our tables. std_pkey = functools.partial( db.Column, pkey_type, primary_key=True, nullable=False, default=mkuuid) # Common options for one-to-one relationships in our database. one_to_one = functools.partial( db.relationship, passive_deletes=False, cascade='all, delete-orphan', uselist=False) # common options for one-to-many relationships in our database. one_to_many = functools.partial( db.relationship, passive_deletes=False, cascade='all, delete-orphan', lazy='dynamic') # common options for many-to-one relationships in our database. many_to_one = functools.partial( db.relationship, passive_deletes=False, cascade='all, delete-orphan', single_parent=True) # Common options for many-to-many relationships in our database. many_to_many = functools.partial( db.relationship, passive_deletes=False, cascade='all, delete', lazy='dynamic') # Common options for foreign key relationships. def std_fkey(col): return db.Column(pkey_type, db.ForeignKey(col, ondelete='RESTRICT')) class TimedEntity(object): @property def duration(self): """ Calculates '(time_end-time_start)' and return the resulting 'datetime.timedelta' object. """ if self.time_end is None or self.time_start is None: return timedelta(seconds=0) else: return self.time_end - self.time_start def start(self): """ Explicitly sets 'self.time_start' """ self.time_start = datetime.now() def stop(self): """ Explicitly sets 'self.time_end' """ self.time_end = datetime.now() class CompressedData(types.TypeDecorator): """ Implements a new sqlalchemy column type that automatically serializes and compresses data when writing it to the database and decompresses the data when reading it. http://docs.sqlalchemy.org/en/latest/core/custom_types.html """ impl = types.Binary def process_bind_param(self, value, dialect): return zlib.compress(encodeutils.to_utf8(jsonutils.dumps(value))) def process_result_value(self, value, dialect): if value is not None: return jsonutils.loads(zlib.decompress(value)) else: return value def copy(self, **kwargs): return CompressedData(self.impl.length) class CompressedText(types.TypeDecorator): """ Implements a new sqlalchemy column type that automatically compresses data when writing it to the database and decompresses the data when reading it. http://docs.sqlalchemy.org/en/latest/core/custom_types.html """ impl = types.Binary def process_bind_param(self, value, dialect): return zlib.compress(encodeutils.to_utf8(value)) def process_result_value(self, value, dialect): return encodeutils.safe_decode(zlib.decompress(value)) def copy(self, **kwargs): return CompressedText(self.impl.length) class Playbook(db.Model, TimedEntity): """ The 'Playbook' class represents a single run of 'ansible-playbook'. 'Playbook' entities have the following relationships: - 'data' -- a list of k/v pairs recorded in this playbook run - 'plays' -- a list of plays encountered in this playbook run - 'tasks' -- a list of tasks encountered in this playbook run - 'stats' -- a list of statistic records, one for each host involved in this playbook - 'hosts' -- a list of hosts involved in this playbook - 'files' -- a list of files encountered by this playbook (via include or role directives). """ __tablename__ = 'playbooks' id = std_pkey() path = db.Column(db.String(255)) ansible_version = db.Column(db.String(255)) options = db.Column(CompressedData((2 ** 32) - 1)) data = one_to_many('Data', backref='playbook') files = one_to_many('File', backref='playbook') plays = one_to_many('Play', backref='playbook') tasks = one_to_many('Task', backref='playbook') stats = one_to_many('Stats', backref='playbook') hosts = one_to_many('Host', backref='playbook') time_start = db.Column(db.DateTime, default=datetime.now) time_end = db.Column(db.DateTime) complete = db.Column(db.Boolean, default=False) @property def file(self): return (self.files .filter(File.playbook_id == self.id) .filter(File.is_playbook)).one() def __repr__(self): return '<Playbook %s>' % self.path class File(db.Model): """ Represents a task list (role or playbook or included file) referenced by an Ansible run. """ __tablename__ = 'files' __table_args__ = ( db.UniqueConstraint('playbook_id', 'path'), ) id = std_pkey() playbook_id = std_fkey('playbooks.id') # This has to be a String instead of Text because of # http://stackoverflow.com/questions/1827063/ # and it must have a max length smaller than PATH_MAX because MySQL is # limited to a maximum key length of 3072 bytes. These restrictions stem # from the fact that we are using this column in a UNIQUE constraint. path = db.Column(db.String(255)) content = many_to_one('FileContent', backref='files') content_id = db.Column(db.String(40), db.ForeignKey('file_contents.id')) tasks = many_to_one('Task', backref=backref('file', uselist=False)) # is_playbook is true for playbooks referenced directly on the # ansible-playbook command line. is_playbook = db.Column(db.Boolean, default=False) class FileContent(db.Model): """ Stores content of Ansible task lists encountered during an Ansible run. We store content keyed by the its sha1 hash, so if a file doesn't change the content will only be stored once in the database. The hash is calculated automatically when the object is written to the database. """ __tablename__ = 'file_contents' id = db.Column(db.String(40), primary_key=True, default=content_sha1) content = db.Column(CompressedText((2**32) - 1)) class Play(db.Model, TimedEntity): """ The 'Play' class represents a play in an ansible playbook. 'Play' entities have the following relationships: - 'tasks' -- a list of tasks in this play - 'task_results' -- a list of task results in this play (via the 'tasks' relationship defined by 'TaskResult'). """ __tablename__ = 'plays' id = std_pkey() playbook_id = std_fkey('playbooks.id') name = db.Column(db.Text) sortkey = db.Column(db.Integer) tasks = one_to_many('Task', backref='play') time_start = db.Column(db.DateTime, default=datetime.now) time_end = db.Column(db.DateTime) def __repr__(self): return '<Play %s>' % (self.name or self.id) @property def offset_from_playbook(self): return self.time_start - self.playbook.time_start class Task(db.Model, TimedEntity): """ The 'Task' class represents a single task defined in an Ansible playbook. 'Task' entities have the following relationships: - 'playbook' -- the playbook containing this task (via the 'tasks' relationship defined by 'Playbook') - 'play' -- the play containing this task (via the 'tasks' relationship defined by 'Play') - 'task_results' -- a list of results for each host targeted by this task. """ __tablename__ = 'tasks' id = std_pkey() playbook_id = std_fkey('playbooks.id') play_id = std_fkey('plays.id') name = db.Column(db.Text) sortkey = db.Column(db.Integer) action = db.Column(db.Text) tags = db.Column(db.Text) is_handler = db.Column(db.Boolean) file_id = std_fkey('files.id') lineno = db.Column(db.Integer) time_start = db.Column(db.DateTime, default=datetime.now) time_end = db.Column(db.DateTime) task_results = one_to_many('TaskResult', backref='task') def __repr__(self): return '<Task %s>' % (self.name or self.id) @property def offset_from_playbook(self): return self.time_start - self.playbook.time_start @property def offset_from_play(self): return self.time_start - self.play.time_start class TaskResult(db.Model, TimedEntity): """ The 'TaskResult' class represents the result of running a single task on a single host. A 'TaskResult' entity has the following relationships: - 'task' -- the task for which this is a result (via the 'task_results' relationship defined by 'Task'). - 'host' -- the host associated with this result (via the 'task_results' relationship defined by 'Host') """ __tablename__ = 'task_results' id = std_pkey() task_id = std_fkey('tasks.id') host_id = std_fkey('hosts.id') status = db.Column(db.Enum('ok', 'failed', 'skipped', 'unreachable')) changed = db.Column(db.Boolean, default=False) failed = db.Column(db.Boolean, default=False) skipped = db.Column(db.Boolean, default=False) unreachable = db.Column(db.Boolean, default=False) ignore_errors = db.Column(db.Boolean, default=False) result = db.Column(CompressedText((2**32) - 1)) time_start = db.Column(db.DateTime, default=datetime.now) time_end = db.Column(db.DateTime) @property def derived_status(self): if self.status == 'ok' and self.changed: return 'changed' elif self.status == 'failed' and self.ignore_errors: return 'ignored' else: return self.status def __repr__(self): return '<TaskResult %s>' % self.host.name class Host(db.Model): """ The 'Host' object represents a host reference by an Ansible inventory. A 'Host' entity has the following relationships: - 'task_results' -- a list of 'TaskResult' objects associated with this host. - 'stats' -- a list of 'Stats' objects resulting from playbook runs against this host. - 'playbooks' -- a list of 'Playbook' runs that have included this host. """ __tablename__ = 'hosts' __table_args__ = ( db.UniqueConstraint('playbook_id', 'name'), ) id = std_pkey() playbook_id = std_fkey('playbooks.id') name = db.Column(db.String(255), index=True) facts = one_to_one('HostFacts', backref='host') task_results = one_to_many('TaskResult', backref='host') stats = one_to_one('Stats', backref='host') def __repr__(self): return '<Host %s>' % self.name class HostFacts(db.Model): """ The 'HostFacts' object represents a host reference by an Ansible inventory. It is meant to record facts when a setup task is run for a host. A 'HostFacts' entity has the following relationship: - 'hosts' -- the host owner of the facts """ __tablename__ = 'host_facts' id = std_pkey() host_id = std_fkey('hosts.id') timestamp = db.Column(db.DateTime, default=datetime.now) values = db.Column(db.Text(16777215)) def __repr__(self): return '<HostFacts %s>' % self.host.name class Stats(db.Model): """ A 'Stats' object contains statistics for a single host from a single Ansible playbook run. A 'Stats' entity has the following relationships: - 'playbook' -- the playbook associated with these statistics (via the 'stats' relationship defined in `Playbook`) - 'host' -- The host associated with these statistics (via the 'stats' relationship defined in 'Host') """ __tablename__ = 'stats' id = std_pkey() playbook_id = std_fkey('playbooks.id') host_id = std_fkey('hosts.id') changed = db.Column(db.Integer, default=0) failed = db.Column(db.Integer, default=0) ok = db.Column(db.Integer, default=0) skipped = db.Column(db.Integer, default=0) unreachable = db.Column(db.Integer, default=0) def __repr__(self): return '<Stats for %s>' % self.host.name class Data(db.Model): """ The 'Data' object represents a recorded key/value pair provided by the ara_record module. A 'Data' entity has the following relationships: - 'playbook' -- the playbook this key/value pair was recorded in """ __tablename__ = 'data' __table_args__ = ( db.UniqueConstraint('playbook_id', 'key'), ) id = std_pkey() playbook_id = std_fkey('playbooks.id') key = db.Column(db.String(255)) value = db.Column(CompressedData((2 ** 32) - 1)) type = db.Column(db.String(255)) def __repr__(self): return '<Data %s:%s>' % (self.data.playbook_id, self.data.key)