mirror of
https://pagure.io/fm-orchestrator.git
synced 2026-04-05 11:48:33 +08:00
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.
396 lines
14 KiB
Python
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)
|