ara/ara/models.py

457 lines
14 KiB
Python

# 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)