Squashed general improvements
This is a squash of several general improvements like: - Add and fix docstrings - Add new task/files endpoints - Set up file and content compression/decompression - Try to get serializers work the way we want them to Change-Id: I52ba5b31e9d225704ed271ede843f3d4a6b468b4
This commit is contained in:
parent
a1ed3a3291
commit
167fc26311
|
@ -1,4 +1,4 @@
|
||||||
# Generated by Django 2.0.3 on 2018-03-10 17:18
|
# Generated by Django 2.0.3 on 2018-03-17 19:47
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
@ -65,7 +65,7 @@ class Migration(migrations.Migration):
|
||||||
('updated', models.DateTimeField(auto_now=True)),
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
('started', models.DateTimeField(default=django.utils.timezone.now)),
|
('started', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
('ended', models.DateTimeField(blank=True, null=True)),
|
('ended', models.DateTimeField(blank=True, null=True)),
|
||||||
('name', models.TextField()),
|
('name', models.TextField(blank=True, null=True)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'db_table': 'plays',
|
'db_table': 'plays',
|
||||||
|
@ -118,6 +118,8 @@ class Migration(migrations.Migration):
|
||||||
('unreachable', models.BooleanField(default=False)),
|
('unreachable', models.BooleanField(default=False)),
|
||||||
('ignore_errors', models.BooleanField(default=False)),
|
('ignore_errors', models.BooleanField(default=False)),
|
||||||
('result', models.BinaryField(max_length=4294967295)),
|
('result', models.BinaryField(max_length=4294967295)),
|
||||||
|
('host', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Host')),
|
||||||
|
('play', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Play')),
|
||||||
('playbook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Playbook')),
|
('playbook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Playbook')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
@ -132,24 +134,34 @@ class Migration(migrations.Migration):
|
||||||
('updated', models.DateTimeField(auto_now=True)),
|
('updated', models.DateTimeField(auto_now=True)),
|
||||||
('started', models.DateTimeField(default=django.utils.timezone.now)),
|
('started', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
('ended', models.DateTimeField(blank=True, null=True)),
|
('ended', models.DateTimeField(blank=True, null=True)),
|
||||||
('name', models.TextField()),
|
('name', models.TextField(blank=True, null=True)),
|
||||||
('action', models.TextField()),
|
('action', models.TextField()),
|
||||||
('lineno', models.IntegerField()),
|
('lineno', models.IntegerField()),
|
||||||
('tags', models.BinaryField(max_length=4294967295)),
|
('tags', models.BinaryField(max_length=4294967295)),
|
||||||
('handler', models.BooleanField()),
|
('handler', models.BooleanField()),
|
||||||
('file', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='tasks', to='api.File')),
|
('file', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='tasks', to='api.File')),
|
||||||
('play', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='tasks', to='api.Play')),
|
('play', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='api.Play')),
|
||||||
('playbook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='api.Playbook')),
|
('playbook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='api.Playbook')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'db_table': 'tasks',
|
'db_table': 'tasks',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='result',
|
||||||
|
name='task',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.Task'),
|
||||||
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='play',
|
model_name='play',
|
||||||
name='playbook',
|
name='playbook',
|
||||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plays', to='api.Playbook'),
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plays', to='api.Playbook'),
|
||||||
),
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='host',
|
||||||
|
name='play',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, related_name='hosts', to='api.Play'),
|
||||||
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='host',
|
model_name='host',
|
||||||
name='playbook',
|
name='playbook',
|
||||||
|
|
208
api/models.py
208
api/models.py
|
@ -19,45 +19,71 @@ import logging
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Ansible statuses
|
||||||
|
OK = 'ok'
|
||||||
|
FAILED = 'failed'
|
||||||
|
SKIPPED = 'skipped'
|
||||||
|
UNREACHABLE = 'unreachable'
|
||||||
|
# ARA specific statuses (derived or assumed)
|
||||||
|
CHANGED = 'changed'
|
||||||
|
IGNORED = 'ignored'
|
||||||
|
UNKNOWN = 'unknown'
|
||||||
|
|
||||||
|
RESULT_STATUS = (
|
||||||
|
(OK, 'ok'),
|
||||||
|
(FAILED, 'failed'),
|
||||||
|
(SKIPPED, 'skipped'),
|
||||||
|
(UNREACHABLE, 'unreachable'),
|
||||||
|
(UNKNOWN, 'unknown')
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger('ara_backend.models')
|
logger = logging.getLogger('ara_backend.models')
|
||||||
|
|
||||||
|
# TODO: Figure out what to do when creating the first playbook file
|
||||||
|
# -> create playbook first
|
||||||
|
# -> create file/file_content and link to playbook_id (foreign key)
|
||||||
|
# -> make is_playbook = True because it's a playbook file
|
||||||
|
# -> Add a unique constraint on "is_playbook = True" for a given playbook id ?
|
||||||
|
|
||||||
|
# TODO: Get feedback on model
|
||||||
|
# playbook -> play -> task -> host -> result
|
||||||
|
# -> host (hosts are associated/filtered to a play)
|
||||||
|
# playbook -> file <- file_content
|
||||||
|
# task -> file <- file_content
|
||||||
|
# statistics for a playbook are cumulated per host
|
||||||
|
# facts are retrieved for a host (printing those in CLI is terrible)
|
||||||
|
|
||||||
|
# - There's multiple results for a host throughout a playbook
|
||||||
|
# - There's multiple hosts for a task
|
||||||
|
# - There's multiple tasks in a play
|
||||||
|
# - There's multiple play in a playbook
|
||||||
|
# - Hosts need to be associated to a play
|
||||||
|
# - Should all the binary things be in a single table so it's easier to shard ?
|
||||||
|
# - e.g, Reddit's ThingDB
|
||||||
|
|
||||||
|
|
||||||
class Base(models.Model):
|
class Base(models.Model):
|
||||||
|
"""
|
||||||
|
Abstract base model part of every model
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
id = models.BigAutoField(primary_key=True, editable=False)
|
id = models.BigAutoField(primary_key=True, editable=False)
|
||||||
created = models.DateTimeField(auto_now_add=True, editable=False)
|
created = models.DateTimeField(auto_now_add=True, editable=False)
|
||||||
updated = models.DateTimeField(auto_now=True, editable=False)
|
updated = models.DateTimeField(auto_now=True, editable=False)
|
||||||
|
|
||||||
@property
|
|
||||||
def age(self):
|
|
||||||
"""
|
|
||||||
Calculates duration between created and updated.
|
|
||||||
"""
|
|
||||||
return self.updated - self.created
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
|
|
||||||
class DurationMixin(models.Model):
|
class DurationMixin(models.Model):
|
||||||
started = models.DateTimeField(default=timezone.now)
|
"""
|
||||||
ended = models.DateTimeField(blank=True, null=True)
|
Abstract model for models with a concept of duration
|
||||||
|
"""
|
||||||
@property
|
|
||||||
def duration(self):
|
|
||||||
"""
|
|
||||||
Calculates duration between started and ended or between started and
|
|
||||||
updated if we do not yet have an end.
|
|
||||||
"""
|
|
||||||
if self.ended is None:
|
|
||||||
if self.started is None:
|
|
||||||
return timezone.timedelta(seconds=0)
|
|
||||||
else:
|
|
||||||
return self.updated - self.started
|
|
||||||
return self.ended - self.started
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
started = models.DateTimeField(default=timezone.now)
|
||||||
|
ended = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
|
|
||||||
class Playbook(Base, DurationMixin):
|
class Playbook(Base, DurationMixin):
|
||||||
"""
|
"""
|
||||||
|
@ -75,10 +101,14 @@ class Playbook(Base, DurationMixin):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '<Playbook %s:%s>' % (self.id, self.path)
|
return '<Playbook %s:%s>' % (self.id, self.path)
|
||||||
__repr__ = __str__
|
|
||||||
|
|
||||||
|
|
||||||
class FileContent(Base):
|
class FileContent(Base):
|
||||||
|
"""
|
||||||
|
Contents of a uniquely stored and compressed file.
|
||||||
|
Running the same playbook twice will yield two playbook files but just
|
||||||
|
one file contents.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'file_contents'
|
db_table = 'file_contents'
|
||||||
|
|
||||||
|
@ -87,10 +117,13 @@ class FileContent(Base):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '<FileContent %s:%s>' % (self.id, self.sha1)
|
return '<FileContent %s:%s>' % (self.id, self.sha1)
|
||||||
__repr__ = __str__
|
|
||||||
|
|
||||||
|
|
||||||
class File(Base):
|
class File(Base):
|
||||||
|
"""
|
||||||
|
Data about Ansible files (playbooks, tasks, role files, var files, etc).
|
||||||
|
Multiple files can reference the same FileContent record.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'files'
|
db_table = 'files'
|
||||||
unique_together = ('path', 'playbook',)
|
unique_together = ('path', 'playbook',)
|
||||||
|
@ -106,10 +139,13 @@ class File(Base):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '<File %s:%s>' % (self.id, self.path)
|
return '<File %s:%s>' % (self.id, self.path)
|
||||||
__repr__ = __str__
|
|
||||||
|
|
||||||
|
|
||||||
class Record(Base):
|
class Record(Base):
|
||||||
|
"""
|
||||||
|
A rudimentary key/value table to associate arbitrary data to a playbook.
|
||||||
|
Used with the ara_record and ara_read Ansible modules.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'records'
|
db_table = 'records'
|
||||||
unique_together = ('key', 'playbook',)
|
unique_together = ('key', 'playbook',)
|
||||||
|
@ -123,10 +159,31 @@ class Record(Base):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '<Record %s:%s>' % (self.id, self.key)
|
return '<Record %s:%s>' % (self.id, self.key)
|
||||||
__repr__ = __str__
|
|
||||||
|
|
||||||
|
class Play(Base, DurationMixin):
|
||||||
|
"""
|
||||||
|
Data about Ansible plays.
|
||||||
|
Hosts, tasks and results are childrens of an Ansible play.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
db_table = 'plays'
|
||||||
|
|
||||||
|
name = models.TextField(blank=True, null=True)
|
||||||
|
playbook = models.ForeignKey(Playbook,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='plays')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return '<Play %s:%s>' % (self.name, self.id)
|
||||||
|
|
||||||
|
|
||||||
class Host(Base):
|
class Host(Base):
|
||||||
|
"""
|
||||||
|
Data about Ansible hosts.
|
||||||
|
Contains compressed host facts and statistics about the host for the
|
||||||
|
playbook.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'hosts'
|
db_table = 'hosts'
|
||||||
unique_together = ('name', 'playbook',)
|
unique_together = ('name', 'playbook',)
|
||||||
|
@ -141,35 +198,23 @@ class Host(Base):
|
||||||
playbook = models.ForeignKey(Playbook,
|
playbook = models.ForeignKey(Playbook,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='hosts')
|
related_name='hosts')
|
||||||
|
play = models.ForeignKey(Play,
|
||||||
|
on_delete=models.DO_NOTHING,
|
||||||
|
related_name='hosts')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '<Host %s:%s>' % (self.id, self.name)
|
return '<Host %s:%s>' % (self.id, self.name)
|
||||||
__repr__ = __str__
|
|
||||||
|
|
||||||
|
|
||||||
class Play(Base, DurationMixin):
|
|
||||||
class Meta:
|
|
||||||
db_table = 'plays'
|
|
||||||
|
|
||||||
name = models.TextField()
|
|
||||||
playbook = models.ForeignKey(Playbook,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='plays')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def offset_from_playbook(self):
|
|
||||||
return self.started - self.playbook.started
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return '<Play %s:%s>' % (self.name, self.id)
|
|
||||||
__repr__ = __str__
|
|
||||||
|
|
||||||
|
|
||||||
class Task(Base, DurationMixin):
|
class Task(Base, DurationMixin):
|
||||||
|
"""
|
||||||
|
Data about Ansible tasks.
|
||||||
|
Results are children of Ansible tasks.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'tasks'
|
db_table = 'tasks'
|
||||||
|
|
||||||
name = models.TextField()
|
name = models.TextField(blank=True, null=True)
|
||||||
action = models.TextField()
|
action = models.TextField()
|
||||||
lineno = models.IntegerField()
|
lineno = models.IntegerField()
|
||||||
tags = models.BinaryField(max_length=(2 ** 32) - 1)
|
tags = models.BinaryField(max_length=(2 ** 32) - 1)
|
||||||
|
@ -178,48 +223,25 @@ class Task(Base, DurationMixin):
|
||||||
playbook = models.ForeignKey(Playbook,
|
playbook = models.ForeignKey(Playbook,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='tasks')
|
related_name='tasks')
|
||||||
|
play = models.ForeignKey(Play,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='tasks')
|
||||||
file = models.ForeignKey(File,
|
file = models.ForeignKey(File,
|
||||||
on_delete=models.DO_NOTHING,
|
on_delete=models.DO_NOTHING,
|
||||||
related_name='tasks')
|
related_name='tasks')
|
||||||
play = models.ForeignKey(Play,
|
|
||||||
on_delete=models.DO_NOTHING,
|
|
||||||
related_name='tasks')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def offset_from_playbook(self):
|
|
||||||
return self.started - self.playbook.started
|
|
||||||
|
|
||||||
@property
|
|
||||||
def offset_from_play(self):
|
|
||||||
return self.started - self.play.started
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '<Task %s:%s>' % (self.name, self.id)
|
return '<Task %s:%s>' % (self.name, self.id)
|
||||||
__repr__ = __str__
|
|
||||||
|
|
||||||
|
|
||||||
class Result(Base, DurationMixin):
|
class Result(Base, DurationMixin):
|
||||||
|
"""
|
||||||
|
Data about Ansible results.
|
||||||
|
A task can have many results if the task is run on multiple hosts.
|
||||||
|
"""
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'results'
|
db_table = 'results'
|
||||||
|
|
||||||
# Ansible statuses
|
|
||||||
OK = 'ok'
|
|
||||||
FAILED = 'failed'
|
|
||||||
SKIPPED = 'skipped'
|
|
||||||
UNREACHABLE = 'unreachable'
|
|
||||||
# ARA specific statuses (derived or assumed)
|
|
||||||
CHANGED = 'changed'
|
|
||||||
IGNORED = 'ignored'
|
|
||||||
UNKNOWN = 'unknown'
|
|
||||||
|
|
||||||
RESULT_STATUS = (
|
|
||||||
(OK, 'ok'),
|
|
||||||
(FAILED, 'failed'),
|
|
||||||
(SKIPPED, 'skipped'),
|
|
||||||
(UNREACHABLE, 'unreachable'),
|
|
||||||
(UNKNOWN, 'unknown')
|
|
||||||
)
|
|
||||||
|
|
||||||
status = models.CharField(max_length=25,
|
status = models.CharField(max_length=25,
|
||||||
choices=RESULT_STATUS,
|
choices=RESULT_STATUS,
|
||||||
default=UNKNOWN)
|
default=UNKNOWN)
|
||||||
|
@ -229,23 +251,19 @@ class Result(Base, DurationMixin):
|
||||||
unreachable = models.BooleanField(default=False)
|
unreachable = models.BooleanField(default=False)
|
||||||
ignore_errors = models.BooleanField(default=False)
|
ignore_errors = models.BooleanField(default=False)
|
||||||
result = models.BinaryField(max_length=(2 ** 32) - 1)
|
result = models.BinaryField(max_length=(2 ** 32) - 1)
|
||||||
|
|
||||||
playbook = models.ForeignKey(Playbook,
|
playbook = models.ForeignKey(Playbook,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name='results')
|
related_name='results')
|
||||||
|
play = models.ForeignKey(Play,
|
||||||
@property
|
on_delete=models.CASCADE,
|
||||||
def derived_status(self):
|
related_name='results')
|
||||||
if self.status == self.OK and self.changed:
|
task = models.ForeignKey(Task,
|
||||||
return self.CHANGED
|
on_delete=models.CASCADE,
|
||||||
elif self.status == self.FAILED and self.ignore_errors:
|
related_name='results')
|
||||||
return self.IGNORED
|
host = models.ForeignKey(Host,
|
||||||
elif self.status not in [
|
on_delete=models.CASCADE,
|
||||||
self.OK, self.FAILED, self.SKIPPED, self.UNREACHABLE
|
related_name='results')
|
||||||
]:
|
|
||||||
return self.UNKNOWN
|
|
||||||
else:
|
|
||||||
return self.status
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '<Result %s:%s>' % (self.id, self.derived_status)
|
return '<Result %s, %s:%s>' % (self.id, self.host.name, self.status)
|
||||||
__repr__ = __str__
|
|
||||||
|
|
|
@ -1,6 +1,22 @@
|
||||||
# -*- coding: utf-8 -*-
|
# Copyright (c) 2018 Red Hat, Inc.
|
||||||
import hashlib
|
#
|
||||||
|
# This file is part of ARA Records Ansible.
|
||||||
|
#
|
||||||
|
# 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 json
|
import json
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import zlib
|
import zlib
|
||||||
from api import models
|
from api import models
|
||||||
|
@ -13,6 +29,10 @@ logger = logging.getLogger('api.serializers')
|
||||||
|
|
||||||
|
|
||||||
class CompressedTextField(serializers.CharField):
|
class CompressedTextField(serializers.CharField):
|
||||||
|
"""
|
||||||
|
Compresses text before storing it in the database.
|
||||||
|
Decompresses text from the database before serving it.
|
||||||
|
"""
|
||||||
def to_representation(self, obj):
|
def to_representation(self, obj):
|
||||||
return zlib.decompress(obj).decode('utf8')
|
return zlib.decompress(obj).decode('utf8')
|
||||||
|
|
||||||
|
@ -21,6 +41,11 @@ class CompressedTextField(serializers.CharField):
|
||||||
|
|
||||||
|
|
||||||
class CompressedObjectField(serializers.JSONField):
|
class CompressedObjectField(serializers.JSONField):
|
||||||
|
"""
|
||||||
|
Serializes/compresses an object (i.e, list, dict) before storing it in the
|
||||||
|
database.
|
||||||
|
Decompresses/deserializes an object before serving it.
|
||||||
|
"""
|
||||||
def to_representation(self, obj):
|
def to_representation(self, obj):
|
||||||
return json.loads(zlib.decompress(obj).decode('utf8'))
|
return json.loads(zlib.decompress(obj).decode('utf8'))
|
||||||
|
|
||||||
|
@ -28,18 +53,32 @@ class CompressedObjectField(serializers.JSONField):
|
||||||
return zlib.compress(data.encode('utf8'))
|
return zlib.compress(data.encode('utf8'))
|
||||||
|
|
||||||
|
|
||||||
class SHA1Field(serializers.CharField):
|
class ItemDurationField(serializers.DurationField):
|
||||||
def to_representation(self, obj):
|
"""
|
||||||
return json.loads(lzma.decompress(obj).decode('utf8'))
|
Calculates duration between started and ended or between started and
|
||||||
|
updated if we do not yet have an end.
|
||||||
|
"""
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
kwargs['read_only'] = True
|
||||||
|
super(ItemDurationField, self).__init__(**kwargs)
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_representation(self, obj):
|
||||||
return lzma.compress(data.encode('utf8'))
|
if obj.ended is None:
|
||||||
|
if obj.started is None:
|
||||||
|
return timezone.timedelta(seconds=0)
|
||||||
|
else:
|
||||||
|
return obj.updated - obj.started
|
||||||
|
return obj.ended - obj.started
|
||||||
|
|
||||||
|
|
||||||
class BaseSerializer(serializers.ModelSerializer):
|
class BaseSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for the data in the model base
|
Serializer for the data in the model base
|
||||||
"""
|
"""
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
id = serializers.IntegerField(read_only=True)
|
||||||
created = serializers.DateTimeField(
|
created = serializers.DateTimeField(
|
||||||
read_only=True,
|
read_only=True,
|
||||||
help_text='Date of creation %s' % DATE_FORMAT
|
help_text='Date of creation %s' % DATE_FORMAT
|
||||||
|
@ -48,19 +87,15 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||||
read_only=True,
|
read_only=True,
|
||||||
help_text='Date of last update %s' % DATE_FORMAT
|
help_text='Date of last update %s' % DATE_FORMAT
|
||||||
)
|
)
|
||||||
age = serializers.DurationField(
|
|
||||||
read_only=True,
|
|
||||||
help_text='Duration since the creation %s' % DURATION_FORMAT
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
|
|
||||||
class DurationSerializer(serializers.ModelSerializer):
|
class DurationSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for duration-based fields
|
Serializer for duration-based fields
|
||||||
"""
|
"""
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
started = serializers.DateTimeField(
|
started = serializers.DateTimeField(
|
||||||
initial=timezone.now().isoformat(),
|
initial=timezone.now().isoformat(),
|
||||||
help_text='Date this item started %s' % DATE_FORMAT
|
help_text='Date this item started %s' % DATE_FORMAT
|
||||||
|
@ -69,10 +104,7 @@ class DurationSerializer(serializers.ModelSerializer):
|
||||||
required=False,
|
required=False,
|
||||||
help_text='Date this item ended %s' % DATE_FORMAT
|
help_text='Date this item ended %s' % DATE_FORMAT
|
||||||
)
|
)
|
||||||
duration = serializers.DurationField(
|
duration = ItemDurationField(source='*')
|
||||||
read_only=True,
|
|
||||||
help_text="Duration between 'started' and 'ended' %s" % DURATION_FORMAT
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
"""
|
"""
|
||||||
|
@ -84,11 +116,8 @@ class DurationSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
|
class PlaybookSerializer(serializers.HyperlinkedModelSerializer, BaseSerializer, DurationSerializer):
|
||||||
class PlaybookSerializer(serializers.HyperlinkedModelSerializer, BaseSerializer):
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Playbook
|
model = models.Playbook
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
@ -99,12 +128,13 @@ class PlaybookSerializer(serializers.HyperlinkedModelSerializer, BaseSerializer)
|
||||||
read_only=True,
|
read_only=True,
|
||||||
help_text='Plays associated to this playbook'
|
help_text='Plays associated to this playbook'
|
||||||
)
|
)
|
||||||
# tasks = serializers.HyperlinkedRelatedField(
|
tasks = serializers.HyperlinkedRelatedField(
|
||||||
# many=True,
|
many=True,
|
||||||
# read_only=True,
|
view_name='task-detail',
|
||||||
# view_name='tasks',
|
read_only=True,
|
||||||
# help_text='Tasks associated to this playbook'
|
help_text='Tasks associated to this playbook'
|
||||||
# )
|
)
|
||||||
|
|
||||||
# hosts = serializers.HyperlinkedRelatedField(
|
# hosts = serializers.HyperlinkedRelatedField(
|
||||||
# many=True,
|
# many=True,
|
||||||
# read_only=True,
|
# read_only=True,
|
||||||
|
@ -123,17 +153,16 @@ class PlaybookSerializer(serializers.HyperlinkedModelSerializer, BaseSerializer)
|
||||||
# view_name='records',
|
# view_name='records',
|
||||||
# help_text='Records associated to this playbook'
|
# help_text='Records associated to this playbook'
|
||||||
# )
|
# )
|
||||||
# files = serializers.HyperlinkedRelatedField(
|
files = serializers.HyperlinkedRelatedField(
|
||||||
# many=True,
|
many=True,
|
||||||
# read_only=True,
|
view_name='file-detail',
|
||||||
# view_name='files',
|
read_only=True,
|
||||||
# help_text='Records associated to this playbook'
|
help_text='Files associated to this playbook'
|
||||||
# )
|
)
|
||||||
#
|
parameters = CompressedObjectField(
|
||||||
# parameters = CompressedObjectField(
|
initial={},
|
||||||
# initial={},
|
help_text='A JSON dictionary containing Ansible command parameters'
|
||||||
# help_text='A JSON dictionary containing Ansible command parameters'
|
)
|
||||||
# )
|
|
||||||
path = serializers.CharField(help_text='Path to the playbook file')
|
path = serializers.CharField(help_text='Path to the playbook file')
|
||||||
ansible_version = serializers.CharField(
|
ansible_version = serializers.CharField(
|
||||||
help_text='Version of Ansible used to run this playbook'
|
help_text='Version of Ansible used to run this playbook'
|
||||||
|
@ -143,16 +172,81 @@ class PlaybookSerializer(serializers.HyperlinkedModelSerializer, BaseSerializer)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class PlaySerializer(BaseSerializer, DurationSerializer):
|
class PlaySerializer(serializers.HyperlinkedModelSerializer, BaseSerializer, DurationSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Play
|
model = models.Play
|
||||||
fields = '__all__'
|
fields = '__all__'
|
||||||
|
|
||||||
#
|
playbook = serializers.HyperlinkedRelatedField(
|
||||||
# class TaskSerializer(BaseSerializer, DurationSerializer):
|
view_name='playbook-detail',
|
||||||
# class Meta:
|
read_only=True,
|
||||||
# model = models.Task
|
help_text='Playbook associated to this play'
|
||||||
# fields = '__all__'
|
)
|
||||||
|
tasks = serializers.HyperlinkedRelatedField(
|
||||||
|
many=True,
|
||||||
|
view_name='task-detail',
|
||||||
|
read_only=True,
|
||||||
|
help_text='Tasks associated to this play'
|
||||||
|
)
|
||||||
|
name = serializers.CharField(
|
||||||
|
help_text='Name of the play',
|
||||||
|
allow_blank=True,
|
||||||
|
allow_null=True,
|
||||||
|
)
|
||||||
|
# hosts = serializers.HyperlinkedRelatedField(
|
||||||
|
# many=True,
|
||||||
|
# view_name='host-detail',
|
||||||
|
# read_only=True,
|
||||||
|
# help_text='Hosts associated to this play'
|
||||||
|
#)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskSerializer(serializers.HyperlinkedModelSerializer, BaseSerializer, DurationSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = models.Task
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
playbook = serializers.HyperlinkedRelatedField(
|
||||||
|
view_name='playbook-detail',
|
||||||
|
read_only=True,
|
||||||
|
help_text='Playbook associated to this task'
|
||||||
|
)
|
||||||
|
play = serializers.HyperlinkedRelatedField(
|
||||||
|
view_name='play-detail',
|
||||||
|
read_only=True,
|
||||||
|
help_text='Play associated to this task'
|
||||||
|
)
|
||||||
|
file = serializers.HyperlinkedRelatedField(
|
||||||
|
view_name='file-detail',
|
||||||
|
read_only=True,
|
||||||
|
help_text='File associated to this task'
|
||||||
|
)
|
||||||
|
# results = serializers.HyperlinkedRelatedField(
|
||||||
|
# many=True,
|
||||||
|
# view_name='result-detail',
|
||||||
|
# read_only=True,
|
||||||
|
# help_text='Results associated to this task'
|
||||||
|
# )
|
||||||
|
name = serializers.CharField(
|
||||||
|
help_text='Name of the task',
|
||||||
|
allow_blank=True,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
action = serializers.CharField(help_text='Action of the task')
|
||||||
|
lineno = serializers.IntegerField(
|
||||||
|
help_text='Line number in the file of the task'
|
||||||
|
)
|
||||||
|
tags = CompressedObjectField(
|
||||||
|
help_text='A JSON list containing Ansible tags',
|
||||||
|
initial=[],
|
||||||
|
default=[],
|
||||||
|
)
|
||||||
|
handler = serializers.BooleanField(
|
||||||
|
help_text='Whether or not this task was a handler',
|
||||||
|
initial=False,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
# class HostSerializer(BaseSerializer):
|
# class HostSerializer(BaseSerializer):
|
||||||
|
@ -165,44 +259,65 @@ class PlaySerializer(BaseSerializer, DurationSerializer):
|
||||||
# class Meta:
|
# class Meta:
|
||||||
# model = models.Result
|
# model = models.Result
|
||||||
# fields = '__all__'
|
# fields = '__all__'
|
||||||
#
|
# @property
|
||||||
|
# def derived_status(self):
|
||||||
|
# if self.status == self.OK and self.changed:
|
||||||
|
# return self.CHANGED
|
||||||
|
# elif self.status == self.FAILED and self.ignore_errors:
|
||||||
|
# return self.IGNORED
|
||||||
|
# elif self.status not in [
|
||||||
|
# self.OK, self.FAILED, self.SKIPPED, self.UNREACHABLE
|
||||||
|
# ]:
|
||||||
|
# return self.UNKNOWN
|
||||||
|
# else:
|
||||||
|
# return self.status
|
||||||
#
|
#
|
||||||
# class RecordSerializer(BaseSerializer):
|
# class RecordSerializer(BaseSerializer):
|
||||||
# class Meta:
|
# class Meta:
|
||||||
# model = models.Record
|
# model = models.Record
|
||||||
# fields = '__all__'
|
# fields = '__all__'
|
||||||
#
|
#
|
||||||
#
|
|
||||||
# class FileContentSerializer(BaseSerializer):
|
|
||||||
# class Meta:
|
class FileContentSerializer(BaseSerializer):
|
||||||
# model = models.FileContent
|
class Meta:
|
||||||
# fields = ('contents', 'sha1')
|
model = models.FileContent
|
||||||
#
|
fields = '__all__'
|
||||||
# contents = CompressedTextField(help_text='Contents of the file')
|
|
||||||
# sha1 = serializers.CharField(read_only=True, help_text='sha1 of the file')
|
contents = CompressedTextField(help_text='Contents of the file')
|
||||||
#
|
sha1 = serializers.CharField(read_only=True, help_text='sha1 of the file')
|
||||||
# def create(self, validated_data):
|
|
||||||
# sha1 = hashlib.sha1(validated_data['contents']).hexdigest()
|
def create(self, validated_data):
|
||||||
# validated_data['sha1'] = sha1
|
sha1 = hashlib.sha1(validated_data['contents']).hexdigest()
|
||||||
# obj, created = models.FileContent.objects.get_or_create(
|
validated_data['sha1'] = sha1
|
||||||
# **validated_data
|
obj, created = models.FileContent.objects.get_or_create(
|
||||||
# )
|
**validated_data
|
||||||
# return obj
|
)
|
||||||
#
|
return obj
|
||||||
#
|
|
||||||
# class FileSerializer(BaseSerializer):
|
|
||||||
# path = serializers.CharField(help_text='Path to the file')
|
class FileSerializer(serializers.HyperlinkedModelSerializer, BaseSerializer):
|
||||||
# content = FileContentSerializer()
|
class Meta:
|
||||||
#
|
model = models.File
|
||||||
# def create(self, validated_data):
|
fields = '__all__'
|
||||||
# contents = validated_data.pop('content')['contents']
|
|
||||||
# obj, created = models.FileContent.objects.get_or_create(
|
# TODO: Why doesn't this work ? There's no playbook field shown.
|
||||||
# contents=contents,
|
# Works just fine in other serializers (ex: task)
|
||||||
# sha1=hashlib.sha1(contents).hexdigest()
|
# playbook = serializers.HyperlinkedRelatedField(
|
||||||
# )
|
# view_name='playbook-detail',
|
||||||
# validated_data['content'] = obj
|
# read_only=True,
|
||||||
# return models.File.objects.create(**validated_data)
|
# help_text='Playbook associated to this file'
|
||||||
#
|
# )
|
||||||
# class Meta:
|
path = serializers.CharField(help_text='Path to the file')
|
||||||
# model = models.File
|
# TODO: This probably needs to be a related field to filecontent serializer
|
||||||
# fields = ('id', 'path', 'content', 'playbook')
|
content = serializers.CharField()
|
||||||
|
is_playbook = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
content = validated_data.pop('content')
|
||||||
|
obj, created = models.FileContent.objects.get_or_create(
|
||||||
|
contents=content.encode('utf8'),
|
||||||
|
sha1=hashlib.sha1(content.encode('utf8')).hexdigest()
|
||||||
|
)
|
||||||
|
validated_data['content'] = obj
|
||||||
|
return models.File.objects.create(**validated_data)
|
||||||
|
|
|
@ -25,6 +25,10 @@ urlpatterns = [
|
||||||
url(r'^playbooks/(?P<pk>[0-9]+)/$', views.PlaybookDetail.as_view(), name='playbook-detail'),
|
url(r'^playbooks/(?P<pk>[0-9]+)/$', views.PlaybookDetail.as_view(), name='playbook-detail'),
|
||||||
url(r'^plays/$', views.PlayList.as_view(), name='play-list'),
|
url(r'^plays/$', views.PlayList.as_view(), name='play-list'),
|
||||||
url(r'^plays/(?P<pk>[0-9]+)/$', views.PlayDetail.as_view(), name='play-detail'),
|
url(r'^plays/(?P<pk>[0-9]+)/$', views.PlayDetail.as_view(), name='play-detail'),
|
||||||
|
url(r'^tasks/$', views.TaskList.as_view(), name='task-list'),
|
||||||
|
url(r'^tasks/(?P<pk>[0-9]+)/$', views.TaskDetail.as_view(), name='task-detail'),
|
||||||
|
url(r'^files/$', views.FileList.as_view(), name='file-list'),
|
||||||
|
url(r'^files/(?P<pk>[0-9]+)/$', views.FileDetail.as_view(), name='file-detail'),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns = format_suffix_patterns(urlpatterns)
|
urlpatterns = format_suffix_patterns(urlpatterns)
|
||||||
|
|
24
api/views.py
24
api/views.py
|
@ -27,7 +27,9 @@ from rest_framework import generics
|
||||||
def api_root(request, format=None):
|
def api_root(request, format=None):
|
||||||
return Response({
|
return Response({
|
||||||
'playbooks': reverse('playbook-list', request=request, format=format),
|
'playbooks': reverse('playbook-list', request=request, format=format),
|
||||||
'plays': reverse('play-list', request=request, format=format)
|
'plays': reverse('play-list', request=request, format=format),
|
||||||
|
'tasks': reverse('task-list', request=request, format=format),
|
||||||
|
'files': reverse('file-list', request=request, format=format)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -49,3 +51,23 @@ class PlayList(generics.ListCreateAPIView):
|
||||||
class PlayDetail(generics.RetrieveUpdateDestroyAPIView):
|
class PlayDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
queryset = models.Play.objects.all()
|
queryset = models.Play.objects.all()
|
||||||
serializer_class = serializers.PlaySerializer
|
serializer_class = serializers.PlaySerializer
|
||||||
|
|
||||||
|
|
||||||
|
class TaskList(generics.ListCreateAPIView):
|
||||||
|
queryset = models.Task.objects.all()
|
||||||
|
serializer_class = serializers.TaskSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class TaskDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
queryset = models.Task.objects.all()
|
||||||
|
serializer_class = serializers.TaskSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class FileList(generics.ListCreateAPIView):
|
||||||
|
queryset = models.File.objects.all()
|
||||||
|
serializer_class = serializers.FileSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class FileDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
queryset = models.File.objects.all()
|
||||||
|
serializer_class = serializers.FileSerializer
|
||||||
|
|
|
@ -17,7 +17,7 @@ SECRET_KEY = env('SECRET_KEY', preprocessor=get_secret_key, default=None)
|
||||||
|
|
||||||
DEBUG = env.bool('DJANGO_DEBUG', default=False)
|
DEBUG = env.bool('DJANGO_DEBUG', default=False)
|
||||||
|
|
||||||
ALLOWED_HOSTS = env('ALLOWED_HOSTS', cast=list, default=['localhost', '127.0.0.1'])
|
ALLOWED_HOSTS = env('ALLOWED_HOSTS', cast=list, default=['localhost', '127.0.0.1', 'testserver'])
|
||||||
|
|
||||||
ADMINS = (('Guillaume Vincent', 'gvincent@redhat.com'),)
|
ADMINS = (('Guillaume Vincent', 'gvincent@redhat.com'),)
|
||||||
|
|
||||||
|
|
|
@ -16,9 +16,10 @@
|
||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with ARA. If not, see <http://www.gnu.org/licenses/>.
|
# along with ARA. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
# Creates fake data in the database, bypassing the API.
|
# Creates mock data offline leveraging the API
|
||||||
import django
|
import django
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
|
@ -29,40 +30,75 @@ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ara.settings')
|
||||||
django.setup()
|
django.setup()
|
||||||
|
|
||||||
from api import models
|
from api import models
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
playbook, _ = models.Playbook.objects.get_or_create(
|
|
||||||
started='2016-05-06T17:20:25.749489-04:00',
|
|
||||||
path='/tmp/test.yml',
|
|
||||||
ansible_version='2.3.4',
|
|
||||||
completed=False,
|
|
||||||
)
|
|
||||||
print(serializers.serialize('json',
|
|
||||||
models.Playbook.objects.all(),
|
|
||||||
indent=2))
|
|
||||||
|
|
||||||
play, _ = models.Play.objects.get_or_create(
|
def post(endpoint, data):
|
||||||
started='2016-05-06T17:20:25.749489-04:00',
|
client = Client()
|
||||||
name='Test play',
|
print("Posting to %s..." % endpoint)
|
||||||
playbook=playbook,
|
obj = client.post(endpoint, data)
|
||||||
)
|
print("HTTP %s" % obj.status_code)
|
||||||
print(serializers.serialize('json',
|
print("Got: %s" % json.dumps(obj.json(), indent=2))
|
||||||
models.Play.objects.all(),
|
print("#" * 40)
|
||||||
indent=2))
|
|
||||||
|
|
||||||
content = 'foo'.encode('utf8')
|
return obj
|
||||||
filecontent, _ = models.FileContent.objects.get_or_create(
|
|
||||||
contents=content,
|
|
||||||
sha1=hashlib.sha1(content).hexdigest()
|
|
||||||
)
|
|
||||||
print(serializers.serialize('json',
|
|
||||||
models.FileContent.objects.all(),
|
|
||||||
indent=2))
|
|
||||||
|
|
||||||
file, _ = models.File.objects.get_or_create(
|
|
||||||
playbook=playbook,
|
playbook = post(
|
||||||
content=filecontent,
|
'/api/v1/playbooks/',
|
||||||
path='/tmp/anothertest.yml'
|
dict(
|
||||||
|
started='2016-05-06T17:20:25.749489-04:00',
|
||||||
|
path='/tmp/playbook.yml',
|
||||||
|
ansible_version='2.3.4',
|
||||||
|
completed=False,
|
||||||
|
parameters=json.dumps(dict(
|
||||||
|
foo='bar'
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
play = post(
|
||||||
|
'/api/v1/plays/',
|
||||||
|
dict(
|
||||||
|
started='2016-05-06T17:20:25.749489-04:00',
|
||||||
|
name='Test play',
|
||||||
|
playbook=playbook.json()['url']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
playbook_file = post(
|
||||||
|
'/api/v1/files/',
|
||||||
|
dict(
|
||||||
|
path=playbook.json()['path'],
|
||||||
|
# TODO: Fix this somehow
|
||||||
|
content='# playbook',
|
||||||
|
playbook=playbook.json()['url'],
|
||||||
|
is_playbook=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
task_file = post(
|
||||||
|
'/api/v1/files/',
|
||||||
|
dict(
|
||||||
|
playbook=playbook.json()['url'],
|
||||||
|
path='/tmp/task.yml',
|
||||||
|
# TODO: Fix this somehow
|
||||||
|
content='# task',
|
||||||
|
is_playbook=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
task = post(
|
||||||
|
'/api/v1/tasks/',
|
||||||
|
dict(
|
||||||
|
playbook=playbook.json()['url'],
|
||||||
|
play=play.json()['url'],
|
||||||
|
file=task_file.json()['url'],
|
||||||
|
name='Task name',
|
||||||
|
action='action',
|
||||||
|
lineno=1,
|
||||||
|
tags=json.dumps(['one', 'two']),
|
||||||
|
handler=False,
|
||||||
|
started='2016-05-06T17:20:25.749489-04:00'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
print(serializers.serialize('json',
|
|
||||||
models.File.objects.all(),
|
|
||||||
indent=2))
|
|
||||||
|
|
Loading…
Reference in New Issue