Source code for changes.models.snapshot

"""LXC snapshot related models.

These are used by Mesos builds and Jenkins-LXC builds.

See also:

* changes-client actually creates, manages and uploads actual snapshot
  image files (which are just file system tarballs). Here we just keep track
  of snapshot IDs and bind them to projects/plans.
"""

from __future__ import absolute_import

from datetime import datetime
from uuid import uuid4
from enum import Enum

from sqlalchemy import Column, DateTime, ForeignKey
from sqlalchemy.orm import backref, relationship
from sqlalchemy.schema import UniqueConstraint

from changes.config import db
from changes.db.types.enum import Enum as EnumType
from changes.db.types.guid import GUID


class SnapshotStatus(Enum):
    """Used to track whether a snapshot or an image is ready to be used.

    State transitions that should be allowed:

      unknown -> anything
      pending -> failed, active
      active -> invalidated

    'failed' and 'invalidated' are terminal states.
    """
    # Unknown status (default, but should not normally be used as a status)
    unknown = 0
    # Snapshot/image ready to be used. Can only become active from
    # pending, and can only becoming invalidated from active.
    active = 1
    # Snapshot build failed. This is a terminal state that is reached
    # if the snapshot was never marked active.
    failed = 2
    # This implies that the snapshot (or image) was once marked as active
    # but was later marked as bad for some reason. This is the only state
    # that an active snapshot/image should ever be switched to.
    #
    # This is a terminal state that is reached for snapshots that were
    # at one point activate
    invalidated = 3
    # Waiting for snapshot build to finish
    pending = 4

    def __str__(self):
        return STATUS_LABELS[self]


STATUS_LABELS = {
    SnapshotStatus.unknown: 'Unknown',
    SnapshotStatus.pending: 'Pending',
    SnapshotStatus.active: 'Active',
    SnapshotStatus.failed: 'Failed',
    SnapshotStatus.invalidated: 'Invalidated',
}


[docs]class Snapshot(db.Model): """ A snapshot is a set of LXC container images (up to one for each plan in a project). Each project can have an arbitrary number of snapshots, but only up to one "current snapshot" is actually used by builds (stored as ProjectOption) at any time. Snapshots are used in the Mesos and Jenkins-LXC environments. Snapshots are currently only used with changes-client. When running a build, the images of the current snapshot are used for individual jobs that are part of a build. A snapshot image can be shared between multiple plans by setting snapshot_plan_id of a Plan. By default, there is a separate image for each plan of a build. The status of a snapshot indicates whether it *can* be used for builds; it doesn't indicate whether the snapshot is actually used for builds right now. A snapshot is active if and only if all the corresponding snapshot images are active. A snapshot is generated by a slightly special snapshot build that uploads a snapshot at the end of the build. """ __tablename__ = 'snapshot' id = Column(GUID, primary_key=True, default=uuid4) project_id = Column( GUID, ForeignKey('project.id', ondelete="CASCADE"), nullable=False) build_id = Column(GUID, ForeignKey('build.id'), unique=True) source_id = Column(GUID, ForeignKey('source.id')) # Most importantly, this tells if this snapshot can be used for builds (i.e., are all # component snapshot images active)? status = Column(EnumType(SnapshotStatus), default=SnapshotStatus.unknown, nullable=False, server_default='0') date_created = Column(DateTime, default=datetime.utcnow, nullable=False) # The build that generated this snapshot. build = relationship('Build', backref=backref('snapshot', uselist=False)) project = relationship('Project', innerjoin=True) # The source that was used to generate the snapshot. source = relationship('Source') def __init__(self, **kwargs): super(Snapshot, self).__init__(**kwargs) if self.id is None: self.id = uuid4() @classmethod def get_current(cls, project_id): """Return the current Snapshot for a project (or None if one is not set).""" from changes.models.project import ProjectOption current_id = db.session.query(ProjectOption.value).filter( ProjectOption.project_id == project_id, ProjectOption.name == 'snapshot.current', ).scalar() if not current_id: return return Snapshot.query.get(current_id)
class SnapshotImage(db.Model): """ Represents an individual image within a snapshot. An image is bound to a (snapshot, plan) and represents the low level base image that a snapshottable-job should be based on. Note that a project with multiple plans may have multiple, independent images per snapshot, as images aren't always shared between plans. """ __tablename__ = 'snapshot_image' __table_args__ = ( UniqueConstraint('snapshot_id', 'plan_id', name='unq_snapshotimage_plan'), ) # The snapshot image id is used by changes-client to store and retrieve snapshots. # New ids are created by changes and passed on to changes-client. id = Column(GUID, primary_key=True, default=uuid4) snapshot_id = Column( GUID, ForeignKey('snapshot.id', ondelete="CASCADE"), nullable=False) plan_id = Column( GUID, ForeignKey('plan.id', ondelete="CASCADE"), nullable=False) job_id = Column(GUID, ForeignKey('job.id', ondelete="CASCADE"), unique=True) status = Column(EnumType(SnapshotStatus), default=SnapshotStatus.unknown, nullable=False, server_default='0') date_created = Column(DateTime, default=datetime.utcnow, nullable=False) snapshot = relationship('Snapshot', backref=backref('images', order_by='SnapshotImage.date_created')) plan = relationship('Plan') # The job that was used to create this snapshot. job = relationship('Job') def __init__(self, **kwargs): super(SnapshotImage, self).__init__(**kwargs) if self.id is None: self.id = uuid4() def change_status(self, status): """ The status field of snapshot is a redundant field that has to be maintained. Its essentially a cached aggregate over snapshot_image.status. This means that whenever we update snapshot_image.status we have to update snapshot.status. This method updates the current status to the new status given as a parameter. TODO we should probably verify that if the current status is active and we are tring to move it to a new status that is not "invalidated" we should give some error. XXX(jhance) Is this a sign of a defective schema? Computing snapshot.status should be possible in-query although I'm not sure to do it from within sqlalchemy. """ self.status = status db.session.add(self) # We need to update the current database with the status of the # new image, but we don't commit completely until we have found # the status of the overall status and update it atomically db.session.flush() inactive_image_query = SnapshotImage.query.filter( SnapshotImage.status != SnapshotStatus.active, SnapshotImage.snapshot_id == self.snapshot.id, ).exists() if not db.session.query(inactive_image_query).scalar(): # If the snapshot status isn't pending for whatever reason, then we # refuse to update its status to active because clearly some other # error occurred elsewhere in the pipeline (for example, the # snapshot build itself failing) if self.snapshot.status == SnapshotStatus.pending: self.snapshot.status = SnapshotStatus.active elif self.snapshot.status == SnapshotStatus.active: self.snapshot.status = SnapshotStatus.invalidated db.session.commit() @classmethod def get(cls, plan, snapshot_id): """Return the SnapshotImage for a plan or None if one is not set. """ # This plan might be configured to be dependent on another plan's snapshot snapshot_plan = plan if plan.snapshot_plan is not None: snapshot_plan = plan.snapshot_plan snapshot = Snapshot.query.filter(Snapshot.id == snapshot_id).scalar() if snapshot is not None: return SnapshotImage.query.filter( SnapshotImage.snapshot_id == snapshot.id, SnapshotImage.plan_id == snapshot_plan.id, ).scalar() return None