#!/usr/bin/python3

# Script dependencies: curl devscripts jq git python3-tenacity python3-launchpadlib snapd

# This script inspects the Snap Store to determine the current git-ubuntu snap
# published in edge, then arranges to build and publish the same snap for other
# architectures using the same source commit.

# Since this is intended to be run headless from CI, Launchpad authentication
# must be provided as a credentials file with its path in an
# LP_CREDENTIALS_FILE environment variable. Create the contents of the file
# with the following. For the git-ubuntu-ci-bot user, it's best to do this in a
# container so as not to accidentally use your own cached credentials:
#
#     import launchpadlib.launchpad
#     lp = launchpadlib.launchpad.Launchpad.login_with('git-ubuntu snap-other-archs.py', 'production', 'devel')
#     with open('credentials', 'wb') as f:
#         f.write(lp.credentials.serialize())

# Since this is intended to be run headless from CI, Launchpad ssh
# authentication must be provided as a ssh private key file supplied with its
# path in the SSH_PRIVATE_KEY_FILE environment variable. This key must be
# registered in the Launchpad account that is used to push to the "gated" git
# repository branch (see constant definitions below).

# This script requires there to be an appropriate PPA, PPA build recipe and
# snap build recipe already present in Launchpad.

# git must have user.name and user.email configured so that git will create a
# commit without complaint.

# Method of operation:
#
# Clone and check out the original source commit (detached HEAD)
# Make minor modifications (to snapcraft.yaml and debian/changelog)
# Commit the changes
# Push changes to Launchpad
# Update the PPA build recipe to use the current version string
# Trigger a PPA build recipe build
# Wait for the PPA build recipe build to complete
# Wait for PPA builds to complete
# Trigger a snap recipe build

import datetime
import logging
import os
from subprocess import check_call, check_output
import tempfile
import yaml

import tenacity

import launchpadlib.launchpad

# The snap package name
SNAPPKG_NAME = 'git-ubuntu'

# DEBEMAIL and DEBFULLNAME are passed to dch to generate a changelog entry
DEBEMAIL = 'ubuntu-devel-discuss@lists.ubuntu.com'
DEBFULLNAME = 'git-ubuntu snap-other-archs.py build script'

# The Launchpad git repository to fetch from
GIT_FETCH_URL = 'https://git.launchpad.net/git-ubuntu'

# The Launchpad git repository to push to, and how to push to it
LP_GIT_USER = 'git-ubuntu-ci-bot'
LP_GIT_REPO = '~git-ubuntu-ci/git-ubuntu'
LP_GIT_BRANCH = 'gated'
GIT_PUSH_URL = f'git+ssh://{LP_GIT_USER}@git.launchpad.net/{LP_GIT_REPO}'
GIT_PUSH_REF = f'refs/heads/{LP_GIT_BRANCH}'
GIT_SSH_KNOWN_HOSTS = 'git.launchpad.net ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEFREwBD2ye2Xrc2SVcUmmJ44MF1BCB3W11NTaiqzVj7XZnQmgWZk9UadHVY2wBXvelcDO51MPN5ozJjFAknw09rP7XMRJMlAOLSIVoU6DRF1u1j8kJVY+dfiDHheS7+siADnrmb8HGn2xQQ6EJDjAXrw1x58x5eZjQ0PFWdI+pRTdYGvWkpHdXKFO6a9/lDx4uo9MCnePEGi/QnkCmKqLCBUlYNZYRiB8nVee2tMF0mjV8xk1rJ+/UP+897+FXFR9w/B1EPRjiQ35ZNQZKPP4isxPtyMuCQkZY7ckWr5YsylNfvNcyGDnO1XazZhJ71rzDpi1RmnFXBW5i+2dm2y7\n'

# The built binary deb name against which to await the build in the PPA
BINPKG_NAME = 'git-ubuntu'

# The PPA details to use to build the prerequisite deb
LP_PPA = 'https://api.launchpad.net/devel/~git-ubuntu-ci/+archive/ubuntu/git-ubuntu-gated'
LP_PPA_RECIPE = 'https://api.launchpad.net/devel/~git-ubuntu-ci/+recipe/git-ubuntu-gated'
LP_DISTROSERIES = 'https://api.launchpad.net/devel/ubuntu/focal'

PPA_URL = 'https://ppa.launchpadcontent.net/git-ubuntu-ci/git-ubuntu-gated/ubuntu'
PPA_RECIPE_VERSION_SUFFIX = '~ubuntu20.04.1'
PPA_KEY_ID = 'E79552E495B366373DFEF16281AC3E205A99896A'
SERIES = 'focal'

# The snap recipe to use to build and upload the snap for the other archs
LP_SNAP_RECIPE = 'https://api.launchpad.net/devel/~git-ubuntu-ci/git-ubuntu/+snap/git-ubuntu-gated'


class InProgress(RuntimeError): pass


@tenacity.retry(
    wait=tenacity.wait_fixed(60),
    stop=tenacity.stop_after_delay(86400),
    retry=tenacity.retry_if_exception_type(InProgress),
)
def recipe_build_successful(ppa_recipe_build):
    ppa_recipe_build.lp_refresh()
    state = ppa_recipe_build.buildstate
    if state in ['Needs building', 'Currently building', 'Uploading build']:
        raise InProgress
    else:
        return state == 'Successfully built'


@tenacity.retry(
    wait=tenacity.wait_fixed(60),
    stop=tenacity.stop_after_delay(86400),
    retry=tenacity.retry_if_exception_type(InProgress),
)
def wait_ppa(lp_archive, binpkg_name, binpkg_version):
    built_binaries = list(
        lp_archive.getPublishedBinaries(
            exact_match=True,
            binary_name=binpkg_name,
            version=binpkg_version,
            status="Published",
        )
    )
    # git-ubuntu is arch: all, so one build is all we expect and when that is
    # completed then all the builds we need are done.
    if not built_binaries:
        raise InProgress


if __name__ == '__main__':
    logging.basicConfig(level=logging.DEBUG)

    logging.debug('Creating Launchpad API object')
    with open(os.environ['LP_CREDENTIALS_FILE'], 'rb') as f:
        lp = launchpadlib.launchpad.Launchpad(
            launchpadlib.launchpad.Credentials.from_string(f.read()),
            None,
            None,
            'production',
            version='devel',
        )

    logging.debug('Determining current gated and built edge commit')
    edge_commit = check_output(f'''
curl -sS --unix-socket /run/snapd.socket 'http://localhost/v2/find?name={SNAPPKG_NAME}&scope=wide'|jq -r 'if .["status-code"] == 200 then .result[0].channels."latest/edge".version | split("-g") | .[-1] else error("Invalid status code") end'|tr -dc '[0-9A-Fa-f]'
''',
        shell=True,
    ).decode().strip()
    logging.info('Current gated and built edge commit is {edge_commit!r}')

    with tempfile.TemporaryDirectory() as git_dir:
        logging.debug('Cloning upstream git repository')
        check_call(['git', 'clone', GIT_FETCH_URL, git_dir])

        # Cannot use contextlib.chdir() since that's only available in Python
        # >= 3.11
        old_cwd = os.getcwd()
        os.chdir(git_dir)

        logging.debug('Checking out gated commit')
        check_call(['git', 'checkout', edge_commit])

        # Set up debian/changelog with an appropriate version string
        snap_version = check_output(['git', 'describe']).decode().strip()
        logging.info(f'snap_version is {snap_version!r}')

        # Replace the hyphens appropriately to construct a valid Debian native
        # version string
        deb_version = snap_version.replace('-', '+').replace('-', '.') + datetime.datetime.utcnow().strftime('+%Y%m%dT%H%M%SZ')
        logging.info(f'deb_version is {deb_version!r}')

        deb_env = dict(os.environ)
        deb_env.update(DEBEMAIL=DEBEMAIL, DEBFULLNAME=DEBFULLNAME)

        logging.debug('Updating debian/changelog')
        check_call(['dch', '-v', deb_version, ''], env=deb_env)
        check_call(['dch', '-r', '-D', SERIES, ''], env=deb_env)

        logging.debug('Reading snapcraft.yaml')
        with open('snap/snapcraft.yaml') as f:
            snapcraft = yaml.safe_load(f)
        # The version is tweaked here to avoid the snap version being marked
        # "dirty" just because we are modifying snapcraft.yaml with this script.
        snapcraft['version'] = snap_version
        snapcraft['package-repositories'] = [{
            'type': 'apt',
            'url': PPA_URL,
            'suites': [SERIES],
            'components': ['main'],
            'key-id': PPA_KEY_ID,
        }]

        logging.debug('Updating snapcraft.yaml')
        with open('snap/snapcraft.yaml', 'w') as f:
            yaml.dump(snapcraft, f)

        logging.debug('Committing metadata changes')
        check_call([
            'git',
            'commit',
            f'-mBuild for alternate architectures against {edge_commit}',
            'debian/changelog',
            'snap/snapcraft.yaml',
        ])

        with tempfile.NamedTemporaryFile(mode='w') as known_hosts_file:
            known_hosts_file.write(GIT_SSH_KNOWN_HOSTS)
            known_hosts_file.file.flush()
            git_ssh_env = dict(os.environ)
            git_ssh_env.update(
                GIT_SSH_COMMAND=f'ssh -o BatchMode=yes -o "UserKnownHostsFile={known_hosts_file.name}" -i "$SSH_PRIVATE_KEY_FILE"',
                GIT_SSH_VARIANT='simple',
                GIT_TERMINAL_PROMPT='0',
            )
            logging.debug('Pushing metadata change commit')
            check_call(
                [
                    'git',
                    'push',
                    GIT_PUSH_URL,
                    f'+HEAD:{GIT_PUSH_REF}',
                ],
                env=git_ssh_env,
            )

        os.chdir(old_cwd)

        logging.debug('Loading PPA recipe')
        ppa_recipe = lp.load(LP_PPA_RECIPE)
        logging.debug('Updating PPA recipe')
        ppa_recipe.recipe_text = f'''# git-build-recipe format 0.4 deb-version {deb_version}
lp:{LP_GIT_REPO} {LP_GIT_BRANCH}
'''
        ppa_recipe.lp_save()

        logging.debug('Loading PPA')
        ppa = lp.load(LP_PPA)

        logging.debug('Requesting PPA recipe build')
        ppa_recipe_build = ppa_recipe.requestBuild(
            archive=ppa,
            distroseries=lp.load(LP_DISTROSERIES),
            pocket='Release',
        )

        logging.debug('Awaiting successful PPA recipe build')
        if not recipe_build_successful(ppa_recipe_build):
            raise RuntimeError('Recipe build failed')

        logging.debug('Awaiting PPA build completion')
        wait_ppa(
            lp_archive=ppa,
            binpkg_name=BINPKG_NAME,
            binpkg_version=(deb_version+PPA_RECIPE_VERSION_SUFFIX),
        )

        logging.debug('Loading snap recipe')
        snap_recipe = lp.load(LP_SNAP_RECIPE)
        logging.debug('Requesting snap recipe build')
        snap_recipe.requestBuilds(
            archive=lp.distributions['ubuntu'].main_archive,
            pocket='Updates',
        )
        logging.debug('Done (snap recipe build status not checked)')
