packages feed

mariadb-haskell-0.1.0.0: validation/auth_fixture.py

#!/usr/bin/env python3
"""Manage a disposable external-authentication fixture and private client files.

The normal Mac workflow uses a container. Linux CI runs the same services inside
its already disposable container. Neither mode changes host PAM/realm settings.
"""
from contextlib import contextmanager
import json
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import time
import uuid

HERE = Path(__file__).resolve().parent
IMAGE = 'mariadb-haskell-stage7-auth'



def runtime_identity(image):
    """Hash runtime files/configuration, excluding changing provenance indexes."""
    config_keys = ('Env', 'Entrypoint', 'Cmd', 'WorkingDir', 'User', 'ExposedPorts',
                   'Volumes', 'Labels', 'StopSignal', 'Healthcheck', 'OnBuild',
                   'Shell', 'ArgsEscaped')
    fields = {key: image.get(key) for key in ('Architecture', 'Os', 'Variant', 'RootFS')}
    fields['Config'] = {key: image.get('Config', {}).get(key) for key in config_keys}
    # OCI omits the default working directory; Docker inspect spells it ''.
    fields['Config']['WorkingDir'] = fields['Config']['WorkingDir'] or ''
    return 'docker-runtime-sha256:'+hashlib.sha256(json.dumps(fields,
        sort_keys=True, separators=(',', ':')).encode()).hexdigest()


def command(args, **kwargs):
    return subprocess.run(list(map(str, args)), check=True, **kwargs)


def cleanup_container(owner):
    """Remove only a container bearing this invocation's unique ownership label.

    Docker can create a container before run reports a startup error. Probe the
    registered name even on that failure path; a name collision is not ownership.
    Diagnostic failures must never prevent the removal attempt.
    """
    inspected = subprocess.run(['docker', 'inspect', '--format',
        '{{ index .Config.Labels "mariadb-haskell.fixture-owner" }}', owner],
        capture_output=True, text=True, check=False, timeout=15)
    if inspected.returncode or inspected.stdout.strip() != owner:
        return
    try:
        subprocess.run(['docker', 'logs', owner], check=False, timeout=15)
    except (OSError, subprocess.SubprocessError):
        pass
    finally:
        subprocess.run(['docker', 'rm', '-f', owner], check=True,
                       stdout=subprocess.DEVNULL, timeout=30)


@contextmanager
def environment(*, local=False):
    """Yield fixture-specific environment and clean up only owned resources.

    The caller must keep this scope alive throughout validation. Credentials,
    caches and CA files are private temporary files, never report artifacts.
    """
    (HERE/'.local').mkdir(exist_ok=True)
    with tempfile.TemporaryDirectory(prefix='external-auth-', dir=HERE/'.local') as directory:
        private = Path(directory)
        owner = 'mariadb-haskell-auth-'+uuid.uuid4().hex[:12]
        process = None
        container = None
        fixture_root = Path('/fixture')
        try:
            if local:
                # This branch is for the disposable Linux CI image only.
                if not Path('/opt/driver-auth/disposable-fixture').is_file() or not Path('/opt/driver-auth/pam_fixture.so').is_file():
                    raise RuntimeError('local authentication fixtures require the disposable Linux CI image')
                fixture_root = private/'services'
                process = subprocess.Popen(['python3', str(HERE/'auth_fixture/serve.py')],
                    env={**os.environ, 'MB_AUTH_FIXTURE_ROOT': str(fixture_root),
                         'MB_AUTH_FIXTURE_PORT': '3307'})
                port, kdc_port = 3307, 10088
            else:
                command(['docker', 'build', '-t', IMAGE, '-f', HERE/'auth_fixture/Dockerfile', HERE/'auth_fixture'])
                # Register before the fallible creation call: the daemon may
                # have created the resource when its client fails or is killed.
                container = owner
                command(['docker', 'run', '-d', '--name', owner,
                    '--label', 'mariadb-haskell.fixture=external-authentication',
                    '--label', 'mariadb-haskell.fixture-owner='+owner,
                    '--tmpfs', '/fixture:rw,mode=0700,size=536870912',
                    '-p', '127.0.0.1::3306', '-p', '127.0.0.1::10088', IMAGE],
                    stdout=subprocess.DEVNULL)
                def published_port(number):
                    value = subprocess.check_output(['docker', 'port', owner, f'{number}/tcp'], text=True).strip()
                    if not value.startswith('127.0.0.1:'):
                        raise RuntimeError('authentication fixture must be bound to loopback')
                    return int(value.rsplit(':', 1)[1])
                port, kdc_port = published_port(3306), published_port(10088)
            for _ in range(160):
                if local:
                    ready = (fixture_root/'ready.json').is_file()
                    alive = process.poll() is None
                else:
                    ready = subprocess.run(['docker', 'exec', owner, 'test', '-f', '/fixture/ready.json'],
                                           capture_output=True).returncode == 0
                    alive = subprocess.check_output(['docker', 'inspect', '-f', '{{.State.Running}}', owner], text=True).strip() == 'true'
                if ready:
                    break
                if not alive:
                    raise RuntimeError('external authentication fixture stopped before readiness')
                time.sleep(0.25)
            else:
                raise RuntimeError('external authentication fixture readiness deadline exceeded')
            for filename in ['ready.json', 'tls-cert.pem', 'driver.ccache', 'other.ccache']:
                if local:
                    shutil.copyfile(fixture_root/filename, private/filename)
                else:
                    # Docker's archive endpoint can omit tmpfs mount contents.
                    # Read through the owned container process instead; ticket
                    # bytes go directly into private files, never tool output.
                    (private/filename).write_bytes(subprocess.check_output(
                        ['docker', 'exec', owner, 'cat', '/fixture/'+filename]))
                (private/filename).chmod(0o600)
            metadata = json.loads((private/'ready.json').read_text())
            realm = metadata['realm']
            (private/'krb5.conf').write_text(f'''[libdefaults]
 default_realm = {realm}
 dns_lookup_kdc = false
 dns_lookup_realm = false
 rdns = false
 udp_preference_limit = 1
[realms]
 {realm} = {{
  kdc = 127.0.0.1:{kdc_port}
 }}
''')
            if local:
                identity = 'local-disposable-linux-profile'
            else:
                image_id = subprocess.check_output(['docker', 'inspect', '-f', '{{.Image}}', container], text=True).strip()
                image = json.loads(subprocess.check_output(['docker', 'image', 'inspect', image_id], text=True))[0]
                identity = runtime_identity(image)
            yield {'MB_AUTH_FIXTURE_IMAGE_ID': identity,
                'MB_AUTH_HOST': '127.0.0.1', 'MB_AUTH_PORT': str(port),
                'MB_AUTH_TLS_CA': str(private/'tls-cert.pem'),
                'MB_AUTH_PAM_PASSWORD': metadata['password'], 'MB_AUTH_PAM_CODE': metadata['code'],
                'MB_AUTH_GSSAPI_PRINCIPAL': metadata['principal'],
                'MB_AUTH_GSSAPI_OTHER_CACHE': 'FILE:'+str(private/'other.ccache'),
                'KRB5CCNAME': 'FILE:'+str(private/'driver.ccache'),
                'KRB5_CONFIG': str(private/'krb5.conf')}
        finally:
            if container:
                # Exact generated name only. Never prune unrelated containers,
                # images, volumes, builder caches or the developer's databases.
                cleanup_container(container)
            if process:
                process.terminate()
                try:
                    process.wait(timeout=15)
                except subprocess.TimeoutExpired:
                    process.kill()
                    process.wait()


def main():
    """Run a command with private fixture variables (use -- before arguments)."""
    import sys
    args = sys.argv[1:]
    if args and args[0] == '--':
        args = args[1:]
    if not args:
        raise SystemExit('usage: python3 validation/auth_fixture.py -- COMMAND [ARGUMENT ...]')
    with environment() as fixture:
        return subprocess.call(args, env={**os.environ, **fixture})


if __name__ == '__main__':
    raise SystemExit(main())