#!/usr/bin/python3

# This script generates a BATS file to exercise “ch-image build --force”
# across a variety of distributions. It’s used by Makefile.am.
#
# About each distribution, we remember:
#
#   - base image name
#   - config name it should select
#   - scope
#       standard: all tests in standard scope
#       full: one test in standard scope, the rest in full
#   - any tests invalid for that distro
#
# For each distribution, we test these factors:
#
#   - the value of --force (absent, fakeroot, seccomp) (3)
#   - whether or not preparation for --force is already done (2)
#   - commands that (4)
#       - don’t need --force, and fail
#       - don’t need --force, and succeed
#       - apparently need --force but in fact do not
#       - really do need --force
#
# This would appear to yield 3×2×4 = 24 tests per distribution. However:
#
#   1. We only try pre-prepared images for “really need” commands with --force
#      given, to save time, so it’s at most 9 potential tests.
#
#   2. The pre-preparation step doesn’t make sense for some distros or for
#      --force=seccomp.
#
#   3. We’ve not yet determined an “apparently need” command for some distros.
#
# Bottom line, the number of tests per distro varies. See the code below for
# specific details.


import abc
import enum
import inspect
import sys


@enum.unique
class Scope(enum.Enum):
   STANDARD = "standard"
   FULL = "full"

@enum.unique
class Run(enum.Enum):
   UNNEEDED_FAIL = "unneeded fail"
   UNNEEDED_WIN = "unneeded win"
   FAKE_NEEDED = "fake needed"
   NEEDED = "needed"


class Test(abc.ABC):

   arch_excludes = []
   force_excludes = []
   base = None
   config = None
   scope = Scope.FULL
   skip_reason = None
   prep_run = None
   runs = { Run.UNNEEDED_FAIL: "false",
            Run.UNNEEDED_WIN: "true" }

   def __init__(self, run, force, preprep):
      self.run = run
      self.force = force
      self.preprep = preprep

   def __str__(self):
      preprep = "preprep" if self.preprep else "no preprep"
      return f"{self.base}, {self.run.value}, {self.force}, {preprep}"

   @property
   def build1_post_hook(self):
      return ""

   @property
   def build2_post_hook(self):
      return ""

   @property
   def build_from_hook(self):
      return ""

   @property
   def skip(self):
      if (self.skip_reason is None):
         return ""
      else:
         return "skip '%s'" % self.skip_reason

   def as_grep_files(self, grep_files, image, invert=False):
      cmds = []
      for (re, path) in grep_files:
         path = f"\"$CH_IMAGE_STORAGE\"/img/{image}/{path}"
         cmd = f"ls -lh {path}"
         if (invert):
            cmd = f"! ( {cmd} )"
         cmds.append(cmd)
         if (not invert):
            cmds.append(f"grep -Eq -- '{re}' {path}")
      return "\n".join(cmds)

   def as_outputs(self, outputs, invert=False):
      cmds = []
      for out in outputs:
         out = f"echo \"$output\" | grep -Eq -- \"{out}\""
         if (invert):
            out = f"! ( {out} )"
         cmds.append(out)
      return "\n".join(cmds)

   def as_runs(self, runs):
      return "\n".join("RUN %s" % run for run in runs)

   def test(self):
      # skip?
      if (self.preprep and not (self.force and self.run == Run.NEEDED)):
         print(f"\n# skip: {self}: not needed")
         return
      if (self.preprep and self.prep_run is None):
         print(f"\n# skip: {self}: no preprep command")
         return
      if (self.preprep and self.force == "seccomp"):
         print(f"\n# skip: {self}: no preprep for --force=seccomp")
         return
      if (self.force in self.force_excludes):
         print(f"\n# skip: {self}: --force=%s excluded" % self.force)
         return
      # scope
      if (    (self.scope == Scope.STANDARD or self.run == Run.NEEDED)
          and self.force != "fakeroot"):
         scope = "standard"
      else:
         scope = "full"
      # architecture excludes
      arch_excludes = "\n".join("arch_exclude %s" % i
                                for i in self.arch_excludes)
      # build 1 to make prep-prepped image (e.g. install EPEL) if needed
      if (not self.preprep):
         build1 = "# skipped: no separate prep"
         build2_base = self.base
      else:
         build2_base = "tmpimg"
         build1 = f"""\
run ch-image -v build -t tmpimg -f - . << 'EOF'
FROM {self.base}
{self.build_from_hook}
RUN {self.prep_run}
EOF
echo "$output"
[[ $status -eq 0 ]]
{self.build1_post_hook}"""
      # force
      force = "--force=%s" % (self.force) if self.force else "--force=none"
      # run command we’re testing
      try:
         run = self.runs[self.run]
      except KeyError:
         print(f"\n# skip: {self}: no run command")
         return
      # status
      if (   self.run == Run.UNNEEDED_FAIL
          or ( self.run == Run.NEEDED and not self.force )):
         status = 1
      else:
         status = 0
      # output
      outs = []
      if (self.force == "fakeroot"):
         outs += [f"--force=fakeroot: will use: {self.config}"]
         if (self.run in { Run.NEEDED, Run.FAKE_NEEDED }):
            outs += ["--force=fakeroot: modified 1 RUN instructions"]
      out = self.as_outputs(outs)
      # emit the test
      print(f"""
@test "ch-image --force: {self}" {{
{self.skip}
scope {scope}
{arch_excludes}

# build 1: intermediate image for preparatory commands
{build1}

# build 2: image we're testing
run ch-image -v build {force} -t tmpimg2 -f - . << 'EOF'
FROM {build2_base}
{self.build_from_hook}
RUN {run}
EOF
echo "$output"
[[ $status -eq {status} ]]
{out}
{self.build2_post_hook}
}}
""", end="")


class EPEL_Mixin:

   # Mixin class for RPM distros where we want to pre-install EPEL. I think
   # this should maybe go away and just go into a _Red_Hat base class, i.e.
   # test all RPM distros with EPEL pre-installed, but this matches what
   # existed in 50_fakeroot.bats. Note the install-EPEL command is elsewhere.

   epel_outputs = ["(Updating|Installing).+: epel-release"]
   epel_greps = [("enabled=1", "/etc/yum.repos.d/epel*.repo")]

   @property
   def build1_post_hook(self):
      return "\n".join(["# validate EPEL installed",
                        self.as_outputs(self.epel_outputs),
                        self.as_grep_files(self.epel_greps, "tmpimg")])

   @property
   def build2_post_hook(self):
      return "\n".join([
         "# validate EPEL present if installed by build 1, gone if by --force",
         self.as_grep_files(self.epel_greps, "tmpimg2", not self.preprep)])


class RHEL7(Test):
   config = "rhel7"
   runs = { **Test.runs, **{ Run.FAKE_NEEDED: "yum install -y ed",
                             Run.NEEDED:      "yum install -y openssh" } }
class T_CentOS_7(RHEL7, EPEL_Mixin):
   scope = Scope.STANDARD
   base = "centos:7"
   prep_run = "yum install -y epel-release"

   @property
   def build_from_hook(self):
      return f"""\
RUN sed -i s/mirror.centos.org/vault.centos.org/g /etc/yum.repos.d/*.repo \
    && sed -i s/^#.*baseurl=http/baseurl=http/g /etc/yum.repos.d/*.repo \
    && sed -i s/^mirrorlist=http/#mirrorlist=http/g /etc/yum.repos.d/*.repo"""


class RHEL8(Test):
   config = "rhel8"
   runs = { **Test.runs,
            **{ Run.FAKE_NEEDED: "dnf install -y"
                                 " --setopt=install_weak_deps=false ed",
                Run.NEEDED:      "dnf install -y"
                                 " --setopt=install_weak_deps=false openssh" } }

class T_RHEL_UBI_8(RHEL8):
   base = "registry.access.redhat.com/ubi8/ubi"

class CentOS_8(RHEL8, EPEL_Mixin):
   prep_run = "dnf install -y epel-release"
class T_CentOS_8_Stream(CentOS_8):
   skip_reason = "issue #1904"
   # CentOS Stream pulls from quay.io per the CentOS wiki:
   # https://wiki.centos.org/FAQ/CentOSStream#What_artifacts_are_built.3F
   base = "quay.io/centos/centos:stream8"
#class T_CentOS_9_Stream(CentOS_8):   # FIXME: fails importing GPG key
#   base = "quay.io/centos/centos:stream9"

class T_Alma_8(CentOS_8):
   scope = Scope.STANDARD
   base = "almalinux:8"  # :latest same as :8 as of 2022-03-01
class T_Rocky_8(CentOS_8):
   base = "rockylinux:8"  # :latest same as :8 as of 2022-03-01

# With many of the following images, we test two versions of each image, the
# latest version and the minimum supported version. “Minimum supported” here
# meaning the minimum of (a) what’s available on Docker hub and we think won’t
# vanish too quickly and (b) what we support with fakeroot.

class Fedora(RHEL8):
   config = "fedora"
class T_Fedora_26(Fedora):
   skip_reason = "issue #1904"
   # We would prefer to test the lowest supported --force version, 24,
   # but the ancient version of dnf it has doesn't fail the transaction when
   # a package fails so we test with 26 instead.
   base = "fedora:26"
class T_Fedora_34(Fedora):
   base = "fedora:34"
# No worky as of Fedora 35; see issue #1163.
class Fedora_Latest(Fedora):
   base = "fedora:latest"


class Debian(Test):
   config = "debderiv"
   runs = { **Test.runs,
            **{ Run.NEEDED: "    apt-get update"
                            " && apt-get install -y openssh-client" } }
class T_Debian_10(Debian):
   base = "debian:10"
   arch_excludes = ["ppc64le"]  # base image unavailable
class T_Debian_Latest(Debian):
   scope = Scope.STANDARD
   base = "debian:latest"
class T_Ubuntu_16(Debian):
   base = "ubuntu:16.04"
class T_Ubuntu_Latest(Debian):
   base = "ubuntu:latest"

class SUSE(Test):
   config = "suse"
   runs = { **Test.runs,
            # No openssh packages seem to need --force.
            **{ Run.FAKE_NEEDED: "zypper install -y ed",
                Run.NEEDED:      "zypper install -y dbus-1" } }
# As of 2022-06-03, fails with “Signature verification failed for file
# 'repomd.xml' from repository 'OSS Update'.”. Neither opensuse/archive:42.3
# nor opensuse/leap:42.3 work, though the latter has more architectures.
#class T_OpenSUSE_42_3(SUSE):
#   base = "opensuse/archive:42.3"
#   arch_excludes = ["aarch64", "ppc64le"]  # base only amd64
class T_OpenSUSE_Leap_15_0(SUSE):
   base = "opensuse/leap:15.0"
class T_OpenSUSE_Leap_Latest(SUSE):
   base = "opensuse/leap:latest"

# Arch has tons of old tags, versioned by date, on Docker Hub. However, only
# :latest (or equivalently :base) is documented.
class T_Arch_Latest(Test):
   config = "arch"
   base = "archlinux:latest"
   arch_excludes = ["aarch64", "ppc64le"]  # base only amd64
   force_excludes = ["seccomp"]  # issue #1567
   # pacman does not exit non-zero when installing openssh fails; work
   # around this bug by grepping the pacman log.
   runs = { **Test.runs, **{ Run.FAKE_NEEDED: "pacman -Syq --noconfirm ed; ! fgrep failed: /var/log/pacman.log",
                             Run.NEEDED:      "pacman -Syq --noconfirm openssh; ! fgrep failed: /var/log/pacman.log" } }

class Alpine(Test):
   config = "alpine"
   # openssh does not need --force on Alpine
   runs = { **Test.runs, **{ Run.FAKE_NEEDED: "apk add ed",
                             Run.NEEDED:      "apk add dbus" } }
class T_Alpine_39(Alpine):
   base = "alpine:3.9"
class T_Alpine_Latest(Alpine):
   base = "alpine:latest"


# main loop

print("""\
# NOTE: This file is auto-generated. Do not modify.

load common

setup () {
    [[ $CH_TEST_BUILDER = ch-image ]] || skip 'ch-image only'
    [[ $CH_IMAGE_CACHE = enabled ]] || skip 'bucache enabled only'
}
""")


# All classes starting with T_ get turned into a test.
for (name, test) in (i for i in inspect.getmembers(sys.modules[__name__])
                     if i[0].startswith("T_")):
   for run in Run:
      for force in (None, "fakeroot", "seccomp"):
         for preprep in (False, True):
            test(run, force, preprep).test()
