Rewrite meta/hooks/configure into python3 #24

Closed
wants to merge 36 commits into
from

Conversation

Projects
None yet
6 participants
Collaborator

mvo5 commented Mar 13, 2017

During the review of #23 it became apparent that the shell in the configure hook becomes a bit unwieldy and the suggestion to port to python came up.

Below is a 1:1 port from shell to python. If people are happy I can go further and add some helpers and add the missing tests.

Collaborator

mvo5 commented Mar 13, 2017

I added some basic tests, this needs more work, i.e. the mock command, checking output etc.

requesting changes for the syncs, and also probably the spurious writing out of the pi conf. The rest are more "did you know" comments, not even nits

hooks/configure
@@ -1,4 +1,4 @@
-#!/bin/sh -e
+#!/usr/bin/python3
@chipaca

chipaca Mar 15, 2017

Member

given you're only importing stdlib things you could add -SIBbb and improve run time a little bit (more an FYI than a request-for-change)

+
+
+def switch_handle_power_key(action):
+ valid_actions = ("ignore","poweroff","reboot","halt","kexec","suspend","hibernate","hybrid-sleep","lock")
@chipaca

chipaca Mar 15, 2017

Member

did you know you can now use {} around a listish thing to make it a set? I'd expect to see a set in this kind of lookup thing because of its O(1) behaviour, but obviously given how short it is it'd be a net loss in this case.

@chipaca

chipaca Mar 15, 2017

Member

(also if you were calling switch_handle_power_key more than once I'd suggest making it a frozenset outside of the funciton, but for a single call the creation time wins, and everything else like readability stays the same, so meh)

@zyga

zyga Apr 5, 2017

Collaborator

While we are at it, can we pep-8 this script?

hooks/configure
+ if not os.path.isdir(login_conf_d):
+ os.makedirs(login_conf_d)
+ if not action in valid_actions:
+ raise Exception("invalid action '{}' supplied for system.power-key-action option".format(action))
@chipaca

chipaca Mar 15, 2017

Member

you could use {!r} here for something similar to go's %q

hooks/configure
+ # When the unit is already enabled but not active a call with
+ # --now does start the unit so we have to check for that case
+ # and explicitly start the unit.
+ if systemctl("is-enabled", srv).stdout.strip() == "disabled":
@chipaca

chipaca Mar 15, 2017

Member

did you know is-enabled and is-active also set the return value? they have a --quiet option to not print anything if you want to go that route instead

hooks/configure
+ if systemctl("is-active", srv).stdout.strip() == "inactive":
+ systemctl("start", srv, check=True)
+ else:
+ raise Exception("Invalid value '{}' provided for option {}".format(enable, srv))
@chipaca

chipaca Mar 15, 2017

Member

again quoting might be tricky and i sugges tusing {!r}

hooks/configure
+ w.write(line)
+ if not found and value != "":
+ w.write("{}={}\n".format(key, value))
+ os.rename(path+".tmp", path)
@chipaca

chipaca Mar 15, 2017

Member

sync the file before the rename, sync the directory after it

hooks/configure
+ if os.path.isfile(PI_CONFIG):
+ for key in PI_CONFIG_KEYS:
+ value=check_output(["snapctl", "get", "pi-config.{}".format(key.replace("_","-"))], universal_newlines=True).strip()
+ update_pi_config_value(PI_CONFIG, key, value)
@chipaca

chipaca Mar 15, 2017

Member

we're writing out the same file multiple times every time, to change a single value in each pass? Am I reading this right?
And we're not syncing any of those writes
This is Not Good

hooks/configure
+def systemctl(*args, check=False):
+ res = subprocess.run(["systemctl"]+list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
+ if check and res.returncode != 0:
+ raise subprocess.CalledProcessError()
@pedronis

pedronis Mar 15, 2017

seems with 3.5 this can become:
if check:
res.check_returncode()

chipaca and others added some commits Mar 15, 2017

hooks/configure
-sync
+SERVICES=("ssh",)
+
+class atomic:
@pedronis

pedronis Mar 17, 2017

this could be written with @contextlib.contextmanager I think

@chipaca

chipaca Mar 17, 2017

Member

yes. Is the traceback for when an exception is raised inside a context manager that's done with yield readable now?

@chipaca

chipaca Mar 17, 2017

Member

answering myself: yeap, quite readable. good!

Contributor

ogra1 commented Mar 23, 2017

@mvo5 i'm not actually thrilled by the idea to add an internal python dependency.
one of the feedbacks i got at embedded world from IoT manufacturers was that they werent thrilled about something like python being in their installed images. up to now we didnt have any actual hard dep on python in the core snap and would still be able to rip it out if required, a change like this will add a hard dependency and make us lose this opportunity.
could we perhaps instead go with generalized shell functions that you can just re-use in the config hook and that can be sourced at the top of the script ?

Collaborator

mvo5 commented Mar 23, 2017

@ogra1 I understand the concern. However not having any actual hard dep seems to be not quite true:

  apparmor cloud-guest-utils cloud-init console-conf 
  nplan probert subiquitycore ubuntu-core-security-apparmor

are what I see from a quick look.

Also - this was shell initially, the problems around making update_pi_config_value() readable mostly lead to this.

Contributor

ogra1 commented Mar 30, 2017

you probably want to re-sync with master to get the missing fixes for the tests

@@ -1,4 +1,4 @@
-#!/bin/sh -e
+#!/usr/bin/python3 -SIBbb
@zyga

zyga Apr 4, 2017

Collaborator

Nice, I had to look up what those do but I think it's worth having each one.

Small review, the context manager code is not correct. I didn't review the rest in detail.

hooks/configure
+ fd = os.open(tmp, os.O_WRONLY|os.O_CREAT|os.O_EXCL, mode=0o644, dir_fd=dfd)
+ f = open(fd, "w", closefd=False)
+
+ yield f
@zyga

zyga Apr 4, 2017

Collaborator

You really have to try/finally to have this behave the way you hope.

try:
  ...
  yield ...
finally:
  ..

After all the contextmanager decorator is just Python and once an exception flies by you have to have the finally clause to make the cleanups happen.

hooks/configure
+ os.close(dfd)
+
+def systemctl(*args, check=False):
+ res = subprocess.run(["systemctl"]+list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
@zyga

zyga Apr 4, 2017

Collaborator

Why not subprocess.chcek_call?

@chipaca

chipaca Apr 5, 2017

Member

because that's check=True

@mvo5

mvo5 Apr 5, 2017

Collaborator

systemctl() is also used for cases when we just need to know the return code and don't want things to blow up, like: if systemctl("is-enabled", "--quiet", srv).returncode != 0: stuff() - this could be done differently or the other way around of course but thats now its done right now :)

hooks/configure
+ w.write(line)
+
+if __name__ == "__main__":
+ if not core_support_available():
@zyga

zyga Apr 4, 2017

Collaborator

You may want to wrap that in main() as otherwise each assignment below becomes a global that is visible in other functions.

@mvo5

mvo5 Apr 5, 2017

Collaborator

Thanks, this is done now.

chipaca and others added some commits Apr 5, 2017

Some extra feedback

hooks/configure
+ dirname, target = os.path.split(path)
+ tmp = target + ".tmp"
+ dfd = os.open(dirname, os.O_RDONLY)
+ fd = os.open(tmp, os.O_WRONLY|os.O_CREAT|os.O_EXCL, mode=0o644, dir_fd=dfd)
@zyga

zyga Apr 5, 2017

Collaborator

Please add os.O_CLOEXEC (as a safe default)

hooks/configure
+ dfd = os.open(dirname, os.O_RDONLY)
+ fd = os.open(tmp, os.O_WRONLY|os.O_CREAT|os.O_EXCL, mode=0o644, dir_fd=dfd)
+
+ f = open(fd, "w", closefd=False)
@zyga

zyga Apr 5, 2017

Collaborator

why not just os.fdopen(fd)?

@chipaca

chipaca Apr 5, 2017

Member

os.fdopen is an alias for open, and the docs for os.fdopen don't document closefd (so you need to go read the docs for open anyway)

More comments.

Please license headers everywhere as well.

+
+
+def switch_handle_power_key(action):
+ valid_actions = ("ignore","poweroff","reboot","halt","kexec","suspend","hibernate","hybrid-sleep","lock")
@chipaca

chipaca Mar 15, 2017

Member

did you know you can now use {} around a listish thing to make it a set? I'd expect to see a set in this kind of lookup thing because of its O(1) behaviour, but obviously given how short it is it'd be a net loss in this case.

@chipaca

chipaca Mar 15, 2017

Member

(also if you were calling switch_handle_power_key more than once I'd suggest making it a frozenset outside of the funciton, but for a single call the creation time wins, and everything else like readability stays the same, so meh)

@zyga

zyga Apr 5, 2017

Collaborator

While we are at it, can we pep-8 this script?

+ if not action in valid_actions:
+ raise Exception("invalid action {!r} supplied for system.power-key-action option".format(action))
+ with atomic(conf) as fp:
+ fp.write("[Login]\nHandlePowerKey={}\n".format(action))
@zyga

zyga Apr 5, 2017

Collaborator

You can also just print("[Login]\nHandlePowerKey={}".format(action), file=fp) but not much difference

@sergiusens

sergiusens Apr 5, 2017

@chipaca also prefers your form of this @zyga and I have started to adopt it (without any particular reason)

@chipaca

chipaca Apr 5, 2017

Member

that chipaca guy sounds really annoying

+ subprocess.check_call(["hooks/configure"])
+ self.assertEqual(self.mock_systemctl.calls(), [
+ ["systemctl", "--version"],
+ ["systemctl", "disable", "ssh.service"],
@zyga

zyga Apr 5, 2017

Collaborator

Why didn't we go with the disable --now approach? I recall we had some issues in snapd but my memory is rusty.

+ return MockCmd(self.tmp, basename, script)
+
+ def mock_snapctl(self, k, v):
+ self.mock_binary("snapctl", """if [ "$1" = "get" ] && [ "$2" = "%s" ]; then echo "%s"; fi""" % (k, v))
@zyga

zyga Apr 5, 2017

Collaborator

How about shlex.quote here? (for k/v)

@mvo5

mvo5 Apr 5, 2017

Collaborator

Yes, would be more correct. We will need to check if k/v are empty because shlex will quote empty values which is not what we need in our script.

+{1}
+ """.format(self.logfile, script)
+ if not tmpdir in os.environ["PATH"]:
+ os.environ["PATH"] = tmpdir+":"+os.environ["PATH"]
@zyga

zyga Apr 5, 2017

Collaborator

os.path.pathsep.join(...)

@mvo5

mvo5 Apr 5, 2017

Collaborator

like this:

-            os.environ["PATH"] = tmpdir+":"+os.environ["PATH"]
+            os.environ["PATH"] = os.path.pathsep.join([tmpdir]+os.environ["PATH"].split(os.path.pathsep))
@zyga

zyga Apr 5, 2017

Collaborator

Hrm, that is pretty verbose. Anyway, use your feelings :)

+ content = fp.read()
+ all_calls = []
+ for call in content.split("\n\n"):
+ if call:
@zyga

zyga Apr 5, 2017

Collaborator

Why the if?

@mvo5

mvo5 Apr 5, 2017

Collaborator

This is to ignore empty lines - should I add a comment?

@zyga

zyga Apr 5, 2017

Collaborator

Ah, makes sense! Yes I think that would help the next reviewer.

hooks/configure
+ to_write.append("{}={}\n".format(key, config[key]))
+ if needs_write:
+ with atomic(path) as w:
+ for line in to_write:
@zyga

zyga Apr 5, 2017

Collaborator

w.writelines(to_write) since you already have the \n characters there

Contributor

ogra1 commented Apr 5, 2017

i am still not convinced python is the right choice here ... one of the main reasons to re-write snapd in go was that it became unusable on single-core/low ram ARM boards when it started to grow. seeing how the hook grew in the recent months and in the light that we seem to start to put gadget configuration in i fear it will also easily grow to a 1000 lines fast and start exposing the same performance issues.
would it be obscene to ask if we could instead re-do it in go (i'm really not as tied to shell as everyione thinks, but python on embedded is really not the best choice imho) ?

Collaborator

mvo5 commented Apr 6, 2017

As discussed in https://forum.snapcraft.io/t/use-python-for-the-core-snap-configure-hook/168/5 this will be re-written in go instead of python.

@mvo5 mvo5 closed this Apr 6, 2017

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment