Debian: Add modify patch and update iso path

- Adds the --modify to the make_patch script which
updates the STATUS tag to one of the supported options:
DEV, REL or OBS

Example:
./make_patch.py modify --status development \
--patch-file PATCH_0001.patch
* --formal can also be used if the patch needs to be signed

- Updates the deploy dir path to:
localdisk/deploy as now we have a single iso for std and rt.

- Remove deprecated make_test_patch script

Test Plan:
Pass: create and modify patch status
Pass: modify patch and formal sign it

Story: 2009969
Task: 45842
Signed-off-by: Luis Sampaio <luis.sampaio@windriver.com>
Change-Id: I508c2a723a07f48f8fc84ed043968eadc5804dcf
changes/86/850586/2
Luis Sampaio 2 months ago
parent 8b04c8113b
commit 89ff083a39
  1. 253
      sw-patch/cgcs-patch/cgcs_make_patch/make_patch.py
  2. 2
      sw-patch/cgcs-patch/cgcs_make_patch/make_patching_workspace.py
  3. 357
      sw-patch/scripts/make_test_patch.py

@ -31,11 +31,7 @@ This will create a new commit in the build ostree_repo
--clone-repo ostree_test
Once the script is done the .patch file can be located at:
$STX_BUILD_HOME/localdisk/lat/std/deploy/
Pending items:
- Modify patch Status
$STX_BUILD_HOME/localdisk/deploy/
"""
import argparse
import hashlib
@ -52,10 +48,13 @@ from xml.dom import minidom
# Signing function
sys.path.insert(0, "../../cgcs-patch")
from cgcs_patch.patch_signing import sign_files # noqa: E402 pylint: disable=wrong-import-position
from cgcs_patch.patch_verify import verify_files # noqa: E402 pylint: disable=wrong-import-position
# STATUS_OBSOLETE = 'OBS'
# STATUS_RELEASED = 'REL'
STATUS_DEVELOPEMENT = 'DEV'
PATCH_STATUS = {
'release': 'REL',
'obsolete': 'OBS',
'development': 'DEV'
}
METADATA_TAGS = ['ID', 'SW_VERSION', 'SUMMARY', 'DESCRIPTION', 'INSTALL_INSTRUCTIONS', 'WARNINGS', 'STATUS',
'UNREMOVABLE', 'REBOOT_REQUIRED', 'REQUIRES', 'RESTART_SCRIPT', 'APPLY_ACTIVE_RELEASE_ONLY']
@ -103,6 +102,21 @@ class PatchRecipeXMLFail(PatchError):
pass
class PatchInvalidStatus(PatchError):
"""Invalid status"""
pass
class PatchModifyError(PatchError):
"""Error while modifying patch"""
pass
class PatchValidationFailure(PatchError):
"""Patch validation failure"""
pass
class PatchRecipeData(object):
"""
Patch data
@ -202,7 +216,7 @@ class PatchBuilder(object):
def __init__(self, delta_dir="delta_dir"):
try:
# ostree repo location
self.deploy_dir = os.path.join(os.environ["STX_BUILD_HOME"], "localdisk/lat/std/deploy")
self.deploy_dir = os.path.join(os.environ["STX_BUILD_HOME"], "localdisk/deploy")
self.ostree_repo = os.path.join(self.deploy_dir, "ostree_repo")
self.delta_dir = delta_dir
self.detached_signature_file = "signature.v2"
@ -243,7 +257,7 @@ class PatchBuilder(object):
if "STATUS" in self.patch_data.metadata:
self.__add_text_tag_to_xml(top, "status", self.patch_data.metadata["STATUS"])
else:
self.__add_text_tag_to_xml(top, "status", STATUS_DEVELOPEMENT)
self.__add_text_tag_to_xml(top, "status", PATCH_STATUS['development'])
self.__add_text_tag_to_xml(top, "unremovable", self.patch_data.metadata["UNREMOVABLE"])
self.__add_text_tag_to_xml(top, "reboot_required", self.patch_data.metadata["REBOOT_REQUIRED"])
@ -344,18 +358,59 @@ class PatchBuilder(object):
return commits_from_base
def __sign_official_patches(self):
def __sign_official_patches(self, patch_file):
"""
Sign formal patch
Called internally once a patch is created and formal flag is set to true
:param patch_file full path to the patch file
"""
log.info("Signing patch %s", self.patch_file_name)
log.info("Signing patch %s", patch_file)
try:
patch_file_path = os.path.join(self.deploy_dir, self.patch_file_name)
subprocess.check_call(["sign_patch_formal.sh", patch_file_path])
# patch_file_path = os.path.join(self.deploy_dir, self.patch_file_name)
subprocess.check_call(["sign_patch_formal.sh", patch_file])
except subprocess.CalledProcessError as e:
log.exception("Failed to sign official patch. Call to sign_patch_formal.sh process returned non-zero exit status %i", e.returncode)
raise SystemExit(e.returncode)
except FileNotFoundError:
log.exception("sign_patch_formal.sh not found, make sure $STX_BUILD_HOME/repo/cgcs-root/build-tools is in the $PATH")
def __sign_and_pack(self, patch_file, formal=False):
"""
Generates the patch signatures and pack the .patch file
:param patch_file .patch file full path
"""
filelist = ["metadata.tar", "software.tar"]
# Generate the local signature file
sig = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
for f in filelist:
sig ^= get_md5(f)
sigfile = open("signature", "w")
sigfile.write("%x" % sig)
sigfile.close()
# this comes from patch_functions write_patch
# Generate the detached signature
#
# Note: if cert_type requests a formal signature, but the signing key
# is not found, we'll instead sign with the "dev" key and
# need_resign_with_formal is set to True.
need_resign_with_formal = sign_files(
filelist,
self.detached_signature_file,
cert_type=None)
log.debug("Formal signing status %s", need_resign_with_formal)
# Save files into .patch
files = [f for f in os.listdir('.') if os.path.isfile(f)]
tar = tarfile.open(patch_file, "w:gz")
for file in files:
tar.add(file)
tar.close()
log.info("Patch file created %s", patch_file)
if formal:
log.info("Trying to sign formal patch")
self.__sign_official_patches(patch_file)
def prepare_env(self, clone_repo="ostree-clone"):
"""
@ -429,6 +484,7 @@ class PatchBuilder(object):
tar = tarfile.open("metadata.tar", "w")
tar.add("metadata.xml")
tar.close()
os.remove("metadata.xml")
if self.patch_data.restart_script:
log.info("Saving restart scripts")
@ -437,49 +493,103 @@ class PatchBuilder(object):
self.patch_data.restart_script["metadata_name"]
)
# Sign and create the .patch file
self.__sign_and_pack(
os.path.join(self.deploy_dir, self.patch_file_name),
formal
)
os.chdir(self.deploy_dir)
shutil.rmtree(tmpdir)
shutil.rmtree(self.delta_dir)
log.info("Patch file created %s at %s", self.patch_file_name, self.deploy_dir)
def modify_metadata_text(self, filename, key, value):
"""
Open an xml file, find first element matching 'key' and replace the text with 'value'
"""
new_filename = "%s.new" % filename
tree = ET.parse(filename)
# Prevent a proliferation of carriage returns when we write this XML back out to file.
for e in tree.iter():
if e.text is not None:
e.text = e.text.rstrip()
if e.tail is not None:
e.tail = e.tail.rstrip()
root = tree.getroot()
# Make the substitution
e = root.find(key)
if e is None:
msg = "modify_metadata_text: failed to find tag '%s'" % key
log.error(msg)
raise PatchValidationFailure(msg)
e.text = value
# write the modified file
outfile = open(new_filename, 'w')
rough_xml = ET.tostring(root)
outfile.write(minidom.parseString(rough_xml).toprettyxml(indent=" "))
outfile.close()
os.rename(new_filename, filename)
def read_patch(self, path):
"""
Extract the patch to current dir and validate signature
"""
# Open the patch file and extract the contents to the current dir
tar = tarfile.open(path, "r:gz")
tar.extractall()
# Checks signature
sigfile = open("signature", "r")
sig = int(sigfile.read(), 16)
sigfile.close()
filelist = ["metadata.tar", "software.tar"]
# Generate the local signature file
sig = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
expected_sig = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
for f in filelist:
sig ^= get_md5(f)
sigfile = open("signature", "w")
sigfile.write("%x" % sig)
sigfile.close()
# this comes from patch_functions write_patch
# Generate the detached signature
#
# Note: if cert_type requests a formal signature, but the signing key
# is not found, we'll instead sign with the "dev" key and
# need_resign_with_formal is set to True.
need_resign_with_formal = sign_files(
filelist,
self.detached_signature_file,
cert_type=None)
if sig != expected_sig:
msg = "Patch failed verification"
log.error(msg)
raise PatchValidationFailure(msg)
# Verify detached signature
if os.path.exists(self.detached_signature_file):
sig_valid = verify_files(
filelist,
self.detached_signature_file,
cert_type=None)
sig_valid = True
if sig_valid is True:
msg = "Signature verified, patch has been signed"
else:
msg = "Signature check failed"
raise PatchValidationFailure(msg)
else:
msg = "Patch has not been signed"
raise PatchValidationFailure(msg)
log.debug("Formal signing status %s", need_resign_with_formal)
# Extract metadata xml
tar = tarfile.open("metadata.tar")
tar.extractall()
# Create the patch
tar = tarfile.open(os.path.join(self.deploy_dir, self.patch_file_name), "w:gz")
for file in filelist:
tar.add(file)
tar.add("signature")
tar.add(self.detached_signature_file)
if self.patch_data.restart_script and \
os.path.isfile(self.patch_data.restart_script["metadata_name"]):
tar.add(self.patch_data.restart_script["metadata_name"])
def write_patch(self, patch_file, formal=False):
"""
Write files into .patch file and sign
"""
log.info("Saving patch file")
tar = tarfile.open("metadata.tar", "w")
tar.add("metadata.xml")
tar.close()
# remove the xml
os.remove("metadata.xml")
os.chdir(self.deploy_dir)
shutil.rmtree(tmpdir)
shutil.rmtree(self.delta_dir)
log.info("Patch file created %s at %s", self.patch_file_name, self.deploy_dir)
if formal:
log.info("Trying to sign formal patch")
self.__sign_official_patches()
# Sign and create the .patch file
self.__sign_and_pack(patch_file, formal)
def handle_create(params):
@ -510,6 +620,38 @@ def handle_prepare(params):
patch_builder.prepare_env(params.clone_repo)
def handle_modify(params):
"""
Modify patch status and resigns
"""
log.info("Modifying patch %s", params.patch_file)
if not os.path.isfile(params.patch_file):
raise FileNotFoundError("Patch file not found")
if params.status not in PATCH_STATUS:
raise PatchInvalidStatus(f"Supported status are {PATCH_STATUS}")
# Modify patch
orig_wd = os.getcwd()
workdir = tempfile.mkdtemp(prefix="patch_modify_")
os.chdir(workdir)
try:
p = PatchBuilder()
# extract and validate signatures
p.read_patch(params.patch_file)
log.info("Updating patch status to %s", PATCH_STATUS[params.status])
# Update Status
p.modify_metadata_text("metadata.xml", "status", PATCH_STATUS[params.status])
p.write_patch(params.patch_file, params.formal)
except PatchModifyError:
log.exception("Error while modifying patch")
finally:
shutil.rmtree(workdir)
os.chdir(orig_wd)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Debian make_patch helper")
@ -532,6 +674,15 @@ if __name__ == "__main__":
create_parser.add_argument("-d", "--delta-dir", type=str, help="Delta dir name", default="delta-dir")
create_parser.add_argument("-c", "--clone-repo", type=str, help="Clone repo directory name", default=None, required=True)
# Modify Patch action
modify_parser = subparsers.add_parser("modify",
add_help=False,
description="modify patch status",
help="Modify patch status - DEV, REL, OBS")
modify_parser.add_argument("-s", "--status", type=str, help="Patch status", required=True)
modify_parser.add_argument("-f", "--formal", action="store_true", help="Formal patch flag")
modify_parser.add_argument("-pf", "--patch-file", type=str, help="Patch file", required=True)
args = parser.parse_args()
log.debug("Args: %s", args)
@ -539,5 +690,7 @@ if __name__ == "__main__":
handle_create(args)
elif args.cmd == "prepare":
handle_prepare(args)
elif args.cmd == "modify":
handle_modify(args)
log.info("Done")

@ -252,7 +252,7 @@ if __name__ == "__main__":
patch_env = PatchEnv()
log.info("Environment: %s", patch_env)
deploy_dir = os.path.join(patch_env.build_home, "localdisk", "lat", "std", "deploy")
deploy_dir = os.path.join(patch_env.build_home, "localdisk", "deploy")
ostree_repo_build = os.path.join(deploy_dir, "ostree_repo")
# Setup env

@ -1,357 +0,0 @@
#
# Copyright (c) 2022 Wind River Systems, Inc.
#
# SPDX-License-Identifier: Apache-2.0
#
'''
Create a test patch for Debian
- fake restart script
- fake dettached signature (signature.v2)
Prereqs:
- Requires the ostree tool installed - apt-get install ostree
- export STX_BUILD_HOME
e.g: export STX_BUILD_HOME=/localdisk/designer/lsampaio/stx-debian
- pip3 install pycryptodomex
Setup Steps:
sudo chmod 644 $STX_BUILD_HOME/localdisk/deploy/ostree_repo/.lock
sudo chmod 644 $STX_BUILD_HOME/localdisk/lat/std/deploy/ostree_repo/.lock
python make_test_patch.py --prepare --repo ostree_repo --clone-repo ostree-clone
Patch Steps:
<make some ostree changes>
<build-image>
rm -Rf $STX_BUILD_HOME/localdisk/deploy/delta_dir
rm -Rf $STX_BUILD_HOME/localdisk/lat/std/deploy/delta_dir
python make_test_patch.py --create --repo ostree_repo --clone-repo ostree-clone
'''
import argparse
import hashlib
import logging
import tarfile
import tempfile
import os
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
from xml.dom import minidom
sys.path.insert(0, "../cgcs-patch")
from cgcs_patch.patch_signing import sign_files
# ostree_repo location
DEPLOY_DIR = os.path.join(os.environ['STX_BUILD_HOME'], 'localdisk/lat/std/deploy')
OSTREE_REPO = os.path.join(DEPLOY_DIR, 'ostree_repo')
# Delta dir used by rsync, hardcoded for now
DELTA_DIR = 'delta_dir'
detached_signature_file = 'signature.v2'
SOFTWARE_VERSION = '22.06'
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
)
log = logging.getLogger('make_test_patch')
def prepare_env(name='ostree-clone'):
'''
Generates a copy of the current ostree_repo which is used
to create the delta dir during patch creation
:param name: name of the cloned directory
'''
log.info('Preparing ostree clone directory')
os.chdir(DEPLOY_DIR)
clone_dir = os.path.join(DEPLOY_DIR, name)
if os.path.isdir(clone_dir):
log.error('Clone directory exists {}'.format(name))
exit(1)
os.mkdir(clone_dir)
current_sha = open(os.path.join(OSTREE_REPO, 'refs/heads/starlingx'), 'r').read()
log.info('Current SHA: {}'.format(current_sha))
log.info('Cloning the directory...')
subprocess.call(['rsync', '-a', OSTREE_REPO + '/', clone_dir])
log.info('Prepared ostree repo clone at {}'.format(clone_dir))
def create_delta_dir(delta_dir='delta_dir', clone_dir='ostree-clone', clean_mode=False):
'''
Creates the ostree delta directory
Contains the changes from the REPO (updated) and the cloned dir (pre update)
:param delta_dir: delta directory name
:param clone_dir: clone dir name
'''
log.info('Creating ostree delta')
clone_dir = os.path.join(DEPLOY_DIR, clone_dir)
if os.path.isdir(delta_dir):
if clean_mode:
log.info('Delta dir exists {}, cleaning it'.format(delta_dir))
shutil.rmtree(delta_dir)
else:
log.error('Delta dir exists {}, clean it up and try again'.format(delta_dir))
exit(1)
if not os.path.isdir(clone_dir):
log.error('Clone dir not found')
exit(1)
subprocess.call(['rsync', '-rpgo', '--compare-dest', clone_dir, OSTREE_REPO + '/', delta_dir + '/'])
log.info('Delta dir created')
def add_text_tag_to_xml(parent,
name,
text):
'''
Utility function for adding a text tag to an XML object
:param parent: Parent element
:param name: Element name
:param text: Text value
:return:The created element
'''
tag = ET.SubElement(parent, name)
tag.text = text
return tag
def gen_xml(patch_id, ostree_content, file_name="metadata.xml"):
'''
Generate patch metadata XML file
:param file_name: Path to output file
'''
top = ET.Element("patch")
add_text_tag_to_xml(top, 'id', patch_id)
add_text_tag_to_xml(top, 'sw_version', SOFTWARE_VERSION)
add_text_tag_to_xml(top, 'summary', 'Summary text')
add_text_tag_to_xml(top, 'description', 'Description text')
add_text_tag_to_xml(top, 'install_instructions', 'Install instructions text')
add_text_tag_to_xml(top, 'warnings', 'Warnings text')
add_text_tag_to_xml(top, 'status', 'DEV')
add_text_tag_to_xml(top, 'unremovable', 'N')
add_text_tag_to_xml(top, 'reboot_required', 'Y')
add_text_tag_to_xml(top, 'apply_active_release_only', '')
add_text_tag_to_xml(top, 'restart_script', 'Patch1_Restart_Script.sh')
# Parse ostree_content
content = ET.SubElement(top, 'contents')
ostree = ET.SubElement(content, 'ostree')
add_text_tag_to_xml(ostree, 'number_of_commits', str(len(ostree_content['commits'])))
base_commit = ET.SubElement(ostree, 'base')
add_text_tag_to_xml(base_commit, 'commit', ostree_content['base']['commit'])
add_text_tag_to_xml(base_commit, 'checksum', ostree_content['base']['checksum'])
for i, c in enumerate(ostree_content['commits']):
commit = ET.SubElement(ostree, 'commit' + str(i + 1))
add_text_tag_to_xml(commit, 'commit', c['commit'])
add_text_tag_to_xml(commit, 'checksum', c['checksum'])
add_text_tag_to_xml(top, 'requires', '')
add_text_tag_to_xml(top, 'semantics', '')
# print
outfile = open(file_name, 'w')
tree = ET.tostring(top)
outfile.write(minidom.parseString(tree).toprettyxml(indent=" "))
def gen_restart_script(file_name):
'''
Generate restart script
:param file_name: Path to script file
'''
# print
outfile = open(file_name, 'w')
r = 'echo test restart script'
outfile.write(r)
def get_md5(path):
'''
Utility function for generating the md5sum of a file
:param path: Path to file
'''
md5 = hashlib.md5()
block_size = 8192
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(block_size), b''):
md5.update(chunk)
return int(md5.hexdigest(), 16)
def get_commit_checksum(commit_id, repo='ostree_repo'):
'''
Get commit checksum from a commit id
:param commit_id
:param repo
'''
# get all checksums
cmd = 'ostree --repo={} log starlingx | grep -i checksum | sed \'s/.* //\''.format(repo)
cksums = subprocess.check_output(cmd, shell=True).decode(sys.stdout.encoding).strip().split('\n')
return(cksums[commit_id])
def get_commits_from_base(base_sha, repo='ostree_repo'):
'''
Get a list of commits from base sha
:param base_sha
:param repo
'''
commits_from_base = []
cmd = 'ostree --repo={} log starlingx | grep commit | sed \'s/.* //\''.format(repo)
commits = subprocess.check_output(cmd, shell=True).decode(sys.stdout.encoding).strip().split('\n')
if commits[0] == base_sha:
log.info('base and top commit are the same')
return commits_from_base
# find base and add the commits to the list
for i, c in enumerate(commits):
if c == base_sha:
break
log.info('saving commit {}'.format(c))
# find commit checksum
cksum = get_commit_checksum(i, repo)
commits_from_base.append({
'commit': c,
'checksum': cksum
})
return commits_from_base
def create_patch(patch_id, patch_file, repo='ostree_repo', clone_dir='ostree-clone', clean_mode=False):
'''
Creates a debian patch using ostree delta between 2 repos (rsync)
:param repo: main ostree_repo where build-image adds new commits
:param clone_dir: repo cloned before the changes
'''
os.chdir(DEPLOY_DIR)
# read the base sha from the clone
base_sha = open(os.path.join(clone_dir, 'refs/heads/starlingx'), 'r').read().strip()
log.info('Generating delta dir')
create_delta_dir(delta_dir=DELTA_DIR, clone_dir=clone_dir, clean_mode=clean_mode)
# ostree --repo=ostree_repo show starlingx | grep -i checksum | sed 's/.* //'
cmd = 'ostree --repo={} show starlingx | grep -i checksum | sed \'s/.* //\''.format(clone_dir)
base_checksum = subprocess.check_output(cmd, shell=True).decode(sys.stdout.encoding).strip()
commits = get_commits_from_base(base_sha, repo)
if commits:
ostree_content = {
'base': {
'commit': base_sha,
'checksum': base_checksum
},
}
ostree_content['commits'] = commits
else:
log.info('No changes detected')
exit(0)
log.info('Generating patch file...')
# Create software.tar, metadata.tar and signatures
# Create a temporary working directory
tmpdir = tempfile.mkdtemp(prefix='patch_')
# Change to the tmpdir
os.chdir(tmpdir)
tar = tarfile.open('software.tar', 'w')
tar.add(os.path.join(DEPLOY_DIR, DELTA_DIR), arcname='')
tar.close
log.info('Generating xml with ostree content {}'.format(commits))
gen_xml(patch_id, ostree_content)
tar = tarfile.open('metadata.tar', 'w')
tar.add('metadata.xml')
tar.close()
log.info('Saving restart scripts (if any)')
# TODO: verify how to handle the restart script
gen_restart_script('Patch1_Restart_Script.sh')
filelist = ['metadata.tar', 'software.tar']
# Generate the signature file
sig = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
for f in filelist:
sig ^= get_md5(f)
sigfile = open('signature', 'w')
sigfile.write('%x' % sig)
sigfile.close()
# this comes from patch_functions write_patch
# Generate the detached signature
#
# Note: if cert_type requests a formal signature, but the signing key
# is not found, we'll instead sign with the 'dev' key and
# need_resign_with_formal is set to True.
need_resign_with_formal = sign_files(
filelist,
detached_signature_file,
cert_type=None)
# Create the patch
tar = tarfile.open(os.path.join(DEPLOY_DIR, patch_file), 'w:gz')
for f in filelist:
tar.add(f)
tar.add('signature')
tar.add(detached_signature_file)
tar.add('Patch1_Restart_Script.sh')
tar.close()
os.chdir(DEPLOY_DIR)
shutil.rmtree(tmpdir)
log.info('Patch file created {} at {}'.format(patch_file, DEPLOY_DIR))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Debian make_test_patch helper")
parser.add_argument('-r', '--repo', type=str,
help='Ostree repo name',
default=None, required=True)
parser.add_argument('-p', '--prepare', action='store_true',
help='Prepare the ostree_repo clone directory, should be executed before making changes to the environment')
parser.add_argument('-cr', '--clone-repo', type=str,
help='Clone repo directory name',
default=None, required=True)
parser.add_argument('-c', '--create', action='store_true',
help='Create patch, should be executed after changes are done to the environment')
parser.add_argument('-i', '--id', type=str,
help='Patch ID', default='PATCH_0001')
parser.add_argument('-cl', '--clean-mode', action='store_true',
help='Whether to clean the delta directory automatically')
args = parser.parse_args()
log.info('STX_BUILD_HOME: {}'.format(os.environ['STX_BUILD_HOME']))
log.info('DEPLOY DIR: {}'.format(DEPLOY_DIR))
log.info('DELTA DIR: {}'.format(DELTA_DIR))
patch_id = args.id
patch_file = patch_id + '.patch'
if args.prepare:
log.info('Calling prepare environment')
prepare_env(args.clone_repo)
elif args.create:
log.info('Calling create patch')
create_patch(patch_id, patch_file, args.repo, args.clone_repo, args.clean_mode)
Loading…
Cancel
Save