457 lines
14 KiB
Python
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)
|