Exemple #1
0
class Landing(db.Model):
    """DEPRECATED

    This model has been replaced by landoapi.models.transplant.Transplant
    and has been kept around as part of data migration. In the future when
    migration has been completed this may been cleaned up and dropped from
    the database with another migration.
    """
    __tablename__ = "landings"

    id = db.Column(db.Integer, primary_key=True)
    request_id = db.Column(db.Integer, unique=True)
    revision_id = db.Column(db.Integer)
    diff_id = db.Column(db.Integer)
    active_diff_id = db.Column(db.Integer)
    status = db.Column(db.Enum(LandingStatus),
                       nullable=False,
                       default=LandingStatus.aborted)
    error = db.Column(db.Text(), default='')
    result = db.Column(db.Text(), default='')
    requester_email = db.Column(db.String(254))
    tree = db.Column(db.String(128))
    created_at = db.Column(db.DateTime(timezone=True),
                           nullable=False,
                           default=db.func.now())
    updated_at = db.Column(db.DateTime(timezone=True),
                           nullable=False,
                           default=db.func.now(),
                           onupdate=db.func.now())
Exemple #2
0
class LandingJob(Base):
    """State for a landing job."""

    # The postgres enum column which this definition results in
    # uses an enum type where the order matches the order of
    # values at definition time. Since python `enum.Enum` is
    # ordered, the resulting column will have the same order
    # as the definition order of the enum. This can be relied
    # on for comparisons or things like queries with ORDER BY.
    status = db.Column(db.Enum(LandingJobStatus),
                       nullable=False,
                       default=LandingJobStatus.SUBMITTED)

    # JSON object mapping string revision id of the form "<int>" (used because
    # json keys may not be integers) to integer diff id. This is used to
    # record the diff id used with each revision and make searching for
    # Transplants that match a set of revisions easy (such as those
    # in a stack).
    # e.g.
    #     {
    #         "1001": 1221,
    #         "1002": 1246,
    #         "1003": 1412
    #     }
    revision_to_diff_id = db.Column(JSONB, nullable=False)

    # JSON array of string revision ids of the form "<int>" (used to match
    # the string type of revision_to_diff_id keys) listing the order
    # of the revisions in the request from most ancestral to most
    # descendant.
    # e.g.
    #     ["1001", "1002", "1003"]
    revision_order = db.Column(JSONB, nullable=False)

    # Text describing errors when status != LANDED.
    error = db.Column(db.Text(), default="")

    # Error details in a dictionary format, listing failed merges, etc...
    # E.g. {
    #    "failed_paths": [{"path": "...", "url": "..."}],
    #     "reject_paths": [{"path": "...", "content": "..."}]
    # }
    error_breakdown = db.Column(JSONB, nullable=True)

    # LDAP email of the user who requested transplant.
    requester_email = db.Column(db.String(254), nullable=False)

    # Lando's name for the repository.
    repository_name = db.Column(db.Text(), nullable=False)

    # URL of the repository revisions are to land to.
    repository_url = db.Column(db.Text(), default="")

    # Identifier for the most descendent commit created by this landing.
    landed_commit_id = db.Column(db.Text(), default="")

    # Number of attempts made to complete the job.
    attempts = db.Column(db.Integer, nullable=False, default=0)

    # Priority of the job. Higher values are processed first.
    priority = db.Column(db.Integer, nullable=False, default=0)

    # Duration of job from start to finish
    duration_seconds = db.Column(db.Integer, default=0)

    @property
    def landing_path(self):
        return [(int(r), self.revision_to_diff_id[r])
                for r in self.revision_order]

    @property
    def head_revision(self):
        """Human-readable representation of the branch head's Phabricator revision ID.
        """
        assert (
            self.revision_order
        ), "head_revision should never be called without setting self.revision_order!"
        return "D" + self.revision_order[-1]

    @classmethod
    def revisions_query(cls, revisions):
        revisions = [str(int(r)) for r in revisions]
        return cls.query.filter(
            cls.revision_to_diff_id.has_any(array(revisions)))

    @classmethod
    def job_queue_query(cls,
                        repositories=None,
                        grace_seconds=DEFAULT_GRACE_SECONDS):
        """Return a query which selects the queued jobs.

        Args:
            repositories (iterable): A list of repository names to use when filtering
                the landing job search query.
            grace_seconds (int): Ignore landing jobs that were submitted after this
                many seconds ago.
        """
        applicable_statuses = (
            LandingJobStatus.SUBMITTED,
            LandingJobStatus.IN_PROGRESS,
            LandingJobStatus.DEFERRED,
        )
        q = cls.query.filter(cls.status.in_(applicable_statuses))

        if repositories:
            q = q.filter(cls.repository_name.in_(repositories))

        if grace_seconds:
            now = datetime.datetime.now(datetime.timezone.utc)
            grace_cutoff = now - datetime.timedelta(seconds=grace_seconds)
            q = q.filter(cls.created_at < grace_cutoff)

        # Any `LandingJobStatus.IN_PROGRESS` job is first and there should
        # be a maximum of one (per repository). For
        # `LandingJobStatus.SUBMITTED` jobs, higher priority items come first
        # and then we order by creation time (older first).
        q = q.order_by(cls.status.desc(), cls.priority.desc(), cls.created_at)

        return q

    @classmethod
    def next_job_for_update_query(cls, repositories=None):
        """Return a query which selects the next job and locks the row."""
        q = cls.job_queue_query(repositories=repositories)

        # Returned rows should be locked for updating, this ensures the next
        # job can be claimed.
        q = q.with_for_update()

        return q

    def transition_status(self, action, commit=False, db=None, **kwargs):
        """Change the status and other applicable fields according to actions.

        Args:
            action (str): the action to take, e.g. "land" or "fail"
            commit (bool): whether to commit the changes to the database or not
            db (SQLAlchemy.db): the database to commit to
            **kwargs:
                Additional arguments required by each action, e.g. `message` or
                `commit_id`.
        """
        actions = {
            LandingJobAction.LAND: {
                "required_params": ["commit_id"],
                "status": LandingJobStatus.LANDED,
            },
            LandingJobAction.FAIL: {
                "required_params": ["message"],
                "status": LandingJobStatus.FAILED,
            },
            LandingJobAction.DEFER: {
                "required_params": ["message"],
                "status": LandingJobStatus.DEFERRED,
            },
            LandingJobAction.CANCEL: {
                "required_params": [],
                "status": LandingJobStatus.CANCELLED,
            },
        }

        if action not in actions:
            raise ValueError(f"{action} is not a valid action")

        required_params = actions[action]["required_params"]
        if sorted(required_params) != sorted(kwargs.keys()):
            missing_params = required_params - kwargs.keys()
            raise ValueError(f"Missing {missing_params} params")

        if commit and db is None:
            raise ValueError("db is required when commit is set to True")

        self.status = actions[action]["status"]

        if action in (LandingJobAction.FAIL, LandingJobAction.DEFER):
            self.error = kwargs["message"]

        if action == LandingJobAction.LAND:
            self.landed_commit_id = kwargs["commit_id"]

        if commit:
            db.session.commit()

    def serialize(self):
        """Return a JSON compatible dictionary."""
        return {
            "id":
            self.id,
            "status":
            self.status.value,
            "landing_path": [{
                "revision_id": "D{}".format(r),
                "diff_id": self.revision_to_diff_id[r]
            } for r in self.revision_order],
            "error_breakdown":
            self.error_breakdown,
            "details": (self.error or self.landed_commit_id
                        if self.status in (LandingJobStatus.FAILED,
                                           LandingJobStatus.CANCELLED) else
                        self.landed_commit_id or self.error),
            "requester_email":
            self.requester_email,
            "tree":
            self.repository_name,
            "repository_url":
            self.repository_url,
            "created_at":
            (self.created_at.astimezone(datetime.timezone.utc).isoformat()),
            "updated_at":
            (self.updated_at.astimezone(datetime.timezone.utc).isoformat()),
        }
Exemple #3
0
class Transplant(Base):
    """Represents a request to Autoland Transplant."""

    __tablename__ = "transplants"

    # Autoland Transplant request ID.
    request_id = db.Column(db.Integer, unique=True)

    status = db.Column(db.Enum(TransplantStatus),
                       nullable=False,
                       default=TransplantStatus.aborted)

    # JSON object mapping string revision id of the form "<int>" (used because
    # json keys may not be integers) to integer diff id. This is used to
    # record the diff id used with each revision and make searching for
    # Transplants that match a set of revisions easy (such as those
    # in a stack).
    # e.g.
    #     {
    #         "1001": 1221,
    #         "1002": 1246,
    #         "1003": 1412
    #     }
    revision_to_diff_id = db.Column(JSONB, nullable=False)

    # JSON array of string revision ids of the form "<int>" (used to match
    # the string type of revision_to_diff_id keys) listing the order
    # of the revisions in the request from most ancestral to most
    # descendant.
    # e.g.
    #     ["1001", "1002", "1003"]
    revision_order = db.Column(JSONB, nullable=False)

    # Text describing errors when not landed.
    error = db.Column(db.Text(), default="")

    # Revision (sha) of the head of the push.
    result = db.Column(db.Text(), default="")

    # LDAP email of the user who requested transplant.
    requester_email = db.Column(db.String(254))

    # URL of the repository revisions are to land to.
    repository_url = db.Column(db.Text(), default="")

    # Treestatus tree name the revisions are to land to.
    tree = db.Column(db.String(128))

    def update_from_transplant(self, landed, error="", result=""):
        """Set the status from pingback request."""
        self.error = error
        self.result = result
        if not landed:
            self.status = (TransplantStatus.failed
                           if error else TransplantStatus.submitted)
        else:
            self.status = TransplantStatus.landed

    @property
    def landing_path(self):
        return [(int(r), self.revision_to_diff_id[r])
                for r in self.revision_order]

    @property
    def head_revision(self):
        """Human-readable representation of the branch head's Phabricator revision ID.
        """
        assert (
            self.revision_order
        ), "head_revision should never be called without setting self.revision_order!"
        return "D" + self.revision_order[-1]

    @classmethod
    def revisions_query(cls, revisions):
        revisions = [str(int(r)) for r in revisions]
        return cls.query.filter(
            cls.revision_to_diff_id.has_any(array(revisions)))

    def serialize(self):
        """Return a JSON compatible dictionary."""
        return {
            "id":
            self.id,
            "request_id":
            self.request_id,
            "status":
            self.status.value,
            "landing_path": [{
                "revision_id": "D{}".format(r),
                "diff_id": self.revision_to_diff_id[r]
            } for r in self.revision_order],
            "details":
            (self.error or self.result if self.status
             in (TransplantStatus.failed,
                 TransplantStatus.aborted) else self.result or self.error),
            "requester_email":
            self.requester_email,
            "tree":
            self.tree,
            "repository_url":
            self.repository_url,
            "created_at":
            (self.created_at.astimezone(datetime.timezone.utc).isoformat()),
            "updated_at":
            (self.updated_at.astimezone(datetime.timezone.utc).isoformat()),
        }
Exemple #4
0
class Landing(db.Model):
    """Represents the landing process in Autoland.

    Landing is communicating with Autoland via TransplantClient.
    Landing is communicating with Phabricator via PhabricatorClient.
    Landing object might be saved to database without creation of the actual
    landing in Autoland. It is done before landing request to construct
    required "pingback URL" and save related Patch objects.
    To update the Landing status Transplant is calling provided pingback URL.

    Attributes:
        id: Primary Key
        request_id: Id of the request in Autoland
        revision_id: Phabricator id of the revision to be landed
        diff_id: Phabricator id of the diff to be landed
        status: Status of the landing. Modified by `update` API
        error: Text describing the error if not landed
        result: Revision (sha) of push
    """
    __tablename__ = "landings"

    id = db.Column(db.Integer, primary_key=True)
    request_id = db.Column(db.Integer, unique=True)
    revision_id = db.Column(db.String(30))
    diff_id = db.Column(db.Integer)
    status = db.Column(db.Integer)
    error = db.Column(db.String(128), default='')
    result = db.Column(db.String(128))

    def __init__(self,
                 request_id=None,
                 revision_id=None,
                 diff_id=None,
                 status=TRANSPLANT_JOB_PENDING):
        self.request_id = request_id
        self.revision_id = revision_id
        self.diff_id = diff_id
        self.status = status

    @classmethod
    def create(cls, revision_id, diff_id, phabricator_api_key=None):
        """Land revision.

        A typical successful story:
            * Revision and Diff are loaded from Phabricator.
            * Patch is created and uploaded to S3 bucket.
            * Landing object is created (without request_id)
            * A request to land the patch is send to Transplant client.
            * Created landing object is updated with returned `request_id`,
              it is then saved and returned.

        Args:
            revision_id: The id of the revision to be landed
            diff_id: The id of the diff to be landed
            phabricator_api_key: API Key to identify in Phabricator

        Returns:
            A new Landing object

        Raises:
            RevisionNotFoundException: PhabricatorClient returned no revision
                for given revision_id
            LandingNotCreatedException: landing request in Transplant failed
        """
        phab = PhabricatorClient(phabricator_api_key)
        revision = phab.get_revision(id=revision_id)

        if not revision:
            raise RevisionNotFoundException(revision_id)

        repo = phab.get_revision_repo(revision)

        # Save landing to make sure we've got the callback URL.
        landing = cls(revision_id=revision_id, diff_id=diff_id)
        landing.save()

        patch = Patch(landing.id, revision, diff_id)
        patch.upload(phab)

        # Define the pingback URL with the port.
        callback = '{host_url}/landings/{id}/update'.format(
            host_url=current_app.config['PINGBACK_HOST_URL'], id=landing.id)

        trans = TransplantClient()
        # The LDAP username used here has to be the username of the patch
        # pusher (the person who pushed the 'Land it!' button).
        # FIXME: change [email protected] to the real data retrieved
        #        from Auth0 userinfo
        request_id = trans.land('*****@*****.**', patch.s3_url,
                                repo['uri'], callback)
        if not request_id:
            raise LandingNotCreatedException

        landing.request_id = request_id
        landing.status = TRANSPLANT_JOB_STARTED
        landing.save()

        logger.info(
            {
                'revision_id': revision_id,
                'landing_id': landing.id,
                'msg': 'landing created for revision'
            }, 'landing.success')

        return landing

    def save(self):
        """Save objects in storage."""
        if not self.id:
            db.session.add(self)

        return db.session.commit()

    def __repr__(self):
        return '<Landing: %s>' % self.id

    def serialize(self):
        """Serialize to JSON compatible dictionary."""
        return {
            'id': self.id,
            'revision_id': self.revision_id,
            'request_id': self.request_id,
            'diff_id': self.diff_id,
            'status': self.status,
            'error_msg': self.error,
            'result': self.result
        }
Exemple #5
0
class Transplant(db.Model):
    """Represents a request to Autoland Transplant."""
    __tablename__ = "transplants"

    # Internal request ID.
    id = db.Column(db.Integer, primary_key=True)

    # Autoland Transplant request ID.
    request_id = db.Column(db.Integer, unique=True)

    status = db.Column(db.Enum(TransplantStatus),
                       nullable=False,
                       default=TransplantStatus.aborted)
    created_at = db.Column(db.DateTime(timezone=True),
                           nullable=False,
                           default=db.func.now())
    updated_at = db.Column(db.DateTime(timezone=True),
                           nullable=False,
                           default=db.func.now(),
                           onupdate=db.func.now())

    # JSON object mapping string revision id of the form "<int>" (used because
    # json keys may not be integers) to integer diff id. This is used to
    # record the diff id used with each revision and make searching for
    # Transplants that match a set of revisions easy (such as those
    # in a stack).
    # e.g.
    #     {
    #         "1001": 1221,
    #         "1002": 1246,
    #         "1003": 1412
    #     }
    revision_to_diff_id = db.Column(JSONB, nullable=False)

    # JSON array of string revision ids of the form "<int>" (used to match
    # the string type of revision_to_diff_id keys) listing the order
    # of the revisions in the request from most ancestral to most
    # descendant.
    # e.g.
    #     ["1001", "1002", "1003"]
    revision_order = db.Column(JSONB, nullable=False)

    # Text describing errors when not landed.
    error = db.Column(db.Text(), default='')

    # Revision (sha) of the head of the push.
    result = db.Column(db.Text(), default='')

    # LDAP email of the user who requested transplant.
    requester_email = db.Column(db.String(254))

    # URL of the repository revisions are to land to.
    repository_url = db.Column(db.Text(), default='')

    # Treestatus tree name the revisions are to land to.
    tree = db.Column(db.String(128))

    def __repr__(self):
        return '<Transplant: %s>' % self.id

    def update_from_transplant(self, landed, error='', result=''):
        """Set the status from pingback request."""
        self.error = error
        self.result = result
        if not landed:
            self.status = (TransplantStatus.failed
                           if error else TransplantStatus.submitted)
        else:
            self.status = TransplantStatus.landed

    @property
    def landing_path(self):
        return [(int(r), self.revision_to_diff_id[r])
                for r in self.revision_order]

    @classmethod
    def revisions_query(cls, revisions):
        revisions = [str(int(r)) for r in revisions]
        return cls.query.filter(
            cls.revision_to_diff_id.has_any(array(revisions)))

    @classmethod
    def is_revision_submitted(cls, revision_id):
        """Check if revision is successfully submitted.

        Args:
            revision_id: The integer id of the revision.

        Returns:
            Transplant object or False if not submitted.
        """
        transplants = cls.revisions_query(
            [revision_id]).filter_by(status=TransplantStatus.submitted).all()

        if not transplants:
            return False

        return transplants[0]

    @classmethod
    def legacy_latest_landed(cls, revision_id):
        """DEPRECATED Return the latest Landing that is landed, or None.

        Args:
            revision_id: The integer id of the revision.

        Returns:
            Latest transplant object with status landed, or None if
            none exist.
        """
        return cls.revisions_query(
            [revision_id]).filter_by(status=TransplantStatus.landed).order_by(
                cls.updated_at.desc()).first()

    def serialize(self):
        """Return a JSON compatible dictionary."""
        return {
            'id': self.id,
            'request_id': self.request_id,
            'status': self.status.value,
            'landing_path': [
                {
                    'revision_id': 'D{}'.format(r),
                    'diff_id': self.revision_to_diff_id[r],
                } for r in self.revision_order
            ],
            'details': (
                self.error or self.result
                if self.status in (
                    TransplantStatus.failed,
                    TransplantStatus.aborted
                )
                else self.result or self.error
            ),
            'requester_email': self.requester_email,
            'tree': self.tree,
            'repository_url': self.repository_url,
            'created_at': (
                self.created_at.astimezone(datetime.timezone.utc).isoformat()
            ),
            'updated_at': (
                self.updated_at.astimezone(datetime.timezone.utc).isoformat()
            ),
        }  # yapf: disable

    def legacy_serialize(self):
        """DEPRECATED Serialize to JSON compatible dictionary."""

        revision_id = None
        diff_id = None
        if self.revision_order is not None:
            revision_id = self.revision_order[-1]

        if (revision_id is not None and self.revision_to_diff_id is not None):
            diff_id = self.revision_to_diff_id.get(revision_id)

        return {
            'id': self.id,
            'revision_id': 'D{}'.format(revision_id),
            'request_id': self.request_id,
            'diff_id': diff_id,
            'active_diff_id': diff_id,
            'status': self.status.value,
            'error_msg': self.error,
            'result': self.result,
            'requester_email': self.requester_email,
            'tree': self.tree,
            'tree_url': self.repository_url or '',
            'created_at': (
                self.created_at.astimezone(datetime.timezone.utc).isoformat()
            ),
            'updated_at': (
                self.updated_at.astimezone(datetime.timezone.utc).isoformat()
            ),
        }  # yapf: disable
Exemple #6
0
class Landing(db.Model):
    """Represents the landing process in Autoland.

    Landing is communicating with Autoland via TransplantClient.
    Landing is communicating with Phabricator via PhabricatorClient.
    Landing object might be saved to database without creation of the actual
    landing in Autoland. It is done before landing request to construct
    required "pingback URL" and save related Patch objects.
    To update the Landing status Transplant is calling provided pingback URL.
    Active Diff Id is stored on creation if it is different than diff_id.

    Attributes:
        id: Primary Key
        request_id: Id of the request in Autoland
        revision_id: Phabricator id of the revision to be landed
        diff_id: Phabricator id of the diff to be landed
        active_diff_id: Phabricator id of the diff active at the moment of
            landing
        status: Status of the landing. Modified by `update` API
        error: Text describing the error if not landed
        result: Revision (sha) of push
        requester_email: The email address of the requester of the landing.
        tree: The treestatus tree name the revision is to land to.
        created_at: DateTime of the creation
        updated_at: DateTime of the last save
    """
    __tablename__ = "landings"

    id = db.Column(db.Integer, primary_key=True)
    request_id = db.Column(db.Integer, unique=True)
    revision_id = db.Column(db.Integer)
    diff_id = db.Column(db.Integer)
    active_diff_id = db.Column(db.Integer)
    status = db.Column(db.Enum(LandingStatus),
                       nullable=False,
                       default=LandingStatus.aborted)
    error = db.Column(db.Text(), default='')
    result = db.Column(db.Text(), default='')
    requester_email = db.Column(db.String(254))
    tree = db.Column(db.String(128))
    created_at = db.Column(db.DateTime(timezone=True),
                           nullable=False,
                           default=db.func.now())
    updated_at = db.Column(db.DateTime(timezone=True),
                           nullable=False,
                           default=db.func.now(),
                           onupdate=db.func.now())

    @classmethod
    def is_revision_submitted(cls, revision_id):
        """Check if revision is successfully submitted.

        Args:
            revision_id: The integer id of the revision.

        Returns:
            Landed Revision object or False if not submitted.
        """
        landings = cls.query.filter(
            cls.revision_id == revision_id,
            cls.status == LandingStatus.submitted).all()
        if not landings:
            return False

        return landings[0]

    @classmethod
    def latest_landed(cls, revision_id):
        """Return the latest Landing that is landed, or None.

        Args:
            revision_id: The integer id of the revision.

        Returns:
            Latest landing object with status landed, or None if
            none exist.
        """
        return cls.query.filter_by(revision_id=revision_id,
                                   status=LandingStatus.landed).order_by(
                                       cls.updated_at.desc()).first()

    def __repr__(self):
        return '<Landing: %s>' % self.id

    def serialize(self):
        """Serialize to JSON compatible dictionary."""
        return {
            'id': self.id,
            'revision_id': 'D{}'.format(self.revision_id),
            'request_id': self.request_id,
            'diff_id': self.diff_id,
            'active_diff_id': self.active_diff_id,
            'status': self.status.value,
            'error_msg': self.error,
            'result': self.result,
            'requester_email': self.requester_email,
            'tree': self.tree,
            'created_at': (
                self.created_at.astimezone(datetime.timezone.utc).isoformat()
            ),
            'updated_at': (
                self.updated_at.astimezone(datetime.timezone.utc).isoformat()
            ),
        }  # yapf: disable

    def update_from_transplant(self, landed, error='', result=''):
        """Set the status from pingback request."""
        self.error = error
        self.result = result
        if not landed:
            self.status = (LandingStatus.failed
                           if error else LandingStatus.submitted)
        else:
            self.status = LandingStatus.landed