diff --git a/doc/manual/configuration.rst b/doc/manual/configuration.rst index 4ffa31ee..2e8da936 100644 --- a/doc/manual/configuration.rst +++ b/doc/manual/configuration.rst @@ -2602,6 +2602,14 @@ file Use a local directory as binary artifact repository. The directory directory. The optional ``fileMode`` and ``directoryMode`` keys take the desired access modes as numeric value to override the default umask derived modes. +gitea Uses a `Gitea generic package registry`_ as binary artifact + repository. The base server URL is given in ``url``, the registry + owner (user or organization) in ``owner`` and the generic package + name in ``package``. Credentials are given explicitly in the + ``token`` key (a personal access token) or as ``user``/``password`` + for HTTP basic authentication. The optional ``sslVerify`` boolean + key controls whether to verify the SSL certificate and ``retries`` + sets the number of retries on transient errors. http Uses a HTTP server as binary artifact repository. The server has to support the HEAD, PUT and GET methods. The base URL is given in the ``url`` key. The optional ``sslVerify`` boolean key controls @@ -2680,6 +2688,37 @@ the anonymous access to the container can be used like this:: The ``flags: [download]`` makes sure that Bob does not try to upload artifacts in case other backends are configured too. +The ``gitea`` backend stores the artifacts in a Gitea generic package registry. +In contrast to the ``http`` backend the credentials are *not* part of the URL +but are configured explicitly. They therefore become part of the archive +specification and are available even for uploads that run inside the build +sandbox:: + + archive: + - + backend: gitea + url: "https://gitea.example.com" + owner: "bob-artifacts" + package: "myproject" + token: "" + flags: [download, upload] + +Instead of a ``token`` a ``user``/``password`` pair may be given for HTTP basic +authentication. The artifacts are stored below +``{url}/api/packages/{owner}/generic/{package}/`` with one package version per +artifact. + +.. warning:: + The token/password will be part of the Jenkins job configuration. Anybody + who can read the jobs ``config.xml`` will be able to retrieve it. Keep the + credentials in the user configuration (e.g. a git ignored ``user.yaml``). + +.. note:: + The ``gitea`` backend does not implement the managed operations of + :ref:`manpage-archive` (scan/clean) yet. + +.. _Gitea generic package registry: https://docs.gitea.com/usage/packages/generic + .. _configuration-config-archive-prepend-append: archive{Prepend,Append} diff --git a/pym/bob/archive.py b/pym/bob/archive.py index 94a24e11..536c3f44 100644 --- a/pym/bob/archive.py +++ b/pym/bob/archive.py @@ -19,18 +19,22 @@ concurrent uploads the artifact must appear atomically for unrelated readers. """ +from . import BOB_VERSION from .audit import Audit from .errors import BuildError, BobError from .tty import stepAction, stepMessage, \ SKIPPED, EXECUTED, WARNING, INFO, TRACE, ERROR, IMPORTANT -from .utils import asHexStr, removePath, isWindows, getBashPath, tarfileOpen, binStat +from .utils import asHexStr, removePath, isWindows, getBashPath, tarfileOpen, \ + binStat, sslNoVerifyContext from .webdav import WebDav, WebdavError, WebdavNotFoundError, WebdavAlreadyExistsError from tempfile import mkstemp, NamedTemporaryFile, TemporaryFile, gettempdir import asyncio +import base64 import concurrent.futures import concurrent.futures.process import errno import gzip +import http.client import io import os import os.path @@ -39,7 +43,9 @@ import struct import subprocess import tarfile +import urllib.error import urllib.parse +import urllib.request ARCHIVE_GENERATION = '-1' ARTIFACT_SUFFIX = ".tgz" @@ -1211,6 +1217,236 @@ def __upload(self): raise ArtifactError(str(e)) +class GiteaArchive(BaseArchive): + """Bob artifact backend for the Gitea 'generic' package registry. + + Artifacts are stored using the generic package API: + + {url}/api/packages/{owner}/generic/{package}/{version}/{filename} + + with ``version`` and ``filename`` both derived from the build-id. All + artifact types belonging to the same key (``.tgz``, ``.buildid``, + ``.fprnt``) share a single package version. + + Credentials are taken from the archive spec (``token`` or + ``user``/``password``) - never from the URL or ``~/.netrc``. This is + intentional: the credential becomes part of the (picklable) archive object + and is therefore available to the upload executor even inside the build + sandbox, where a ``~/.netrc`` would not be accessible. + """ + + def __init__(self, spec): + super().__init__(spec) + self.__url = urllib.parse.urlparse(spec["url"]) + self.__owner = spec["owner"] + self.__package = spec["package"] + self.__sslVerify = spec.get("sslVerify", True) + self.__retries = spec.get("retries", 1) + + token = spec.get("token") + if token: + self.__authHeader = "token " + token + elif spec.get("user") is not None: + userPass = spec["user"] + ":" + spec.get("password", "") + self.__authHeader = "Basic " + base64.b64encode( + userPass.encode("utf-8")).decode("ascii") + else: + self.__authHeader = None + + def getArchiveName(self): + name = super().getArchiveName() + if name: + return name + basePath = "/".join([self.__url.path.rstrip("/"), "api", "packages", + self.__owner, "generic", self.__package]) + return urllib.parse.urlunparse((self.__url.scheme, self.__url.netloc, + basePath, '', '', '')) + + def _canManage(self): + # Managed operations (scan/clean) would require the Gitea package list + # API. Not implemented yet. + return False + + def __makePath(self, buildId, suffix): + packageResultId = buildIdToName(buildId) + version = packageResultId + filename = packageResultId + suffix + return "/".join([self.__url.path.rstrip("/"), "api", "packages", + self.__owner, "generic", self.__package, version, filename]) + + def _remoteName(self, buildId, suffix): + return urllib.parse.urlunparse((self.__url.scheme, self.__url.netloc, + self.__makePath(buildId, suffix), '', '', '')) + + def __context(self): + return None if self.__sslVerify else sslNoVerifyContext() + + def __headers(self, extra=None): + headers = {'User-Agent': 'BobBuildTool/{}'.format(BOB_VERSION)} + if self.__authHeader is not None: + headers['Authorization'] = self.__authHeader + if extra: + headers.update(extra) + return headers + + def __authError(self, code): + return ArtifactError("authentication failed (HTTP {}). Check that the " + "'token' (or 'user'/'password') of the gitea archive is valid and " + "has write access (personal access token scope 'write:package')." + .format(code)) + + def __retry(self, request): + retries = self.__retries + while True: + try: + return request() + except (http.client.HTTPException, urllib.error.URLError) as e: + # urllib.error.HTTPError (a URLError subclass) is handled and + # translated by the request functions themselves and never + # reaches this point. Everything left here is a transient + # transport error that may be retried. + if retries == 0: + raise ArtifactError(str(e)) + retries -= 1 + + def _openDownloadFile(self, buildId, suffix): + url = self._remoteName(buildId, suffix) + return self.__retry(lambda: self.__openDownloadFile(url)) + + def __openDownloadFile(self, url): + req = urllib.request.Request(url, headers=self.__headers(), method="GET") + try: + rsp = urllib.request.urlopen(req, context=self.__context()) + except urllib.error.HTTPError as e: + e.fp.read() + if e.code == 404: + raise ArtifactNotFoundError() + if e.code >= 500: + raise # transient server error -> retryable + raise ArtifactError("GET {} {}".format(e.code, e.reason)) + return GiteaDownloader(rsp) + + def __exists(self, url): + # Cheap existence/authentication probe before uploading a possibly large + # artifact. Only the first byte is requested. This surfaces an + # authentication problem as a clean 401 instead of an opaque connection + # reset that would otherwise occur when the server drops the connection + # while we are still sending the body. + req = urllib.request.Request(url, + headers=self.__headers({'Range': 'bytes=0-0'}), method="GET") + try: + with urllib.request.urlopen(req, context=self.__context()): + return True + except urllib.error.HTTPError as e: + e.fp.read() + if e.code == 404: + return False + if e.code == 416: + # "Range Not Satisfiable" -> the (empty) file does exist. + return True + if e.code in (401, 403): + raise self.__authError(e.code) + if e.code >= 500: + raise # transient server error -> retryable + raise ArtifactError("GET {} {}".format(e.code, e.reason)) + + def _openUploadFile(self, buildId, suffix, overwrite): + url = self._remoteName(buildId, suffix) + if overwrite: + # Gitea's generic registry refuses to overwrite an existing file. + # Metadata files (build-id, fingerprint) must be replaced, so delete + # a possibly existing file first (a missing one is ignored). + self.__retry(lambda: self.__delete(url)) + elif self.__retry(lambda: self.__exists(url)): + raise ArtifactExistsError() + return GiteaUploader(self, url, overwrite) + + def _putUploadFile(self, url, tmp, overwrite): + try: + return self.__retry(lambda: self.__putUploadFile(url, tmp, overwrite)) + except ArtifactExistsError: + raise + except ArtifactError as e: + msg = str(e) + if any(s in msg for s in ("EOF", "violation of protocol", + "reset", "Broken pipe", "closed")): + raise ArtifactError(msg + " -- the server closed the connection " + "during upload. This usually means a reverse-proxy body size " + "limit (e.g. nginx 'client_max_body_size') or an " + "authentication problem.") + raise + + def __putUploadFile(self, url, tmp, overwrite): + tmp.seek(0, os.SEEK_END) + length = str(tmp.tell()) + tmp.seek(0) + headers = self.__headers({ + 'Content-Length': length, + 'Content-Type': 'application/octet-stream', + }) + req = urllib.request.Request(url, data=tmp, headers=headers, method="PUT") + try: + with urllib.request.urlopen(req, context=self.__context()) as resp: + if resp.status not in (200, 201, 202, 204): + raise ArtifactError("PUT {} {}".format(resp.status, resp.reason)) + except urllib.error.HTTPError as e: + e.fp.read() + if e.code == 409: + # File already exists in the registry. + raise ArtifactExistsError() + if e.code in (401, 403): + raise self.__authError(e.code) + if e.code >= 500: + raise # transient server error -> retryable + raise ArtifactError("PUT {} {}".format(e.code, e.reason)) + + def __delete(self, url): + req = urllib.request.Request(url, headers=self.__headers(), method="DELETE") + try: + with urllib.request.urlopen(req, context=self.__context()): + pass + except urllib.error.HTTPError as e: + e.fp.read() + if e.code == 404: + return + if e.code in (401, 403): + raise self.__authError(e.code) + if e.code >= 500: + raise # transient server error -> retryable + raise ArtifactError("DELETE {} {}".format(e.code, e.reason)) + + def getArchiveUri(self): + return self.__url.netloc + self.__url.path + + +class GiteaDownloader: + def __init__(self, response): + self.response = response + def __enter__(self): + return (None, self.response) + def __exit__(self, exc_type, exc_value, traceback): + self.response.close() + return False + + +class GiteaUploader: + def __init__(self, archiver, url, overwrite): + self.archiver = archiver + self.tmp = TemporaryFile() + self.url = url + self.overwrite = overwrite + def __enter__(self): + return (None, self.tmp) + def __exit__(self, exc_type, exc_value, traceback): + try: + # do actual upload on regular handle close + if exc_type is None: + self.archiver._putUploadFile(self.url, self.tmp, self.overwrite) + finally: + self.tmp.close() + return False + + class MultiArchive: def __init__(self, archives): self.__archives = archives @@ -1283,6 +1519,8 @@ def getSingleArchiver(recipes, archiveSpec): return CustomArchive(archiveSpec, recipes.envWhiteList()) elif archiveBackend == "azure": return AzureArchive(archiveSpec) + elif archiveBackend == "gitea": + return GiteaArchive(archiveSpec) elif archiveBackend == "none": return DummyArchive() elif archiveBackend == "__jenkins": diff --git a/pym/bob/input.py b/pym/bob/input.py index 2830f377..5c5336f4 100644 --- a/pym/bob/input.py +++ b/pym/bob/input.py @@ -3171,7 +3171,7 @@ def validate(self, data): class ArchiveValidator: def __init__(self): - self.__validTypes = schema.Schema({'backend': schema.Or('none', 'file', 'http', 'shell', 'azure')}, + self.__validTypes = schema.Schema({'backend': schema.Or('none', 'file', 'http', 'shell', 'azure', 'gitea')}, ignore_extra_keys=True) baseArchive = { 'backend' : str, @@ -3199,12 +3199,24 @@ def __init__(self): schema.Optional('key') : str, schema.Optional('sasToken"') : str, }) + giteaArchive = baseArchive.copy() + giteaArchive.update({ + 'url' : HttpUrlValidator(), + 'owner' : str, + 'package' : str, + schema.Optional('token') : str, + schema.Optional('user') : str, + schema.Optional('password') : str, + schema.Optional('sslVerify') : bool, + schema.Optional('retries') : PositiveValidator(), + }) self.__backends = { 'none' : schema.Schema(baseArchive), 'file' : schema.Schema(fileArchive), 'http' : schema.Schema(httpArchive), 'shell' : schema.Schema(shellArchive), 'azure' : schema.Schema(azureArchive), + 'gitea' : schema.Schema(giteaArchive), } def validate(self, data): diff --git a/test/unit/test_archive.py b/test/unit/test_archive.py index 06c9e8d7..96954027 100644 --- a/test/unit/test_archive.py +++ b/test/unit/test_archive.py @@ -4,7 +4,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later from binascii import hexlify -from tempfile import NamedTemporaryFile, TemporaryDirectory +from tempfile import NamedTemporaryFile, TemporaryDirectory, TemporaryFile from unittest import TestCase, skipIf from unittest.mock import patch import asyncio @@ -22,7 +22,8 @@ import sys from mocks.http_server import HttpServerMock -from bob.archive import DummyArchive, HttpArchive, getArchiver +from bob.archive import DummyArchive, HttpArchive, GiteaArchive, getArchiver, \ + ArtifactError, ArtifactExistsError, ArtifactNotFoundError from bob.errors import BuildError from bob.utils import runInEventLoop, getProcessPoolExecutor from bob.webdav import WebdavError @@ -791,3 +792,403 @@ def testRetriesAudit(self): archive = self._getHttpArchiveInstance(srv.port) with self.assertRaises(WebdavError): archive._getAudit(filepath) + + +def createGiteaHandler(repoPath, args, expectedAuth=None): + """Mock of the Gitea 'generic' package registry. + + Requests use the generic package layout + + /api/packages//generic/// + + which this handler translates to the on-disk layout used by the + BaseTester helpers (``///-1``) so + that all the shared upload/download assertions apply unchanged. + + If ``expectedAuth`` is given it is the full ``Authorization`` header value + (e.g. ``"token ..."`` or ``"Basic ..."``) that the client must send; + anything else is answered with ``401``. + """ + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _authOk(self): + if expectedAuth is None: + return True + if self.headers.get('Authorization') == expectedAuth: + return True + self.send_response(401, "Unauthorized") + self.end_headers() + return False + + def _diskPath(self): + fname = self.path.rsplit("/", 1)[-1] + for suffix in (".tgz", ".buildid", ".fprnt"): + if fname.endswith(suffix): + break + else: + return None + stem = fname[:-len(suffix)] # -1 + ident = stem[:-len("-1")] # (strip archive generation) + return os.path.join(repoPath, ident[0:2], ident[2:4], + stem[4:] + suffix) + + def _maybeFail(self): + if args.get("retries", 0) > 0: + args["retries"] -= 1 + self.send_error(500, "flaky") + return True + return False + + def do_GET(self): + if not self._authOk(): return + if self._maybeFail(): return + path = self._diskPath() + try: + with open(path, "rb") as f: + data = f.read() + except FileNotFoundError: + self.send_error(404, "not found"); return + except OSError: + self.send_error(500, "internal error"); return + self.send_response(200) + self.send_header("Content-type", "application/octet-stream") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_PUT(self): + length = int(self.headers.get('Content-Length', 0)) + content = self.rfile.read(length) + if not self._authOk(): return + if self._maybeFail(): return + path = self._diskPath() + # The generic registry refuses to overwrite an existing file. + if os.path.exists(path): + self.send_response(409); self.end_headers(); return + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(content) + except OSError: + self.send_error(500, "internal error"); return + self.send_response(201); self.end_headers() + + def do_DELETE(self): + if not self._authOk(): return + if self._maybeFail(): return + path = self._diskPath() + if path and os.path.exists(path): + os.unlink(path) + self.send_response(204) + else: + self.send_response(404) + self.end_headers() + + return Handler + + +class TestGiteaArchive(BaseTester, TestCase): + + def setUp(self): + super().setUp() + self.args = {"retries": 0} + self.httpd = socketserver.ThreadingTCPServer(("localhost", 0), + createGiteaHandler(self.repo.name, self.args)) + self.ip, self.port = self.httpd.server_address + self.server = threading.Thread(target=self.httpd.serve_forever) + self.server.daemon = True + self.server.start() + + def tearDown(self): + self.httpd.shutdown() + self.httpd.server_close() + super().tearDown() + + def _setArchiveSpec(self, spec): + spec['name'] = "gitea" + spec['backend'] = "gitea" + spec["url"] = "http://{}:{}".format(self.ip, self.port) + spec["owner"] = "bob-artifacts" + spec["package"] = "test" + + def testRemoteName(self): + """The build-id maps to the generic package layout.""" + a = GiteaArchive({"backend":"gitea", "url":"https://gitea.example", + "owner":"o", "package":"p"}) + bid = bytes.fromhex("00112233445566778899aabbccddeeff00112233") + self.assertEqual(a._remoteName(bid, ".tgz"), + "https://gitea.example/api/packages/o/generic/p/" + "00112233445566778899aabbccddeeff00112233-1/" + "00112233445566778899aabbccddeeff00112233-1.tgz") + + +class TestGiteaTokenArchive(BaseTester, TestCase): + """Same as above but the mock server requires token authentication.""" + + TOKEN = "s3cr3t-token" + + def setUp(self): + super().setUp() + self.args = {"retries": 0} + self.httpd = socketserver.ThreadingTCPServer(("localhost", 0), + createGiteaHandler(self.repo.name, self.args, "token " + self.TOKEN)) + self.ip, self.port = self.httpd.server_address + self.server = threading.Thread(target=self.httpd.serve_forever) + self.server.daemon = True + self.server.start() + + def tearDown(self): + self.httpd.shutdown() + self.httpd.server_close() + super().tearDown() + + def _setArchiveSpec(self, spec): + spec['backend'] = "gitea" + spec["url"] = "http://{}:{}".format(self.ip, self.port) + spec["owner"] = "o" + spec["package"] = "p" + spec["token"] = self.TOKEN + + def testWrongTokenClearError(self): + """A wrong token must yield a clear authentication error instead of an + opaque connection reset while sending the artifact body.""" + spec = {"backend":"gitea", "owner":"o", "package":"p", + "url":"http://{}:{}".format(self.ip, self.port), + "token":"wrong-token"} + archive = GiteaArchive(spec) + archive.wantUploadLocal(True) + with TemporaryDirectory() as tmp: + audit = os.path.join(tmp, "audit.json.gz") + content = os.path.join(tmp, "workspace") + with open(audit, "wb") as f: + f.write(b"AUDIT") + os.mkdir(content) + with open(os.path.join(content, "data"), "wb") as f: + f.write(b"DATA") + with self.assertRaises(BuildError) as cm: + run(archive.uploadPackage(DummyStep(), UPLOAD1_ARTIFACT, audit, + content, executor=self.executor)) + self.assertIn("authentication", str(cm.exception).lower()) + + +class TestGiteaArchiveRetries(Base, TestCase): + + def setUp(self): + super().setUp() + self.VALID_FILE = self._createArtifact(VALID_ARTIFACT, valid_data=True) + + def _getArchive(self, port, retries): + spec = {'backend':'gitea', 'name':'gitea', 'owner':'o', 'package':'p', + 'retries':retries, 'url':"http://localhost:{}".format(port)} + return getArchiver(DummyRecipeSet(spec)) + + def _startServer(self, retries): + args = {"retries": retries} + httpd = socketserver.ThreadingTCPServer(("localhost", 0), + createGiteaHandler(self.repo.name, args)) + thread = threading.Thread(target=httpd.serve_forever) + thread.daemon = True + thread.start() + return httpd, httpd.server_address[1] + + def _download(self, archive): + archive.wantDownloadLocal(True) + with TemporaryDirectory() as tmp: + audit = os.path.join(tmp, "audit.json.gz") + content = os.path.join(tmp, "workspace") + return run(archive.downloadPackage(DummyStep(), VALID_ARTIFACT, + audit, content, executor=self.executor)) + + def _testRetries(self, r): + # server fails exactly 'r' times -> download succeeds within retries + httpd, port = self._startServer(r) + try: + self.assertTrue(self._download(self._getArchive(port, r))) + finally: + httpd.shutdown(); httpd.server_close() + + # server fails one more time than retries -> download fails (no throw) + httpd, port = self._startServer(r + 1) + try: + self.assertFalse(self._download(self._getArchive(port, r))) + finally: + httpd.shutdown(); httpd.server_close() + + def testRetriesWithNoRetries(self): + self._testRetries(0) + + def testRetriesWithOneRetry(self): + self._testRetries(1) + + def testRetriesWithMultipleRetries(self): + self._testRetries(3) + + +class TestGiteaBasicAuthArchive(BaseTester, TestCase): + """Same as TestGiteaArchive but the mock server requires HTTP basic + authentication configured via ``user``/``password``.""" + + USER = "alice" + PASSWORD = "s3cr3t-pass" + + def setUp(self): + super().setUp() + self.args = {"retries": 0} + expectedAuth = "Basic " + base64.b64encode( + (self.USER + ":" + self.PASSWORD).encode("utf-8")).decode("ascii") + self.httpd = socketserver.ThreadingTCPServer(("localhost", 0), + createGiteaHandler(self.repo.name, self.args, expectedAuth)) + self.ip, self.port = self.httpd.server_address + self.server = threading.Thread(target=self.httpd.serve_forever) + self.server.daemon = True + self.server.start() + + def tearDown(self): + self.httpd.shutdown() + self.httpd.server_close() + super().tearDown() + + def _setArchiveSpec(self, spec): + spec['backend'] = "gitea" + spec["url"] = "http://{}:{}".format(self.ip, self.port) + spec["owner"] = "o" + spec["package"] = "p" + spec["user"] = self.USER + spec["password"] = self.PASSWORD + + +def createGiteaStatusHandler(responses): + """Mock Gitea registry that answers each HTTP method with a scripted result. + + ``responses`` maps the HTTP method ("GET"/"PUT"/"DELETE") to either + ``("status", code)`` to return that status code or ``("close",)`` to read + the request and then drop the connection without any response (simulating a + reverse-proxy or server that closes mid-upload). An unlisted method yields + ``404``. + """ + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _handle(self): + length = int(self.headers.get('Content-Length', 0) or 0) + if length: + self.rfile.read(length) + action = responses.get(self.command, ("status", 404)) + if action[0] == "close": + self.close_connection = True + self.wfile.close() + return + self.send_response(action[1]) + self.end_headers() + + do_GET = _handle + do_PUT = _handle + do_DELETE = _handle + + return Handler + + +class TestGiteaArchiveErrors(Base, TestCase): + """Directly exercise the individual error-handling branches of the + GiteaArchive HTTP methods using a mock server with scripted responses.""" + + BUILD_ID = bytes.fromhex("00112233445566778899aabbccddeeff00112233") + + def _archive(self, responses): + self.responses = responses + self.httpd = socketserver.ThreadingTCPServer(("localhost", 0), + createGiteaStatusHandler(responses)) + self.addCleanup(self.httpd.server_close) + self.addCleanup(self.httpd.shutdown) + thread = threading.Thread(target=self.httpd.serve_forever) + thread.daemon = True + thread.start() + port = self.httpd.server_address[1] + return GiteaArchive({"backend": "gitea", "owner": "o", "package": "p", + "retries": 0, "url": "http://localhost:{}".format(port)}) + + def _url(self, archive): + return archive._remoteName(self.BUILD_ID, ".tgz") + + def _upload(self, archive): + url = self._url(archive) + with TemporaryFile() as tmp: + tmp.write(b"DATA") + archive._putUploadFile(url, tmp, False) + + def testCanManageAndUri(self): + archive = self._archive({}) + self.assertFalse(archive._canManage()) + self.assertIn("localhost", archive.getArchiveUri()) + + def testDownloadHttpError(self): + # GET with a non-404, non-5xx error -> generic ArtifactError + archive = self._archive({"GET": ("status", 403)}) + with self.assertRaises(ArtifactError): + archive._openDownloadFile(self.BUILD_ID, ".tgz") + + def testExistsRangeNotSatisfiable(self): + # preflight GET 416 means the (empty) file exists -> ArtifactExistsError + archive = self._archive({"GET": ("status", 416)}) + with self.assertRaises(ArtifactExistsError): + archive._openUploadFile(self.BUILD_ID, ".tgz", False) + + def testExistsHttpError(self): + # preflight GET with an unexpected 4xx -> generic ArtifactError + archive = self._archive({"GET": ("status", 400)}) + with self.assertRaises(ArtifactError): + archive._openUploadFile(self.BUILD_ID, ".tgz", False) + + def testUploadConflict(self): + # preflight says "missing" (404) but the PUT hits a 409 race + archive = self._archive({"GET": ("status", 404), "PUT": ("status", 409)}) + with self.assertRaises(ArtifactExistsError): + self._upload(archive) + + def testUploadAuthError(self): + archive = self._archive({"GET": ("status", 404), "PUT": ("status", 401)}) + with self.assertRaises(ArtifactError) as cm: + self._upload(archive) + self.assertIn("authentication", str(cm.exception).lower()) + + def testUploadHttpError(self): + archive = self._archive({"GET": ("status", 404), "PUT": ("status", 400)}) + with self.assertRaises(ArtifactError): + self._upload(archive) + + def testUploadUnexpectedStatus(self): + # a 2xx status that is not one of the accepted success codes + archive = self._archive({"GET": ("status", 404), "PUT": ("status", 205)}) + with self.assertRaises(ArtifactError): + self._upload(archive) + + def testUploadConnectionClosed(self): + # server drops the connection during upload -> the error is enriched + # with the reverse-proxy/auth hint + archive = self._archive({"GET": ("status", 404), "PUT": ("close",)}) + with self.assertRaises(ArtifactError) as cm: + self._upload(archive) + self.assertIn("server closed the connection", str(cm.exception)) + + def testOverwriteDeleteAuthError(self): + # overwrite deletes first; a 401 there is a clear auth error + archive = self._archive({"DELETE": ("status", 401)}) + with self.assertRaises(ArtifactError) as cm: + archive._openUploadFile(self.BUILD_ID, ".tgz", True) + self.assertIn("authentication", str(cm.exception).lower()) + + def testOverwriteDeleteServerError(self): + # a 5xx during delete is retryable and, with retries exhausted, fatal + archive = self._archive({"DELETE": ("status", 500)}) + with self.assertRaises(ArtifactError): + archive._openUploadFile(self.BUILD_ID, ".tgz", True) + + def testOverwriteDeleteHttpError(self): + archive = self._archive({"DELETE": ("status", 400)}) + with self.assertRaises(ArtifactError): + archive._openUploadFile(self.BUILD_ID, ".tgz", True)