#!%PYTHON_SHEBANG%

import argparse
import distutils.version
import inspect
import json
import os
import re
import signal
import subprocess
import sys
import time
import types

sys.path.insert(0, (  os.path.dirname(os.path.abspath(__file__))
                    + "/../lib/charliecloud"))
import charliecloud as ch
import misc


BUNDLE_PREFIX = ["/tmp", "/var/tmp"]
CH_BIN = os.path.dirname(os.path.abspath(
           inspect.getframeinfo(inspect.currentframe()).filename))
OCI_VERSION_MIN = "1.0.1-dev"  # inclusive
OCI_VERSION_MAX = "1.0.999"    # inclusive

args = None   # CLI Namespace
state = None  # state object


def main():
   global args, state
   args = args_parse()
   ch.DEBUG("--- starting ------------------------------------")
   ch.DEBUG("args: %s" % sys.argv)
   ch.DEBUG("environment: %s" % { k: v for (k, v) in os.environ.items()
                                  if k.startswith("CH_RUN_OCI_") })
   ch.DEBUG("CLI: %s" % args)
   if (args.op.__name__ == "op_" + os.getenv("CH_RUN_OCI_HANG", default="")):
      ch.DEBUG("hanging before %s per CH_RUN_OCI_HANG" % args.op.__name__)
      sleep_forever()
      assert False, "unreachable code reached"
   state = state_load()
   args.op()
   ch.DEBUG("done")


def args_parse():

   ap = argparse.ArgumentParser(description='OCI wrapper for "ch-run".')
   ap.add_argument("-v", "--verbose", action="count", default=0,
                   help="print extra chatter (can be repeated)")
   ap.add_argument("--version", action=misc.Version,
                   help="print version and exit")
   sps = ap.add_subparsers()

   sp = sps.add_parser("create")
   sp.set_defaults(op=op_create)
   sp.add_argument("--bundle", required=True, metavar="DIR")
   sp.add_argument("--console-socket", metavar="PATH")
   sp.add_argument("--pid-file", required=True, metavar="FILE")
   sp.add_argument("--no-new-keyring", action="store_true")
   sp.add_argument("cid", metavar="CONTAINER_ID")

   sp = sps.add_parser("delete")
   sp.set_defaults(op=op_delete)
   sp.add_argument("cid", metavar="CONTAINER_ID")

   sp = sps.add_parser("kill")
   sp.set_defaults(op=op_kill)
   sp.add_argument("cid", metavar="CONTAINER_ID")
   sp.add_argument("signal", metavar="SIGNAL")

   sp = sps.add_parser("start")
   sp.set_defaults(op=op_start)
   sp.add_argument("cid", metavar="CONTAINER_ID")

   sp = sps.add_parser("state")
   sp.set_defaults(op=op_state)
   sp.add_argument("cid", metavar="CONTAINER_ID")

   args_ = ap.parse_args()
   ch.log_setup(args_.verbose)

   if len(sys.argv) < 2:
      ap.print_help(file=sys.stderr)
      sys.exit(1)

   bundle_ = bundle_from_cid(args_.cid)
   if ("bundle" in args_ and args_.bundle != bundle_):
      ch.FATAL("bundle argument \"%s\" differs from inferred bundle \"%s\""
            % (args_.bundle, bundle_))
   args_.bundle = bundle_

   pid_file_ = pid_file_from_bundle(args_.bundle)
   if ("pid_file" in args_ and args_.pid_file != pid_file_):
      ch.FATAL("pid_file argument \"%s\" differs from inferred \"%s\""
            % (args_.pid_file, pid_file_))
   args_.pid_file = pid_file_

   return args_

def bundle_from_cid(cid):
   m = re.search(r"^buildah-buildah(.+)$", cid)
   if (m is None):
      ch.FATAL("cannot parse container ID: %s" % cid)
   paths = []
   for p in BUNDLE_PREFIX:
      paths.append("%s/buildah%s" % (p, m.group(1)))
      if (os.path.exists(paths[-1])):
         return paths[-1]
   ch.FATAL("can't infer bundle path; none of these exist: %s"
            % " ".join(paths))

def debug_lines(s):
   for line in s.splitlines():
      ch.DEBUG(line)

def image_fixup(path):
   ch.DEBUG("fixing up image: %s" % path)
   # Metadata directory.
   ch.mkdirs("%s/ch/bin" % path)
   # Mount points.
   ch.file_ensure_exists("%s/etc/hosts" % path)
   ch.file_ensure_exists("%s/etc/resolv.conf" % path)
   # /etc/{passwd,group}
   ch.file_write("%s/etc/passwd" % path, """\
root:x:0:0:root:/root:/bin/sh
nobody:x:65534:65534:nobody:/:/bin/false
""")
   ch.file_write("%s/etc/group" % path, """\
root:x:0:
nogroup:x:65534:
""")
   # Kludges to work around expectations of real root, not UID 0 in a
   # unprivileged user namespace. See also the default environment.
   #
   # Debian apt/dpkg/etc. want to chown(1), chgrp(1), etc. in various ways.
   ch.symlink("/bin/true", "%s/ch/bin/chgrp" % path)
   ch.symlink("/bin/true", "%s/ch/bin/dpkg-statoverride" % path)
   # Debian package management also wants to mess around with users. This is
   # causing problems with /etc/gshadow and other files. These links don't
   # work if they are in /ch/bin, I think because dpkg is resetting the path?
   # For now we'll do this, but I don't like it. fakeroot(1) also solves the
   # problem (see issue #472).
   ch.symlink("/bin/true", "%s/bin/chown" % path, clobber=True)
   ch.symlink("/bin/true", "%s/usr/sbin/groupadd" % path, clobber=True)
   ch.symlink("/bin/true", "%s/usr/sbin/useradd" % path, clobber=True)
   ch.symlink("/bin/true", "%s/usr/sbin/usermod" % path, clobber=True)
   ch.symlink("/bin/true", "%s/usr/bin/chage" % path, clobber=True)

def op_create():
   # Validate arguments.
   if (args.console_socket):
      ch.FATAL("--console-socket not supported")

   # Start dummy supervisor.
   if (state.pid is not None):
      ch.FATAL("container already created")
   pid = ch.ossafe(os.fork, "can't fork")
   if (pid == 0):
      # Child; the only reason to exist is so Buildah sees a process when it
      # looks for one. Sleep until told to exit.
      #
      # Note: I looked into changing the process title and this turns out to
      # be remarkably hairy unless you use a 3rd-party module.
      def exit_(sig, frame):
         ch.DEBUG("dummy supervisor: done")
         sys.exit(0)
      signal.signal(signal.SIGTERM, exit_)
      ch.DEBUG("dummy supervisor: starting")
      sleep_forever()
   else:
      state.pid = pid
      with ch.open_(args.pid_file, "wt") as fp:
         print("%d" % pid, file=fp)
      ch.DEBUG("dummy supervisor started with pid %d" % pid)

def op_delete():
   ch.DEBUG("delete operation is a no-op")

def op_kill():
   ch.DEBUG("kill operation is a no-op")

def op_start():
   # Note: Contrary to the implication of its name, the "start" operation
   # blocks until the user command is done.

   c = state.config

   # Unsupported features to barf about.
   if (state.pid is None):
      ch.FATAL("can't start: not created yet")
   if (c["process"].get("terminal", False)):
      ch.FATAL("not supported: pseudoterminals")
   if ("annotations" in c):
      ch.FATAL("not supported: annotations")
   if ("hooks" in c):
      ch.FATAL("not supported: hooks")
   for d in c["linux"]["namespaces"]:
      if ("path" in d):
         ch.FATAL("not supported: joining existing namespaces")
   if ("intelRdt" in c["linux"]):
      ch.FATAL("not supported: Intel RDT")

   # Environment file. This is a list of lines, not a dict.
   #
   # GNU tar, when it thinks it's running as root, tries to chown(2) and
   # chgrp(2) files to whatever's in the tarball. --no-same-owner avoids this.
   with ch.open_(args.bundle + "/environment", "wt") as fp:
      for line in (  c["process"]["env"]                  # from Dockerfile
                   + [ "TAR_OPTIONS=--no-same-owner" ]):  # ours
         line = re.sub(r"^(PATH=)", "\\1/ch/bin:", line)
         ch.DEBUG("env: %s" % line)
         print(line, file=fp)

   # Build command line.
   cmd = CH_BIN + "/ch-run"
   ca = [cmd,
         "--cd", c["process"]["cwd"],
         "--no-home",
         "--no-passwd",
         "--gid", str(c["process"]["user"]["gid"]),
         "--uid", str(c["process"]["user"]["uid"]),
         "--unset-env=*", "--set-env=%s/environment" % args.bundle]
   if (not c["root"].get("readonly", False)):
      ca.append("--write")
   ca += [c["root"]["path"], "--"]
   ca += c["process"]["args"]

   # Fix up root filesystem.
   image_fixup(args.bundle + "/mnt/rootfs")

   # Execute user command. We can't execv(2) because we have to do cleanup
   # after it exits.
   ch.file_ensure_exists(args.bundle + "/user_started")
   ch.DEBUG("user command: %s" % ca)
   # Standard output disappears, so send stdout to stderr.
   cp = subprocess.run(ca, stdout=2)
   ch.file_ensure_exists(args.bundle + "/user_done")
   ch.DEBUG("user command done")

   # Stop dummy supervisor.
   if (state.pid is None):
      ch.FATAL("no dummy supervisor PID found")
   try:
      os.kill(state.pid, signal.SIGTERM)
      state.pid = None
      os.unlink(args.pid_file)
   except OSError as x:
      ch.FATAL("can't kill PID %d: %s (%d)" % (state.pid, x.strerror, x.errno))

   # Puke if user command failed.
   if (cp.returncode != 0):
      ch.FATAL("user command failed: %d" % cp.returncode)

def op_state():
   def status():
      if (state.user_command_started):
         if (state.user_command_done):
            return "stopped"
         else:
            return "running"
      if (state.pid is None):
         return "creating"
      else:
         return "created"
   st = { "ociVersion": OCI_VERSION_MAX,
          "id": args.cid,
          "status": status(),
          "bundle": args.bundle }
   if (state.pid is not None):
      st["pid"] = state.pid
   out = json.dumps(st, indent=2)
   debug_lines(out)
   print(out)

def sleep_forever():
   while True:
      time.sleep(60)  # can't provide infinity here

def pid_file_from_bundle(bundle):
   return bundle + "/pid"

def state_load():
   st = types.SimpleNamespace()

   st.config = json.load(ch.open_(args.bundle + "/config.json", "rt"))
   #debug_lines(json.dumps(st.config, indent=2))

   v_min = distutils.version.LooseVersion(OCI_VERSION_MIN)
   v_actual = distutils.version.LooseVersion(st.config["ociVersion"])
   v_max = distutils.version.LooseVersion(OCI_VERSION_MAX)
   if (not v_min <= v_actual <= v_max):
      ch.FATAL("unsupported OCI version: %s" % st.config["ociVersion"])

   try:
      fp = open(args.pid_file, "rt")
      st.pid = int(ch.ossafe(fp.read, "can't read: %s" % args.pid_file))
      ch.DEBUG("found supervisor pid: %d" % st.pid)
   except FileNotFoundError:
      st.pid = None
      ch.DEBUG("no supervisor pid found")

   st.user_command_started = os.path.isfile(args.bundle + "/user_started")
   st.user_command_done = os.path.isfile(args.bundle + "/user_done")

   return st


if (__name__ == "__main__"):
   main()
