← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:build-tarball into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:build-tarball into launchpad:master with ~cjwatson/launchpad:version-info-outside-git as a prerequisite.

Commit message:
Support publishing deployment artifact to Swift

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/412692

This adds `build-tarball` and `publish-tarball` make targets, which will help us to write a Jenkins job that builds a deployment artifact and publishes it to Swift for use by future deployment machinery.

The new `ols-vms.conf` file is to assist integration with lp:ols-jenkaas, which is where we currently do publication to Swift for other projects.

Aside from the new build and publish scripts, I had to adjust the existing wheel-building target a bit to also build wheels for the packages in `requirements/setup.txt`.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:build-tarball into launchpad:master.
diff --git a/Makefile b/Makefile
index 8b00a2e..be3eb85 100644
--- a/Makefile
+++ b/Makefile
@@ -15,6 +15,8 @@ PY=$(WD)/bin/py
 PYTHONPATH:=$(WD)/lib:${PYTHONPATH}
 VERBOSITY=-vv
 
+DEPENDENCY_REPO ?= https://git.launchpad.net/lp-source-dependencies
+
 # virtualenv and pip fail if setlocale fails, so force a valid locale.
 PIP_ENV := LC_ALL=C.UTF-8
 # Run with "make PIP_NO_INDEX=" if you want pip to find software
@@ -95,6 +97,21 @@ PIP_BIN = \
     bin/watch_jsbuild \
     bin/with-xvfb
 
+# Create archives in labelled directories (e.g.
+# <rev-id>/$(PROJECT_NAME).tar.gz)
+TARBALL_BUILD_LABEL ?= $(shell git rev-parse HEAD)
+TARBALL_FILE_NAME = launchpad.tar.gz
+TARBALL_BUILDS_DIR ?= dist
+TARBALL_BUILD_DIR = $(TARBALL_BUILDS_DIR)/$(TARBALL_BUILD_LABEL)
+TARBALL_BUILD_PATH = $(TARBALL_BUILD_DIR)/$(TARBALL_FILE_NAME)
+
+SWIFT_CONTAINER_NAME ?= launchpad-builds
+# This must match the object path used by fetch_payload in the ols charm
+# layer.
+SWIFT_OBJECT_PATH = \
+       launchpad-builds/$(TARBALL_BUILD_LABEL)/$(TARBALL_FILE_NAME)
+
+
 # DO NOT ALTER : this should just build by default
 .PHONY: default
 default: inplace
@@ -173,6 +190,17 @@ inplace: build logs clean_logs codehosting-dir
 .PHONY: build
 build: compile apidoc jsbuild css_combine
 
+# Bootstrap download-cache and sourcecode.  Useful for CI jobs that want to
+# set these up from scratch.
+.PHONY: bootstrap
+bootstrap:
+	if [ -d download-cache/.git ]; then \
+		git -C download-cache pull; \
+	else \
+		git clone --depth=1 $(DEPENDENCY_REPO) download-cache; \
+	fi
+	utilities/update-sourcecode
+
 # LP_SOURCEDEPS_PATH should point to the sourcecode directory, but we
 # want the parent directory where the download-cache and env directories
 # are. We re-use the variable that is using for the rocketfuel-get script.
@@ -258,29 +286,48 @@ requirements/combined.txt: \
 		--include requirements/launchpad.txt \
 		>"$@"
 
-# This target is used by LOSAs to prepare a build to be pushed out to
-# destination machines.  We only want wheels: they are the expensive bits,
-# and the other bits might run into problems like bug 575037.  This target
-# runs pip, builds a wheelhouse with predictable paths that can be used even
-# if the build is pushed to a different path on the destination machines,
-# and then removes everything created except for the wheels.
-#
 # It doesn't seem to be straightforward to build a wheelhouse of all our
 # dependencies without also building a useless wheel of Launchpad itself;
 # fortunately that doesn't take too long, and we just remove it afterwards.
-.PHONY: build_wheels
-build_wheels: $(PIP_BIN) requirements/combined.txt
+.PHONY: build_wheels_only
+build_wheels_only: $(PIP_BIN) requirements/combined.txt
 	$(RM) -r wheelhouse wheels
+	$(SHHH) $(PIP) wheel -w wheels -r requirements/setup.txt
 	$(SHHH) $(PIP) wheel \
 		-c requirements/setup.txt -c requirements/combined.txt \
 		-w wheels .
 	$(RM) wheels/lp-[0-9]*.whl
-	$(MAKE) clean_pip
+
+# This target is used by deployment machinery to prepare a build to be
+# pushed out to destination machines.  We only want wheels: they are the
+# expensive bits, and the other bits might run into problems like bug
+# 575037.  This target runs pip, builds a wheelhouse with predictable paths
+# that can be used even if the build is pushed to a different path on the
+# destination machines, and then removes everything created except for the
+# wheels.
+.PHONY: build_wheels
+build_wheels: build_wheels_only
+	$(MAKE) clean_js clean_pip
 
 # Compatibility
 .PHONY: build_eggs
 build_eggs: build_wheels
 
+# Build a tarball that can be unpacked and built on another machine,
+# including all the wheels we need.  This will eventually supersede
+# build_wheels.
+.PHONY: build-tarball
+build-tarball:
+	utilities/build-tarball $(TARBALL_BUILD_DIR)
+
+# Publish a buildable tarball to Swift.
+.PHONY: publish-tarball
+publish-tarball: build-tarball
+	[ ! -e ~/.config/swift/launchpad ] || . ~/.config/swift/launchpad; \
+	utilities/publish-to-swift --debug \
+		$(SWIFT_CONTAINER_NAME) $(SWIFT_OBJECT_PATH) \
+		$(TARBALL_BUILD_PATH)
+
 # setuptools won't touch files that would have the same contents, but for
 # Make's sake we need them to get fresh timestamps, so we touch them after
 # building.
diff --git a/ols-vms.conf b/ols-vms.conf
new file mode 100644
index 0000000..cd26bce
--- /dev/null
+++ b/ols-vms.conf
@@ -0,0 +1,11 @@
+# Options defined here provide defaults for all sections
+vm.architecture = amd64
+vm.release = xenial
+
+apt.sources = ppa:launchpad/ppa
+vm.packages = launchpad-dependencies
+
+[launchpad]
+vm.class = lxd
+vm.update = True
+jenkaas.secrets = swift/launchpad:.config/swift/launchpad
diff --git a/utilities/build-tarball b/utilities/build-tarball
new file mode 100755
index 0000000..70acfdb
--- /dev/null
+++ b/utilities/build-tarball
@@ -0,0 +1,39 @@
+#! /bin/sh
+#
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+set -e
+
+if [ -z "$1" ]; then
+    echo "Usage: $0 OUTPUT-DIRECTORY" >&2
+    exit 2
+fi
+output_dir="$1"
+
+# Build wheels to include in the distributed tarball.
+make build_wheels_only
+
+# Ensure that we have an updated idea of this tree's version.
+scripts/update-version-info.sh
+
+echo "Creating deployment tarball in $output_dir"
+
+# Prepare a file list.
+mkdir -p "$output_dir"
+(
+    git ls-files | sed 's,^,./,'
+    # Most of download-cache is unnecessary since we include a wheelhouse
+    # instead, but JavaScript-enabled builds need yarn.
+    find ./download-cache/dist/ -name yarn-\*.tar.gz
+    # The subdirectories of sourcecode may be symlinks.  Force tar to
+    # include the actual files instead.
+    find ./sourcecode/*/ \
+        \( -name .bzr -o -name __pycache__ \) -prune -o -type f -print
+    echo ./version-info.py
+    find ./wheels/ -name \*.whl -print
+) | sort >"$output_dir/.files"
+
+# Create the tarball.
+tar -czf "$output_dir/launchpad.tar.gz" --no-recursion \
+    --files-from "$output_dir/.files"
diff --git a/utilities/publish-to-swift b/utilities/publish-to-swift
new file mode 100755
index 0000000..84de4c1
--- /dev/null
+++ b/utilities/publish-to-swift
@@ -0,0 +1,160 @@
+#! /usr/bin/python3 -S
+#
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Publish a built tarball to Swift for deployment."""
+
+import _pythonpath  # noqa: F401
+
+from argparse import ArgumentParser
+import os
+
+import iso8601
+import requests
+from swiftclient.service import (
+    get_conn,
+    process_options,
+    SwiftService,
+    SwiftUploadObject,
+    )
+from swiftclient.shell import add_default_args
+
+
+def ensure_container_privs(options, container_name):
+    """Ensure that the container exists and is world-readable.
+
+    This allows us to give services suitable credentials for getting the
+    built code from a container.
+    """
+    options = dict(options)
+    options["read_acl"] = ".r:*"
+    with SwiftService(options=options) as swift:
+        swift.post(container=container_name)
+
+
+def get_swift_storage_url(options):
+    """Return the storage URL under which Swift objects are published."""
+    return get_conn(options).get_auth()[0]
+
+
+def publish_file_to_swift(options, container_name, object_path, local_path,
+                          overwrite=True):
+    """Publish a file to a Swift container."""
+    storage_url = get_swift_storage_url(options)
+
+    with SwiftService(options=options) as swift:
+        stat_results = swift.stat(
+            container=container_name, objects=[object_path])
+        if stat_results and next(stat_results)["success"]:
+            print("Object {} already published to {}.".format(
+                object_path, container_name))
+            if not overwrite:
+                return
+
+        print("Publishing {} to {} as {}.".format(
+            local_path, container_name, object_path))
+        for r in swift.upload(
+                container_name,
+                [SwiftUploadObject(local_path, object_name=object_path)]):
+            if not r["success"]:
+                raise r["error"]
+
+        print("Published file: {}/{}/{}".format(
+            storage_url, container_name, object_path))
+
+
+def prune_old_files_from_swift(options, container_name, object_dir):
+    """Prune files from Swift that we no longer need."""
+    response = requests.head("https://launchpad.net/";)
+    response.raise_for_status()
+    production_revision = response.headers["X-VCS-Revision"]
+
+    with SwiftService(options=options) as swift:
+        objs = {}
+        production_mtime = None
+        for stats in swift.list(
+                container=container_name,
+                options={"prefix": "{}/".format(object_dir)}):
+            if not stats["success"]:
+                raise stats["error"]
+            for item in stats["listing"]:
+                if item.get("subdir") is None:
+                    mtime = iso8601.parse_date(item["last_modified"])
+                    objs[item["name"]] = mtime
+                    if item["name"].startswith(
+                            "{}/{}/".format(object_dir, production_revision)):
+                        production_mtime = mtime
+
+        if production_mtime is None:
+            print(
+                "No file in {} corresponding to production revision {}; "
+                "not pruning.".format(container_name, production_revision))
+            return
+
+        for object_name, mtime in sorted(objs.items()):
+            if mtime < production_mtime:
+                print("Pruning {} (older than production)".format(object_name))
+                for r in swift.delete(
+                        container=container_name, objects=[object_name]):
+                    if not r["success"]:
+                        raise r["error"]
+
+
+def main():
+    parser = ArgumentParser()
+    parser.add_argument("container_name")
+    parser.add_argument("swift_object_path")
+    parser.add_argument("local_path")
+    add_default_args(parser)
+    args = parser.parse_args()
+
+    if args.debug:
+        # Print OpenStack-related environment variables for ease of
+        # debugging.  Only OS_AUTH_TOKEN and OS_PASSWORD currently seem to
+        # be secret, but for safety we only show unredacted contents of
+        # variables specifically known to be safe.  See "swift --os-help"
+        # for most of these.
+        safe_keys = {
+            "OS_AUTH_URL",
+            "OS_AUTH_VERSION",
+            "OS_CACERT",
+            "OS_CERT",
+            "OS_ENDPOINT_TYPE",
+            "OS_IDENTITY_API_VERSION",
+            "OS_INTERFACE",
+            "OS_KEY",
+            "OS_PROJECT_DOMAIN_ID",
+            "OS_PROJECT_DOMAIN_NAME",
+            "OS_PROJECT_ID",
+            "OS_PROJECT_NAME",
+            "OS_REGION_NAME",
+            "OS_SERVICE_TYPE",
+            "OS_STORAGE_URL",
+            "OS_TENANT_ID",
+            "OS_TENANT_NAME",
+            "OS_USERNAME",
+            "OS_USER_DOMAIN_ID",
+            "OS_USER_DOMAIN_NAME",
+            "OS_USER_ID",
+            }
+        for key, value in sorted(os.environ.items()):
+            if key.startswith("OS_"):
+                if key not in safe_keys:
+                    value = "<redacted>"
+                print("{}: {}".format(key, value))
+
+    options = vars(args)
+    process_options(options)
+
+    overwrite = "FORCE_REBUILD" in os.environ
+    ensure_container_privs(options, args.container_name)
+    publish_file_to_swift(
+        options, args.container_name, args.swift_object_path, args.local_path,
+        overwrite=overwrite)
+    prune_old_files_from_swift(
+        options, args.container_name, args.swift_object_path.split("/")[0])
+
+
+if __name__ == "__main__":
+    main()