Add pylorax.api.recipes code for handling the Recipe's Git repository
This commit is contained in:
parent
d71290153e
commit
bfc9c6913f
656
src/pylorax/api/recipes.py
Normal file
656
src/pylorax/api/recipes.py
Normal file
@ -0,0 +1,656 @@
|
|||||||
|
#
|
||||||
|
# Copyright (C) 2017 Red Hat, Inc.
|
||||||
|
#
|
||||||
|
# This program 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 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version("Ggit", "1.0")
|
||||||
|
from gi.repository import Ggit as Git
|
||||||
|
from gi.repository import Gio
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytoml as toml
|
||||||
|
import semantic_version as semver
|
||||||
|
|
||||||
|
from pylorax.base import DataHolder
|
||||||
|
from pylorax.sysutils import joinpaths
|
||||||
|
|
||||||
|
|
||||||
|
class CommitTimeValError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class RecipeFileError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class RecipeTOMLError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe(dict):
|
||||||
|
"""A Recipe of package and modules
|
||||||
|
|
||||||
|
This is a subclass of dict that enforces the constructor arguments
|
||||||
|
and adds a .filename property to return the recipe's filename,
|
||||||
|
and a .toml() function to return the recipe as a TOML string.
|
||||||
|
"""
|
||||||
|
def __init__(self, name, description, version, modules, packages):
|
||||||
|
# Check that version is empty or semver compatible
|
||||||
|
if version:
|
||||||
|
semver.Version(version)
|
||||||
|
|
||||||
|
# Make sure modules and packages are listed by their case-insensitive names
|
||||||
|
if modules is not None:
|
||||||
|
modules = sorted(modules, key=lambda m: m["name"].lower())
|
||||||
|
if packages is not None:
|
||||||
|
packages = sorted(packages, key=lambda p: p["name"].lower())
|
||||||
|
dict.__init__(self, name=name,
|
||||||
|
description=description,
|
||||||
|
version=version,
|
||||||
|
modules=modules,
|
||||||
|
packages=packages)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filename(self):
|
||||||
|
"""Return the Recipe's filename
|
||||||
|
|
||||||
|
Replaces spaces in the name with '-' and appends .toml
|
||||||
|
"""
|
||||||
|
return recipe_filename(self.get("name"))
|
||||||
|
|
||||||
|
def toml(self):
|
||||||
|
"""Return the Recipe in TOML format"""
|
||||||
|
return toml.dumps(self).encode("UTF-8")
|
||||||
|
|
||||||
|
def bump_version(self, new_version=None):
|
||||||
|
"""semver recipe version number bump
|
||||||
|
|
||||||
|
:param new_version: An optional new version number
|
||||||
|
:type new_version: str
|
||||||
|
:returns: The new version number or None
|
||||||
|
:rtype: str
|
||||||
|
:raises: ValueError
|
||||||
|
|
||||||
|
If neither have a version, 0.0.1 is returned
|
||||||
|
If there is no previous version the new version is checked and returned
|
||||||
|
If there is no new version, but there is a previous one, bump its patch level
|
||||||
|
If the previous and new versions are the same, bump the patch level
|
||||||
|
If they are different, check and return the new version
|
||||||
|
"""
|
||||||
|
old_version = self.get("version")
|
||||||
|
if not new_version and not old_version:
|
||||||
|
self["version"] = "0.0.1"
|
||||||
|
|
||||||
|
elif new_version and not old_version:
|
||||||
|
semver.Version(new_version)
|
||||||
|
self["version"] = new_version
|
||||||
|
|
||||||
|
elif not new_version or new_version == old_version:
|
||||||
|
new_version = str(semver.Version(old_version).next_patch())
|
||||||
|
self["version"] = new_version
|
||||||
|
|
||||||
|
else:
|
||||||
|
semver.Version(new_version)
|
||||||
|
self["version"] = new_version
|
||||||
|
|
||||||
|
# Return the new version
|
||||||
|
return str(semver.Version(self["version"]))
|
||||||
|
|
||||||
|
class RecipeModule(dict):
|
||||||
|
def __init__(self, name, version):
|
||||||
|
dict.__init__(self, name=name, version=version)
|
||||||
|
|
||||||
|
class RecipePackage(RecipeModule):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def recipe_from_toml(recipe_str):
|
||||||
|
"""Create a Recipe object from a toml string.
|
||||||
|
|
||||||
|
:param recipe_str: The Recipe TOML string
|
||||||
|
:type recipe_str: str
|
||||||
|
:returns: A Recipe object
|
||||||
|
:rtype: Recipe
|
||||||
|
:raises: TomlError
|
||||||
|
"""
|
||||||
|
recipe_toml = toml.loads(recipe_str)
|
||||||
|
|
||||||
|
# Make RecipeModule objects from the toml
|
||||||
|
# The TOML may not have modules or packages in it. Set them to None in this case
|
||||||
|
try:
|
||||||
|
if recipe_toml.get("modules"):
|
||||||
|
modules = [RecipeModule(m.get("name"), m.get("version")) for m in recipe_toml["modules"]]
|
||||||
|
else:
|
||||||
|
modules = None
|
||||||
|
if recipe_toml.get("packages"):
|
||||||
|
packages = [RecipePackage(p.get("name"), p.get("version")) for p in recipe_toml["packages"]]
|
||||||
|
else:
|
||||||
|
packages = None
|
||||||
|
name = recipe_toml["name"]
|
||||||
|
description = recipe_toml["description"]
|
||||||
|
version = recipe_toml.get("version", None)
|
||||||
|
except KeyError:
|
||||||
|
raise RecipeTOMLError
|
||||||
|
|
||||||
|
return Recipe(name, description, version, modules, packages)
|
||||||
|
|
||||||
|
def gfile(path):
|
||||||
|
"""Convert a string path to GFile for use with Git"""
|
||||||
|
return Gio.file_new_for_path(path)
|
||||||
|
|
||||||
|
def recipe_filename(name):
|
||||||
|
"""Return the toml filename for a recipe
|
||||||
|
|
||||||
|
Replaces spaces with '-' and appends '.toml'
|
||||||
|
"""
|
||||||
|
# XXX Raise and error if this is empty?
|
||||||
|
return name.replace(" ", "-") + ".toml"
|
||||||
|
|
||||||
|
def head_commit(repo, branch):
|
||||||
|
"""Get the branch's HEAD Commit Object
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:returns: Branch's head commit
|
||||||
|
:rtype: Git.Commit
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
branch_obj = repo.lookup_branch(branch, Git.BranchType.LOCAL)
|
||||||
|
commit_id = branch_obj.get_target()
|
||||||
|
return repo.lookup(commit_id, Git.Commit)
|
||||||
|
|
||||||
|
def prepare_commit(repo, branch, builder):
|
||||||
|
"""Prepare for a commit
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param builder: instance of TreeBuilder
|
||||||
|
:type builder: TreeBuilder
|
||||||
|
:returns: (Tree, Sig, Ref)
|
||||||
|
:rtype: tuple
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
tree_id = builder.write()
|
||||||
|
tree = repo.lookup(tree_id, Git.Tree)
|
||||||
|
sig = Git.Signature.new_now("bdcs-api-server", "user-email")
|
||||||
|
ref = "refs/heads/%s" % branch
|
||||||
|
return (tree, sig, ref)
|
||||||
|
|
||||||
|
def open_or_create_repo(path):
|
||||||
|
"""Open an existing repo, or create a new one
|
||||||
|
|
||||||
|
:param path: path to recipe directory
|
||||||
|
:type path: string
|
||||||
|
:returns: A repository object
|
||||||
|
:rtype: Git.Repository
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
|
||||||
|
A bare git repo will be created in at the specified path.
|
||||||
|
If a repo already exists it will be opened and returned instead of
|
||||||
|
creating a new one.
|
||||||
|
"""
|
||||||
|
Git.init()
|
||||||
|
if os.path.exists(joinpaths(path, "HEAD")):
|
||||||
|
return Git.Repository.open(gfile(path))
|
||||||
|
|
||||||
|
repo = Git.Repository.init_repository(gfile(path), True)
|
||||||
|
|
||||||
|
# Make an initial empty commit
|
||||||
|
sig = Git.Signature.new_now("bdcs-api-server", "user-email")
|
||||||
|
tree_id = repo.get_index().write_tree()
|
||||||
|
tree = repo.lookup(tree_id, Git.Tree)
|
||||||
|
repo.create_commit("HEAD", sig, sig, "UTF-8", "Initial Recipe repository commit", tree, [])
|
||||||
|
return repo
|
||||||
|
|
||||||
|
def write_commit(repo, branch, filename, message, content):
|
||||||
|
"""Make a new commit to a repository's branch
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: full path of the file to add
|
||||||
|
:type filename: str
|
||||||
|
:param message: The commit message
|
||||||
|
:type message: str
|
||||||
|
:param content: The data to write
|
||||||
|
:type content: str
|
||||||
|
:returns: OId of the new commit
|
||||||
|
:rtype: Git.OId
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
parent_commit = head_commit(repo, branch)
|
||||||
|
blob_id = repo.create_blob_from_buffer(content)
|
||||||
|
|
||||||
|
# Use treebuilder to make a new entry for this filename and blob
|
||||||
|
parent_tree = parent_commit.get_tree()
|
||||||
|
builder = repo.create_tree_builder_from_tree(parent_tree)
|
||||||
|
builder.insert(filename, blob_id, Git.FileMode.BLOB)
|
||||||
|
(tree, sig, ref) = prepare_commit(repo, branch, builder)
|
||||||
|
return repo.create_commit(ref, sig, sig, "UTF-8", message, tree, [parent_commit])
|
||||||
|
|
||||||
|
def read_commit_spec(repo, spec):
|
||||||
|
"""Return the raw content of the blob specified by the spec
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param spec: Git revparse spec
|
||||||
|
:type spec: str
|
||||||
|
:returns: Contents of the commit
|
||||||
|
:rtype: str
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
|
||||||
|
eg. To read the README file from master the spec is "master:README"
|
||||||
|
"""
|
||||||
|
commit_id = repo.revparse(spec).get_id()
|
||||||
|
blob = repo.lookup(commit_id, Git.Blob)
|
||||||
|
return blob.get_raw_content()
|
||||||
|
|
||||||
|
def read_commit(repo, branch, filename, commit=None):
|
||||||
|
"""Return the contents of a file on a specific branch or commit.
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: filename to read
|
||||||
|
:type filename: str
|
||||||
|
:param commit: Optional commit hash
|
||||||
|
:type commit: str
|
||||||
|
:returns: Contents of the commit
|
||||||
|
:rtype: str
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
|
||||||
|
If no commit is passed the master:filename is returned, otherwise it will be
|
||||||
|
commit:filename
|
||||||
|
"""
|
||||||
|
return read_commit_spec(repo, "%s:%s" % (commit or branch, filename))
|
||||||
|
|
||||||
|
def read_recipe_commit(repo, branch, filename, commit=None):
|
||||||
|
"""Read a recipe commit from git and return a Recipe object
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: filename to read
|
||||||
|
:type filename: str
|
||||||
|
:param commit: Optional commit hash
|
||||||
|
:type commit: str
|
||||||
|
:returns: A Recipe object
|
||||||
|
:rtype: Recipe
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
|
||||||
|
If no commit is passed the master:filename is returned, otherwise it will be
|
||||||
|
commit:filename
|
||||||
|
"""
|
||||||
|
recipe_toml = read_commit(repo, branch, filename, commit)
|
||||||
|
return recipe_from_toml(recipe_toml)
|
||||||
|
|
||||||
|
def list_branch_files(repo, branch):
|
||||||
|
"""Return a sorted list of the files on the branch HEAD
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:returns: A sorted list of the filenames
|
||||||
|
:rtype: list(str)
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
commit = head_commit(repo, branch).get_id().to_string()
|
||||||
|
return list_commit_files(repo, commit)
|
||||||
|
|
||||||
|
def list_commit_files(repo, commit):
|
||||||
|
"""Return a sorted list of the files on a commit
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param commit: The commit hash to list
|
||||||
|
:type commit: str
|
||||||
|
:returns: A sorted list of the filenames
|
||||||
|
:rtype: list(str)
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
commit_id = Git.OId.new_from_string(commit)
|
||||||
|
commit_obj = repo.lookup(commit_id, Git.Commit)
|
||||||
|
tree = commit_obj.get_tree()
|
||||||
|
return sorted([tree.get(i).get_name() for i in range(0,tree.size())])
|
||||||
|
|
||||||
|
def delete_file(repo, branch, filename):
|
||||||
|
"""Delete a file from a branch.
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: filename to delete
|
||||||
|
:type filename: str
|
||||||
|
:returns: OId of the new commit
|
||||||
|
:rtype: Git.OId
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
parent_commit = head_commit(repo, branch)
|
||||||
|
parent_tree = parent_commit.get_tree()
|
||||||
|
builder = repo.create_tree_builder_from_tree(parent_tree)
|
||||||
|
builder.remove(filename)
|
||||||
|
(tree, sig, ref) = prepare_commit(repo, branch, builder)
|
||||||
|
message = "Recipe %s deleted" % filename
|
||||||
|
return repo.create_commit(ref, sig, sig, "UTF-8", message, tree, [parent_commit])
|
||||||
|
|
||||||
|
def revert_file(repo, branch, filename, commit):
|
||||||
|
"""Revert the contents of a file to that of a previous commit
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: filename to revert
|
||||||
|
:type filename: str
|
||||||
|
:param commit: Commit hash
|
||||||
|
:type commit: str
|
||||||
|
:returns: OId of the new commit
|
||||||
|
:rtype: Git.OId
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
commit_id = Git.OId.new_from_string(commit)
|
||||||
|
commit_obj = repo.lookup(commit_id, Git.Commit)
|
||||||
|
revert_tree = commit_obj.get_tree()
|
||||||
|
entry = revert_tree.get_by_name(filename)
|
||||||
|
blob_id = entry.get_id()
|
||||||
|
parent_commit = head_commit(repo, branch)
|
||||||
|
|
||||||
|
# Use treebuilder to modify the tree
|
||||||
|
parent_tree = parent_commit.get_tree()
|
||||||
|
builder = repo.create_tree_builder_from_tree(parent_tree)
|
||||||
|
builder.insert(filename, blob_id, Git.FileMode.BLOB)
|
||||||
|
(tree, sig, ref) = prepare_commit(repo, branch, builder)
|
||||||
|
commit_hash = commit_id.to_string()
|
||||||
|
message = "Recipe %s reverted to commit %s" % (filename, commit_hash)
|
||||||
|
return repo.create_commit(ref, sig, sig, "UTF-8", message, tree, [parent_commit])
|
||||||
|
|
||||||
|
def commit_recipe(repo, branch, recipe, new_version=None):
|
||||||
|
"""Commit a recipe to a branch
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param recipe: Recipe to commit
|
||||||
|
:type recipe: Recipe
|
||||||
|
:returns: OId of the new commit
|
||||||
|
:rtype: Git.OId
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
recipe.bump_version(new_version)
|
||||||
|
recipe_toml = recipe.toml()
|
||||||
|
message = "Recipe %s, version %s saved." % (recipe["name"], recipe["version"])
|
||||||
|
return write_commit(repo, branch, recipe.filename, message, recipe_toml)
|
||||||
|
|
||||||
|
def commit_recipe_file(repo, branch, filename):
|
||||||
|
"""Commit a recipe file to a branch
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: Path to the recipe file to commit
|
||||||
|
:type filename: str
|
||||||
|
:returns: OId of the new commit
|
||||||
|
:rtype: Git.OId
|
||||||
|
:raises: Can raise errors from Ggit or RecipeFileError
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
f = open(filename, 'rb')
|
||||||
|
recipe = recipe_from_toml(f.read())
|
||||||
|
except IOError:
|
||||||
|
raise RecipeFileError
|
||||||
|
|
||||||
|
return commit_recipe(repo, branch, recipe, new_version=recipe["version"])
|
||||||
|
|
||||||
|
def commit_recipe_directory(repo, branch, directory):
|
||||||
|
"""Commit all *.toml files from a directory, if they aren't already in git.
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param directory: The directory of *.toml recipes to commit
|
||||||
|
:type directory: str
|
||||||
|
:returns: None
|
||||||
|
:raises: Can raise errors from Ggit or RecipeFileError
|
||||||
|
|
||||||
|
Files with Toml or RecipeFileErrors will be skipped, and the remainder will
|
||||||
|
be tried.
|
||||||
|
"""
|
||||||
|
dir_files = set([e for e in os.listdir(directory) if e.endswith(".toml")])
|
||||||
|
branch_files = set(list_branch_files(repo, branch))
|
||||||
|
new_files = dir_files.difference(branch_files)
|
||||||
|
|
||||||
|
for f in new_files:
|
||||||
|
# Skip files with errors, but try the others
|
||||||
|
try:
|
||||||
|
commit_recipe_file(repo, branch, f)
|
||||||
|
except (RecipeFileError, toml.TomlError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def tag_file_commit(repo, branch, filename):
|
||||||
|
"""Tag a file's most recent commit
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: Filename to tag
|
||||||
|
:type filename: str
|
||||||
|
:returns: Tag id or None if it failed.
|
||||||
|
:rtype: Git.OId
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
|
||||||
|
This uses git tags, of the form `refs/tags/<branch>/<filename>/r<revision>`
|
||||||
|
Only the most recent recipe commit can be tagged to prevent out of order tagging.
|
||||||
|
Revisions start at 1 and increment for each new commit that is tagged.
|
||||||
|
If the commit has already been tagged it will return false.
|
||||||
|
"""
|
||||||
|
file_commits = list_commits(repo, branch, filename)
|
||||||
|
if not file_commits:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the most recently tagged version (may not be one) and add 1 to it.
|
||||||
|
for details in file_commits:
|
||||||
|
if details.revision is not None:
|
||||||
|
new_revision = details.revision + 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
new_revision = 1
|
||||||
|
|
||||||
|
name = "%s/%s/r%d" % (branch, filename, new_revision)
|
||||||
|
sig = Git.Signature.new_now("bdcs-api-server", "user-email")
|
||||||
|
commit_id = Git.OId.new_from_string(file_commits[0].commit)
|
||||||
|
commit = repo.lookup(commit_id, Git.Commit)
|
||||||
|
return repo.create_tag(name, commit, sig, name, Git.CreateFlags.NONE)
|
||||||
|
|
||||||
|
def find_commit_tag(repo, branch, filename, commit_id):
|
||||||
|
"""Find the tag that matches the commit_id
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: filename to revert
|
||||||
|
:type filename: str
|
||||||
|
:param commit_id: The commit id to check
|
||||||
|
:type commit_id: Git.OId
|
||||||
|
:returns: The tag or None if there isn't one
|
||||||
|
:rtype: str or None
|
||||||
|
|
||||||
|
There should be only 1 tag pointing to a commit, but there may not
|
||||||
|
be a tag at all.
|
||||||
|
|
||||||
|
The tag will look like: 'refs/tags/<branch>/<filename>/r<revision>'
|
||||||
|
"""
|
||||||
|
pattern = "%s/%s/r*" % (branch, filename)
|
||||||
|
tags = [t for t in repo.list_tags_match(pattern) if is_commit_tag(repo, commit_id, t)]
|
||||||
|
if len(tags) != 1:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return tags[0]
|
||||||
|
|
||||||
|
def is_commit_tag(repo, commit_id, tag):
|
||||||
|
"""Check to see if a tag points to a specific commit.
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param commit_id: The commit id to check
|
||||||
|
:type commit_id: Git.OId
|
||||||
|
:param tag: The tag to check
|
||||||
|
:type tag: str
|
||||||
|
:returns: True if the tag points to the commit, False otherwise
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
ref = repo.lookup_reference("refs/tags/" + tag)
|
||||||
|
tag_id = ref.get_target()
|
||||||
|
tag = repo.lookup(tag_id, Git.Tag)
|
||||||
|
target_id = tag.get_target_id()
|
||||||
|
return commit_id.compare(target_id) == 0
|
||||||
|
|
||||||
|
def get_revision_from_tag(tag):
|
||||||
|
"""Return the revision number from a tag
|
||||||
|
|
||||||
|
:param tag: The tag to exract the revision from
|
||||||
|
:type tag: str
|
||||||
|
:returns: The integer revision or None
|
||||||
|
:rtype: int or None
|
||||||
|
|
||||||
|
The revision is the part after the r in 'branch/filename/rXXX'
|
||||||
|
"""
|
||||||
|
if tag is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(tag.rsplit('r',2)[-1])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class CommitDetails(DataHolder):
|
||||||
|
def __init__(self, commit, timestamp, message, revision=None):
|
||||||
|
DataHolder.__init__(self,
|
||||||
|
commit = commit,
|
||||||
|
timestamp = timestamp,
|
||||||
|
message = message,
|
||||||
|
revision = revision)
|
||||||
|
|
||||||
|
def list_commits(repo, branch, filename):
|
||||||
|
"""List the commit history of a file on a branch.
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param branch: Branch name
|
||||||
|
:type branch: str
|
||||||
|
:param filename: filename to revert
|
||||||
|
:type filename: str
|
||||||
|
:returns: A list of commit details
|
||||||
|
:rtype: list(CommitDetails)
|
||||||
|
:raises: Can raise errors from Ggit
|
||||||
|
"""
|
||||||
|
revwalk = Git.RevisionWalker.new(repo)
|
||||||
|
revwalk.set_sort_mode(Git.SortMode.TIME | Git.SortMode.REVERSE)
|
||||||
|
branch_ref = "refs/heads/%s" % branch
|
||||||
|
revwalk.push_ref(branch_ref)
|
||||||
|
|
||||||
|
commits = []
|
||||||
|
while True:
|
||||||
|
commit_id = revwalk.next()
|
||||||
|
if not commit_id:
|
||||||
|
break
|
||||||
|
commit = repo.lookup(commit_id, Git.Commit)
|
||||||
|
|
||||||
|
parents = commit.get_parents()
|
||||||
|
# No parents? Must be the first commit.
|
||||||
|
if parents.get_size() == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tree = commit.get_tree()
|
||||||
|
# Is the filename in this tree? If not, move on.
|
||||||
|
if not tree.get_by_name(filename):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Is filename different in all of the parent commits?
|
||||||
|
parent_commits = map(parents.get, xrange(0, parents.get_size()))
|
||||||
|
is_diff = all(map(lambda pc: is_parent_diff(repo, filename, tree, pc), parent_commits))
|
||||||
|
# No changes from parents, skip it.
|
||||||
|
if not is_diff:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag = find_commit_tag(repo, branch, filename, commit.get_id())
|
||||||
|
try:
|
||||||
|
commits.append(get_commit_details(commit, get_revision_from_tag(tag)))
|
||||||
|
except CommitTimeValError:
|
||||||
|
# Skip any commits that have trouble converting the time
|
||||||
|
# TODO - log details about this failure
|
||||||
|
pass
|
||||||
|
|
||||||
|
# These will be in reverse time sort order thanks to revwalk
|
||||||
|
return commits
|
||||||
|
|
||||||
|
def get_commit_details(commit, revision=None):
|
||||||
|
"""Return the details about a specific commit.
|
||||||
|
|
||||||
|
:param commit: The commit to get details from
|
||||||
|
:type commit: Git.Commit
|
||||||
|
:param revision: Optional commit revision
|
||||||
|
:type revision: int
|
||||||
|
:returns: Details about the commit
|
||||||
|
:rtype: CommitDetails
|
||||||
|
:raises: CommitTimeValError or Ggit exceptions
|
||||||
|
|
||||||
|
"""
|
||||||
|
message = commit.get_message()
|
||||||
|
commit_str = commit.get_id().to_string()
|
||||||
|
sig = commit.get_committer()
|
||||||
|
|
||||||
|
datetime = sig.get_time()
|
||||||
|
# XXX What do we do with timezone?
|
||||||
|
_timezone = sig.get_time_zone()
|
||||||
|
timeval = GLib.TimeVal()
|
||||||
|
ok = datetime.to_timeval(timeval)
|
||||||
|
if not ok:
|
||||||
|
raise CommitTimeValError
|
||||||
|
time_str = timeval.to_iso8601()
|
||||||
|
|
||||||
|
return CommitDetails(commit_str, time_str, message, revision)
|
||||||
|
|
||||||
|
def is_parent_diff(repo, filename, tree, parent):
|
||||||
|
"""Check to see if the commit is different from its parents
|
||||||
|
|
||||||
|
:param repo: Open repository
|
||||||
|
:type repo: Git.Repository
|
||||||
|
:param filename: filename to revert
|
||||||
|
:type filename: str
|
||||||
|
:param tree: The commit's tree
|
||||||
|
:type tree: Git.Tree
|
||||||
|
:param parent: The commit's parent commit
|
||||||
|
:type parent: Git.Commit
|
||||||
|
:retuns: True if filename in the commit is different from its parents
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
diff_opts = Git.DiffOptions.new()
|
||||||
|
diff_opts.set_pathspec([filename])
|
||||||
|
diff = Git.Diff.new_tree_to_tree(repo, parent.get_tree(), tree, diff_opts)
|
||||||
|
return diff.get_num_deltas() > 0
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from pylorax.api import crossdomain
|
from pylorax.api.crossdomain import crossdomain
|
||||||
from pylorax.api.v0 import v0_api
|
from pylorax.api.v0 import v0_api
|
||||||
|
|
||||||
server = Flask(__name__)
|
server = Flask(__name__)
|
||||||
|
@ -20,7 +20,7 @@ from flask import jsonify
|
|||||||
from pykickstart.parser import KickstartParser
|
from pykickstart.parser import KickstartParser
|
||||||
from pykickstart.version import makeVersion, RHEL7
|
from pykickstart.version import makeVersion, RHEL7
|
||||||
|
|
||||||
from pylorax.api import crossdomain
|
from pylorax.api.crossdomain import crossdomain
|
||||||
from pylorax.creator import DRACUT_DEFAULT, mount_boot_part_over_root
|
from pylorax.creator import DRACUT_DEFAULT, mount_boot_part_over_root
|
||||||
from pylorax.creator import make_appliance, make_image, make_livecd, make_live_images
|
from pylorax.creator import make_appliance, make_image, make_livecd, make_live_images
|
||||||
from pylorax.creator import make_runtime, make_squashfs
|
from pylorax.creator import make_runtime, make_squashfs
|
||||||
|
0
src/pylorax/api/workspace.py
Normal file
0
src/pylorax/api/workspace.py
Normal file
Loading…
Reference in New Issue
Block a user