Repository: canonical/microk8s

Issue:
Switch to unix-style argument handling for addon enabling
This is a proposal to switch to regular unix-style argument handling when enabling addons. Briefly, instead of this:

    microk8s.enable kubeflow:"bundle=lite"

switch to this:

    microk8s.enable kubeflow --bundle lite

The big issue to solve is that right now, users can enable multiple addons at once like this:

    microk8s.enable gpu kubeflow

So, we run into an issue of how to handle argument parsing with multiple addons at once. The easiest thing to do would be to just disallow enabling multiple addons at once when using unix-style argument handling. The only issue with this is that an addon may want positional arguments. For example, say the `kubeflow` addon allowed a positional argument of which bundle size to deploy, like this:

    microk8s.enable kubeflow lite

If the user types `microk8s.enable kubeflow gpu dns`, do we parse that as the user wanting 3 addons enabled, or only `kubeflow` and `dns` enabled, and they mistakenly typed `gpu` instead of `full`?

However, if we stick to only parameter flags such as `--foo bar`, it should be possible to implement unix-style argument handling in a backwards-compatible way. In pseudocode, we could have logic like this:

```python
if all(a.split(':')[0] in ADDONS for a in ARGS):
    # We have a command line made entirely of addon names, with or without params
    # e.g. `microk8s.enable foo bar:"123" baz`
    for addon in ARGS:
        enable(addon)
else:
    # Treat as unix-style arguments, where enabling multiple addons at once is disallowed
    # Positional arguments are also disallowed
    ...
```

As a further proposal, I think we should move to [click](https://click.palletsprojects.com/en/7.x/), which will make it easy to create a cohesive CLI experience, as it automatically handles stuff such as argument parsing and `--help` flags. 


Discussion:

Resolving patch:
diff --git a/scripts/wrappers/common/utils.py b/scripts/wrappers/common/utils.py
--- a/scripts/wrappers/common/utils.py
+++ b/scripts/wrappers/common/utils.py
@@ -1,11 +1,14 @@
-import yaml
+import getpass
 import json
 import os
+import platform
 import subprocess
 import sys
 import time
-import platform
-import getpass
+from pathlib import Path
+
+import click
+import yaml
 
 kubeconfig = "--kubeconfig=" + os.path.expandvars("${SNAP_DATA}/credentials/client.config")
 
@@ -17,6 +20,13 @@ def get_current_arch():
     return arch_mapping[platform.machine()]
 
 
+def snap_data() -> Path:
+    try:
+        return Path(os.environ['SNAP_DATA'])
+    except KeyError:
+        return Path('/var/snap/microk8s/current')
+
+
 def run(*args, die=True):
     # Add wrappers to $PATH
     env = os.environ.copy()
@@ -102,13 +112,10 @@ def get_dqlite_info():
 
 
 def is_cluster_locked():
-    clusterLockFile = os.path.expandvars("${SNAP_DATA}/var/lock/clustered.lock")
-    if os.path.isfile(clusterLockFile):
-        print(
-            "This MicroK8s deployment is acting as a node in a cluster. "
-            "Please use the microk8s status on the master."
-        )
-        exit(0)
+    if (snap_data() / 'var/lock/clustered.lock').exists():
+        click.echo('This MicroK8s deployment is acting as a node in a cluster.')
+        click.echo('Please use the master node.')
+        sys.exit(1)
 
 
 def wait_for_ready(wait_ready, timeout):
@@ -151,6 +158,12 @@ def exit_if_no_permission():
         exit(1)
 
 
+def ensure_started():
+    if (snap_data() / 'var/lock/stopped.lock').exists():
+        click.echo('microk8s is not running, try microk8s start', err=True)
+        sys.exit(1)
+
+
 def kubectl_get(cmd, namespace="--all-namespaces"):
     if namespace == "--all-namespaces":
         return run("kubectl", kubeconfig, "get", cmd, "--all-namespaces", die=False)
@@ -211,3 +224,53 @@ def set_service_expected_to_start(service, start=True):
     else:
         fd = os.open(lock, os.O_CREAT, mode=0o700)
         os.close(fd)
+
+
+def xable(action: str, addons: list, xabled_addons: list):
+    """Enables or disables the given addons.
+
+    Collated into a single function since the logic is identical other than
+    the script names.
+    """
+    actions = Path(__file__).absolute().parent / "../../../actions"
+    existing_addons = {sh.with_suffix('').name[7:] for sh in actions.glob('enable.*.sh')}
+
+    # Backwards compatibility with enabling multiple addons at once, e.g.
+    # `microk8s.enable foo bar:"baz"`
+    if all(a.split(':')[0] in existing_addons for a in addons) and len(addons) > 1:
+        for addon in addons:
+            if addon in xabled_addons and addon != 'kubeflow':
+                click.echo("Addon %s is already %sd." % (addon, action))
+            else:
+                addon, *args = addon.split(':')
+                subprocess.run([str(actions / ('%s.%s.sh' % (action, addon)))] + args)
+
+    # The new way of xabling addons, that allows for unix-style argument passing,
+    # such as `microk8s.enable foo --bar`.
+    else:
+        addon, *args = addons[0].split(':')
+
+        if addon in xabled_addons and addon != 'kubeflow':
+            click.echo("Addon %s is already %sd." % (addon, action))
+            sys.exit(0)
+
+        if addon not in existing_addons:
+            click.echo("Nothing to do for `%s`." % addon, err=True)
+            sys.exit(1)
+
+        if args and addons[1:]:
+            click.echo(
+                "Can't pass string arguments and flag arguments simultaneously!\n"
+                "{0} an addon with only one argument style at a time:\n"
+                "\n"
+                "    microk8s {1} foo:'bar'\n"
+                "or\n"
+                "    microk8s {1} foo --bar\n".format(action.title(), action)
+            )
+            sys.exit(1)
+
+        script = [str(actions / ('%s.%s.sh' % (action, addon)))]
+        if args:
+            subprocess.run(script + args)
+        else:
+            subprocess.run(script + list(addons[1:]))
diff --git a/scripts/wrappers/disable.py b/scripts/wrappers/disable.py
new file mode 100755
--- /dev/null
+++ b/scripts/wrappers/disable.py
@@ -0,0 +1,32 @@
+#!/usr/bin/env python3
+
+import click
+
+from common.utils import ensure_started, exit_if_no_permission, is_cluster_locked, xable
+from status import get_status, get_available_addons, get_current_arch
+
+
+@click.command(context_settings={'ignore_unknown_options': True})
+@click.argument('addons', nargs=-1, required=True)
+def disable(addons):
+    """Disables one or more MicroK8s addons.
+
+    For a list of available addons, run `microk8s status`.
+
+    To see help for individual addons, run:
+
+        microk8s disable ADDON -- --help
+    """
+
+    is_cluster_locked()
+    exit_if_no_permission()
+    ensure_started()
+
+    _, disabled_addons = get_status(get_available_addons(get_current_arch()), True)
+    disabled_addons = {a['name'] for a in disabled_addons}
+
+    xable('disable', addons, disabled_addons)
+
+
+if __name__ == '__main__':
+    disable(prog_name='microk8s disable')
diff --git a/scripts/wrappers/enable.py b/scripts/wrappers/enable.py
--- a/scripts/wrappers/enable.py
+++ b/scripts/wrappers/enable.py
@@ -1,20 +1,32 @@
-#!/usr/bin/python3
+#!/usr/bin/env python3
 
-from common.utils import get_available_addons, get_current_arch
+import click
 
+from common.utils import ensure_started, exit_if_no_permission, is_cluster_locked, xable
+from status import get_status, get_available_addons, get_current_arch
 
-def print_console(addons):
-    print("Available Addons:")
-    for addon in addons:
-        print("{:>1} {:<20} # {}".format("", addon["name"], addon["description"]))
 
+@click.command(context_settings={'ignore_unknown_options': True})
+@click.argument('addons', nargs=-1, required=True)
+def enable(addons):
+    """Enables a MicroK8s addon.
 
-def show_help():
-    print("Usage: microk8s enable ADDON...")
-    print("Enable one or more ADDON included with microk8s")
-    print("Example: microk8s enable dns storage")
+    For a list of available addons, run `microk8s status`.
 
+    To see help for individual addons, run:
 
-available_addons = get_available_addons(get_current_arch())
-show_help()
-print_console(available_addons)
+        microk8s enable ADDON -- --help
+    """
+
+    is_cluster_locked()
+    exit_if_no_permission()
+    ensure_started()
+
+    enabled_addons, _ = get_status(get_available_addons(get_current_arch()), True)
+    enabled_addons = {a['name'] for a in enabled_addons}
+
+    xable('enable', addons, enabled_addons)
+
+
+if __name__ == '__main__':
+    enable(prog_name='microk8s enable')


Test patch: