#!/usr/bin/env 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:
#
#   - whether or not --force is given (2)
#   - 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 2×2×4 = 16 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. Some distros don't have any pre-preparation step, so that test doesn't
#      make sense.
#
#   3. Some distros don't have an "apparently need" command determined.
#
# 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 = []
   base = None
   config = None
   scope = Scope.FULL
   prep_run = None
   runs = { Run.UNNEEDED_FAIL: "false",
            Run.UNNEEDED_WIN: "true" }

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

   def __str__(self):
      preprep = "preprep" if self.preprep else "no preprep"
      force = "with --force" if self.forced else "w/o --force"
      return f"{self.base}, {self.run.value}, {force}, {preprep}"

   @property
   def build1_post_hook(self):
      return ""

   @property
   def build2_post_hook(self):
      return ""

   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.forced 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
      # scope
      if (self.scope == Scope.STANDARD or self.run == Run.NEEDED):
         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}
RUN {self.prep_run}
EOF
echo "$output"
[[ $status -eq 0 ]]
{self.build1_post_hook}"""
      # force
      force = "--force" if self.forced else ""
      # 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.forced )):
         status = 1
      else:
         status = 0
      # output
      outs = []
      if (self.forced):
         outs += [f"will use --force: {self.config}"]
         if (self.run == Run.UNNEEDED_WIN ):
            outs += ["warning: --force specified, but nothing to do"]
         elif (self.run in { Run.NEEDED, Run.FAKE_NEEDED }):
            outs += ["--force: init OK & modified 1 RUN instructions"]
      else:
         outs += [f"available --force: {self.config}"]
         if (self.run in { Run.NEEDED, Run.FAKE_NEEDED }):
            outs += ["RUN: available here with --force"]
            if (self.run == Run.NEEDED):
               outs += ["build failed", "--force may fix it"]
         elif (self.run == Run.UNNEEDED_FAIL):
            outs += ["build failed", "current version of --force wouldn't help"]
      out = self.as_outputs(outs)
      # emit the test
      print(f"""
@test "ch-image --force: {self}" {{
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}
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"


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):
   # 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


class Fedora(RHEL8):
   config = "fedora"
class T_Fedora_26(Fedora):
   # 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_9(Debian):
   base = "debian:9"
   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
   # 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'
    # FIXME: Would be good to test with build cache enabled, but there are
    # lots of assumptions about output that are not valid in that case.
    [[ $CH_IMAGE_CACHE = disabled ]] || skip 'bucache disabled 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 forced in (False, True):
         for preprep in (False, True):
            test(run, forced, preprep).test()
