Files
fm-orchestrator/module_build_service/models.py
Ralph Bean 439721da99 Gather build groups from module profiles.
Fixes #181.

When initializing the buildroot for a module build, we used to set up
some build 'groups' for the tag: `build` and `srpm-build`.  These are
the lists of RPMs that koji is supposed to install into the buildroot
before anything else is done.  Crucial stuff goes here, like `git` in
the `srpm-build` group so that koji can clone the repo in the first
place.

We had those lists hardcoded before.  This list changes that to use the
`buildroot` and `srpm-buildroot` profiles of the modules which are our
dependencies (recursively).

This will allow people like @psabata and the base-runtime to make
changes to the build groups for the generational core and work around
their own problems, instead of having to ask us to expand that list.

There were a couple ways to do this:

- I could've cloned the SCM repos for all dependencies and gotten their
  profiles from the modulemd source there.  This seemed flimsy because
  we only want to depend on the profiles of modules that were *really*
  built.
- We could modify PDC to stuff the modulemd contents in there.  We
  already get some dep and tag info from PDC.  My thought here was that
  it would be too heavyweight to store every copy of the modulemd file
  in PDC for every build ever.  We already have it in MBS.
- Lastly, and this is what I did here, I just referred to MBS' own
  database to get the profiles.  This seems to work just fine.

One side-effect is that we need the build profiles from the manually
bootstrapped modules that were put together by hand, and were never
built in the MBS.  In order to work around that, I added an alembic
upgrade script which pre-populates the database with one fake
bootstrapped base-runtime module.  We can expand on that in the future
if we need.
2016-11-21 13:25:52 -05:00

396 lines
14 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2016 Red Hat, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Written by Petr Šabata <contyk@redhat.com>
# Ralph Bean <rbean@redhat.com>
# Matt Prahl <mprahl@redhat.com>
""" SQLAlchemy Database models for the Flask app
"""
import contextlib
from datetime import datetime
from sqlalchemy import engine_from_config, or_
from sqlalchemy.orm import validates, scoped_session, sessionmaker
import modulemd as _modulemd
from module_build_service import db, log, get_url_for
import module_build_service.messaging
from sqlalchemy.orm import lazyload
from flask import url_for
# Just like koji.BUILD_STATES, except our own codes for modules.
BUILD_STATES = {
# When you parse the modulemd file and know the nvr and you create a
# record in the db, and that's it.
# publish the message
# validate that components are available
# and that you can fetch them.
# if all is good, go to wait: telling module_build_service_daemon to take over.
# if something is bad, go straight to failed.
"init": 0,
# Here, the scheduler picks up tasks in wait.
# switch to build immediately.
# throttling logic (when we write it) goes here.
"wait": 1,
# Actively working on it.
"build": 2,
# All is good
"done": 3,
# Something failed
"failed": 4,
# This is a state to be set when a module is ready to be part of a
# larger compose. perhaps it is set by an external service that knows
# about the Grand Plan.
"ready": 5,
}
INVERSE_BUILD_STATES = {v: k for k, v in BUILD_STATES.items()}
@contextlib.contextmanager
def make_session(conf):
# TODO - we could use ZopeTransactionExtension() here some day for
# improved safety on the backend.
engine = engine_from_config({
'sqlalchemy.url': conf.sqlalchemy_database_uri,
})
session = scoped_session(sessionmaker(bind=engine))()
try:
yield session
session.commit()
except:
# This is a no-op if no transaction is in progress.
session.rollback()
raise
finally:
session.close()
class RidaBase(db.Model):
# TODO -- we can implement functionality here common to all our model classes
__abstract__ = True
class Module(RidaBase):
__tablename__ = "modules"
name = db.Column(db.String, primary_key=True)
class ModuleBuild(RidaBase):
__tablename__ = "module_builds"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, db.ForeignKey('modules.name'), nullable=False)
stream = db.Column(db.String, nullable=False)
version = db.Column(db.String, nullable=False)
state = db.Column(db.Integer, nullable=False)
state_reason = db.Column(db.String)
modulemd = db.Column(db.String, nullable=False)
koji_tag = db.Column(db.String) # This gets set after 'wait'
scmurl = db.Column(db.String)
owner = db.Column(db.String, nullable=False)
time_submitted = db.Column(db.DateTime, nullable=False)
time_modified = db.Column(db.DateTime)
time_completed = db.Column(db.DateTime)
# A monotonically increasing integer that represents which batch or
# iteration this module is currently on for successive rebuilds of its
# components. Think like 'mockchain --recurse'
batch = db.Column(db.Integer, default=0)
module = db.relationship('Module', backref='module_builds', lazy=False)
def current_batch(self, state=None):
""" Returns all components of this module in the current batch. """
if not self.batch:
raise ValueError("No batch is in progress: %r" % self.batch)
if state:
return [
component for component in self.component_builds
if component.batch == self.batch and component.state == state
]
else:
return [
component for component in self.component_builds
if component.batch == self.batch
]
def mmd(self):
mmd = _modulemd.ModuleMetadata()
try:
mmd.loads(self.modulemd)
except:
raise ValueError("Invalid modulemd")
return mmd
@validates('state')
def validate_state(self, key, field):
if field in BUILD_STATES.values():
return field
if field in BUILD_STATES:
return BUILD_STATES[field]
raise ValueError("%s: %s, not in %r" % (key, field, BUILD_STATES))
@classmethod
def from_module_event(cls, session, event):
if type(event) == module_build_service.messaging.RidaModule:
return session.query(cls).filter(
cls.id == event.module_build_id).first()
else:
raise ValueError("%r is not a module message."
% type(event).__name__)
@classmethod
def create(cls, session, conf, name, stream, version, modulemd, scmurl, username):
now = datetime.utcnow()
module = cls(
name=name,
stream=stream,
version=version,
state="init",
modulemd=modulemd,
scmurl=scmurl,
owner=username,
time_submitted=now
)
session.add(module)
session.commit()
module_build_service.messaging.publish(
service='module_build_service',
topic='module.state.change',
msg=module.json(), # Note the state is "init" here...
conf=conf,
)
return module
def transition(self, conf, state, state_reason=None):
""" Record that a build has transitioned state. """
now = datetime.utcnow()
old_state = self.state
self.state = state
self.time_modified = now
if INVERSE_BUILD_STATES[self.state] in ['done', 'failed']:
self.time_completed = now
if state_reason:
self.state_reason = state_reason
log.debug("%r, state %r->%r" % (self, old_state, self.state))
module_build_service.messaging.publish(
service='module_build_service',
topic='module.state.change',
msg=self.json(), # Note the state is "init" here...
conf=conf,
)
@classmethod
def by_state(cls, session, state):
return session.query(ModuleBuild).filter_by(state=BUILD_STATES[state]).all()
@classmethod
def from_repo_done_event(cls, session, event):
""" Find the ModuleBuilds in our database that should be in-flight...
... for a given koji tag.
There should be at most one.
"""
tag = event.repo_tag.strip('-build')
query = session.query(cls)\
.filter(cls.koji_tag==tag)\
.filter(cls.state==BUILD_STATES["build"])
count = query.count()
if count > 1:
raise RuntimeError("%r module builds in flight for %r" % (count, tag))
return query.first()
def json(self):
return {
'id': self.id,
'name': self.name,
'stream': self.stream,
'version': self.version,
'state': self.state,
'state_name': INVERSE_BUILD_STATES[self.state],
'state_reason': self.state_reason,
'state_url': get_url_for('module_build_query', id=self.id),
'scmurl': self.scmurl,
'owner': self.owner,
'time_submitted': self.time_submitted,
'time_modified': self.time_modified,
'time_completed': self.time_completed,
# TODO, show their entire .json() ?
'component_builds': [build.id for build in self.component_builds],
}
@staticmethod
def _utc_datetime_to_iso(datetime_object):
"""
Takes a UTC datetime object and returns an ISO formatted string
:param datetime_object: datetime.datetime
:return: string with datetime in ISO format
"""
if datetime_object:
# Converts the datetime to ISO 8601
return datetime_object.strftime("%Y-%m-%dT%H:%M:%SZ")
return None
def api_json(self):
return {
"id": self.id,
"state": self.state,
'state_reason': self.state_reason,
"owner": self.owner,
"name": self.name,
"time_submitted": self._utc_datetime_to_iso(self.time_submitted),
"time_modified": self._utc_datetime_to_iso(self.time_modified),
"time_completed": self._utc_datetime_to_iso(self.time_completed),
"tasks": self.tasks()
}
def tasks(self):
"""
:return: dictionary containing the tasks associated with the build
"""
tasks = dict()
if self.id and self.state != 'init':
for build in ComponentBuild.query.filter_by(module_id=self.id).options(lazyload('module_build')).all():
tasks["%s/%s" % (build.format, build.package)] = "%s/%s" % (build.task_id, build.state)
return tasks
def resolve_profiles(self, session, key, seen=None):
""" Gather dependency profiles named `key` of modules we depend on.
This is used to find the union of all 'buildroot' profiles of a
module's dependencies.
https://pagure.io/fm-orchestrator/issue/181
"""
seen = seen or [] # Initialize to an empty list.
result = set()
for name, stream in self.mmd().buildrequires.items():
# First, guard against infinite recursion
if name in seen:
continue
# Find the latest of the dep in our db of built modules.
dep = session.query(ModuleBuild)\
.filter(ModuleBuild.name==name)\
.filter(ModuleBuild.stream==stream)\
.filter(or_(
ModuleBuild.state==BUILD_STATES["done"],
ModuleBuild.state==BUILD_STATES["ready"],
)).order_by('version').first()
# XXX - We may want to make this fatal one day, but warn for now.
if not dep:
log.warn("Could not find built dep "
"%s/%s for %r" % (name, stream, self))
continue
# Take note of what rpms are in this dep's profile.
profiles = dep.mmd().profiles
if key in profiles:
result |= profiles[key].rpms
# And recurse to all modules that are deps of our dep.
result |= dep.resolve_profiles(session, key, seen + [name])
# Return the union of all rpms in all profiles of the given key.
return result
def __repr__(self):
return "<ModuleBuild %s, stream=%s, version=%s, state %r, batch %r>" % (
self.name, self.stream, self.version,
INVERSE_BUILD_STATES[self.state], self.batch)
class ComponentBuild(RidaBase):
__tablename__ = "component_builds"
id = db.Column(db.Integer, primary_key=True)
package = db.Column(db.String, nullable=False)
scmurl = db.Column(db.String, nullable=False)
# XXX: Consider making this a proper ENUM
format = db.Column(db.String, nullable=False)
task_id = db.Column(db.Integer) # This is the id of the build in koji
# XXX: Consider making this a proper ENUM (or an int)
state = db.Column(db.Integer)
# Reason why the build failed
state_reason = db.Column(db.String)
# This stays as None until the build completes.
nvr = db.Column(db.String)
# A monotonically increasing integer that represents which batch or
# iteration this *component* is currently in. This relates to the owning
# module's batch. This one defaults to None, which means that this
# component is not currently part of a batch.
batch = db.Column(db.Integer, default=0)
module_id = db.Column(db.Integer, db.ForeignKey('module_builds.id'), nullable=False)
module_build = db.relationship('ModuleBuild', backref='component_builds', lazy=False)
@classmethod
def from_component_event(cls, session, event):
if type(event) == module_build_service.messaging.KojiBuildChange:
return session.query(cls).filter(
cls.task_id == event.task_id).first()
else:
raise ValueError("%r is not a koji message." % event['topic'])
def json(self):
retval = {
'id': self.id,
'package': self.package,
'format': self.format,
'task_id': self.task_id,
'state': self.state,
'state_reason': self.state_reason,
'module_build': self.module_id,
}
try:
# Koji is py2 only, so this fails if the main web process is
# running on py3.
import koji
retval['state_name'] = koji.BUILD_STATES.get(self.state)
except ImportError:
pass
return retval
def __repr__(self):
return "<ComponentBuild %s, %r, state: %r, task_id: %r, batch: %r, state_reason: %s>" % (
self.package, self.module_id, self.state, self.task_id, self.batch, self.state_reason)