Skip to content

Commit 9962fe4

Browse files
authored
Merge pull request #1275 from adisbladis/xml-to-json
Move from xml intermediate Nix representation to JSON
2 parents ab0780d + 85faae0 commit 9962fe4

File tree

14 files changed

+361
-143
lines changed

14 files changed

+361
-143
lines changed

doc/plugins/authoring.rst

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,46 @@ Important Notes
113113
os.path.dirname(os.path.abspath(__file__)) + "/nix"
114114
]
115115
116+
5. Resource subclasses must now work with Python objects instead of XML
117+
118+
This old-style ResourceDefinition subclass:
119+
120+
.. code-block:: python
121+
122+
class NeatCloudMachineDefinition(nixops.resources.ResourceDefinition):
123+
124+
def __init__(self, xml):
125+
super().__init__(xml)
126+
self.store_keys_on_machine = (
127+
xml.find("attrs/attr[@name='storeKeysOnMachine']/bool").get("value")
128+
== "true"
129+
)
130+
131+
Should now look like:
132+
133+
.. code-block:: python
134+
135+
class NeatCloudMachineOptions(nixops.resources.ResourceOptions):
136+
storeKeysOnMachine: bool
137+
138+
class NeatCloudMachineDefinition(nixops.resources.ResourceDefinition):
139+
140+
config: MachineOptions
141+
142+
store_keys_on_machine: bool
143+
144+
def __init__(self, name: str, config: nixops.resources.ResourceEval):
145+
super().__init__(name, config)
146+
self.store_keys_on_machine = config.storeKeysOnMachine
147+
148+
``ResourceEval`` is an immutable ``typing.Mapping`` implementation.
149+
Also note that ``ResourceEval`` has turned Nix lists into Python tuples, dictionaries into ResourceEval objects and so on.
150+
``typing.Tuple`` cannot be used as it's fixed-size, use ``typing.Sequence`` instead.
151+
152+
``ResourceOptions`` is an immutable object that provides type validation both with ``mypy`` _and_ at runtime.
153+
Any attributes which are not explicitly typed are passed through as-is.
154+
155+
116156
On with Poetry
117157
----
118158

nix/keys.nix

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ let
2323
options.keyFile = mkOption {
2424
default = null;
2525
type = types.nullOr types.path;
26+
apply = toString;
2627
description = ''
2728
When non-null, contents of the specified file will be deployed to the
2829
specified key on the target machine. If the key name is
@@ -155,8 +156,8 @@ in
155156
config = {
156157

157158
assertions = flip mapAttrsToList config.deployment.keys (key: opts: {
158-
assertion = (opts.text == null && opts.keyFile != null) ||
159-
(opts.text != null && opts.keyFile == null);
159+
assertion = (opts.text == null && opts.keyFile != "") ||
160+
(opts.text != null && opts.keyFile == "");
160161
message = "Deployment key '${key}' must have either a 'text' or a 'keyFile' specified.";
161162
});
162163

nixops/backends/__init__.py

Lines changed: 38 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,52 +3,48 @@
33
import os
44
import re
55
import subprocess
6-
from typing import Dict, Any, List, Optional, Union, Set
6+
from typing import Mapping, Any, List, Optional, Union, Set, Sequence
77
import nixops.util
88
import nixops.resources
99
import nixops.ssh_util
10-
import xml.etree.ElementTree as ET
10+
11+
12+
class KeyOptions(nixops.resources.ResourceOptions):
13+
text: Optional[str]
14+
keyFile: Optional[str]
15+
destDir: str
16+
user: str
17+
group: str
18+
permissions: str
19+
20+
21+
class MachineOptions(nixops.resources.ResourceOptions):
22+
targetPort: int
23+
alwaysActivate: bool
24+
owners: Sequence[str]
25+
hasFastConnection: bool
26+
keys: Mapping[str, KeyOptions]
27+
nixosRelease: str
1128

1229

1330
class MachineDefinition(nixops.resources.ResourceDefinition):
1431
"""Base class for NixOps machine definitions."""
1532

16-
def __init__(self, xml, config={}) -> None:
17-
nixops.resources.ResourceDefinition.__init__(self, xml, config)
18-
self.ssh_port = int(xml.find("attrs/attr[@name='targetPort']/int").get("value"))
19-
self.always_activate = (
20-
xml.find("attrs/attr[@name='alwaysActivate']/bool").get("value") == "true"
21-
)
22-
self.owners = [
23-
e.get("value")
24-
for e in xml.findall("attrs/attr[@name='owners']/list/string")
25-
]
26-
self.has_fast_connection = (
27-
xml.find("attrs/attr[@name='hasFastConnection']/bool").get("value")
28-
== "true"
29-
)
33+
config: MachineOptions
3034

31-
def _extract_key_options(x: ET.Element) -> Dict[str, str]:
32-
opts = {}
33-
for (key, xmlType) in (
34-
("text", "string"),
35-
("keyFile", "path"),
36-
("destDir", "string"),
37-
("user", "string"),
38-
("group", "string"),
39-
("permissions", "string"),
40-
):
41-
elem = x.find("attrs/attr[@name='{0}']/{1}".format(key, xmlType))
42-
if elem is not None:
43-
value = elem.get("value")
44-
if value is not None:
45-
opts[key] = value
46-
return opts
47-
48-
self.keys = {
49-
k.get("name"): _extract_key_options(k)
50-
for k in xml.findall("attrs/attr[@name='keys']/attrs/attr")
51-
}
35+
ssh_port: int
36+
always_activate: bool
37+
owners: List[str]
38+
has_fast_connection: bool
39+
keys: Mapping[str, KeyOptions]
40+
41+
def __init__(self, name: str, config: nixops.resources.ResourceEval):
42+
super().__init__(name, config)
43+
self.ssh_port = config["targetPort"]
44+
self.always_activate = config["alwaysActivate"]
45+
self.owners = config["owners"]
46+
self.has_fast_connection = config["hasFastConnection"]
47+
self.keys = {k: KeyOptions(**v) for k, v in config["keys"].items()}
5248

5349

5450
class MachineState(nixops.resources.ResourceState):
@@ -61,7 +57,7 @@ class MachineState(nixops.resources.ResourceState):
6157
ssh_pinged: bool = nixops.util.attr_property("sshPinged", False, bool)
6258
ssh_port: int = nixops.util.attr_property("targetPort", 22, int)
6359
public_vpn_key: Optional[str] = nixops.util.attr_property("publicVpnKey", None)
64-
keys: Dict[str, str] = nixops.util.attr_property("keys", {}, "json")
60+
keys: Mapping[str, str] = nixops.util.attr_property("keys", {}, "json")
6561
owners: List[str] = nixops.util.attr_property("owners", [], "json")
6662

6763
# Nix store path of the last global configuration deployed to this
@@ -202,7 +198,7 @@ def remove_backup(self, backup_id, keep_physical=False):
202198
"don't know how to remove a backup for machine ‘{0}’".format(self.name)
203199
)
204200

205-
def get_backups(self) -> Dict[str, Dict[str, Any]]:
201+
def get_backups(self) -> Mapping[str, Mapping[str, Any]]:
206202
self.warn("don't know how to list backups for ‘{0}’".format(self.name))
207203
return {}
208204

@@ -259,11 +255,10 @@ def send_keys(self) -> None:
259255
# so keys will probably end up being written to DISK instead of
260256
# into memory.
261257
return
258+
262259
for k, opts in self.get_keys().items():
263260
self.log("uploading key ‘{0}’...".format(k))
264261
tmp = self.depl.tempdir + "/key-" + self.name
265-
if "destDir" not in opts:
266-
raise Exception("Key '{}' has no 'destDir' specified.".format(k))
267262

268263
destDir = opts["destDir"].rstrip("/")
269264
self.run_command(
@@ -274,10 +269,10 @@ def send_keys(self) -> None:
274269
).format(destDir)
275270
)
276271

277-
if "text" in opts:
272+
if opts["text"] is not None:
278273
with open(tmp, "w+") as f:
279274
f.write(opts["text"])
280-
elif "keyFile" in opts:
275+
elif opts["keyFile"] is not None:
281276
self._logged_exec(["cp", opts["keyFile"], tmp])
282277
else:
283278
raise Exception(

nixops/backends/none.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
11
# -*- coding: utf-8 -*-
2+
from typing import Dict, Optional
23
import os
34
import sys
45
import nixops.util
56

6-
from nixops.backends import MachineDefinition, MachineState
7+
from nixops.backends import MachineDefinition, MachineState, MachineOptions
78
from nixops.util import attr_property, create_key_pair
9+
import nixops.resources
810

911

1012
class NoneDefinition(MachineDefinition):
1113
"""Definition of a trivial machine."""
1214

15+
_target_host: str
16+
_public_ipv4: Optional[str]
17+
18+
config: MachineOptions
19+
1320
@classmethod
1421
def get_type(cls):
1522
return "none"
1623

17-
def __init__(self, xml, config):
18-
MachineDefinition.__init__(self, xml, config)
19-
self._target_host = xml.find("attrs/attr[@name='targetHost']/string").get(
20-
"value"
21-
)
22-
23-
public_ipv4 = xml.find("attrs/attr[@name='publicIPv4']/string")
24-
self._public_ipv4 = None if public_ipv4 is None else public_ipv4.get("value")
24+
def __init__(self, name: str, config: nixops.resources.ResourceEval):
25+
super().__init__(name, config)
26+
self._target_host = config["targetHost"]
27+
self._public_ipv4 = config.get("publicIPv4", None)
2528

2629

2730
class NoneState(MachineState):

nixops/deployment.py

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import sqlite3
1010
import threading
1111
from collections import defaultdict
12-
from xml.etree import ElementTree
1312
import re
1413
from datetime import datetime, timedelta
1514
import getpass
@@ -440,16 +439,15 @@ def evaluate_args(self) -> Any:
440439
except subprocess.CalledProcessError:
441440
raise NixEvalError
442441

443-
def evaluate_config(self, attr):
442+
def evaluate_config(self, attr) -> Dict:
444443
try:
445-
# FIXME: use --json
446-
xml = subprocess.check_output(
444+
_json = subprocess.check_output(
447445
["nix-instantiate"]
448446
+ self.extra_nix_eval_flags
449447
+ self._eval_flags(self.nix_exprs)
450448
+ [
451449
"--eval-only",
452-
"--xml",
450+
"--json",
453451
"--strict",
454452
"--arg",
455453
"checkConfigurationOptions",
@@ -461,25 +459,19 @@ def evaluate_config(self, attr):
461459
text=True,
462460
)
463461
if DEBUG:
464-
print("XML output of nix-instantiate:\n" + xml, file=sys.stderr)
462+
print("JSON output of nix-instantiate:\n" + _json, file=sys.stderr)
465463
except OSError as e:
466464
raise Exception("unable to run ‘nix-instantiate’: {0}".format(e))
467465
except subprocess.CalledProcessError:
468466
raise NixEvalError
469467

470-
tree = ElementTree.fromstring(xml)
471-
472-
# Convert the XML to a more Pythonic representation. This is
473-
# in fact the same as what json.loads() on the output of
474-
# "nix-instantiate --json" would yield.
475-
config = nixops.util.xml_expr_to_python(tree.find("*"))
476-
return (tree, config)
468+
return json.loads(_json)
477469

478470
def evaluate_network(self, action: str = "") -> None:
479471
if not self.network_attr_eval:
480472
# Extract global deployment attributes.
481473
try:
482-
(_, config) = self.evaluate_config("info.network")
474+
config = self.evaluate_config("info.network")
483475
except Exception as e:
484476
if action not in ("destroy", "delete"):
485477
raise e
@@ -494,22 +486,20 @@ def evaluate(self) -> None:
494486
self.definitions = {}
495487
self.evaluate_network()
496488

497-
(tree, config) = self.evaluate_config("info")
489+
config = self.evaluate_config("info")
490+
491+
tree = None
498492

499493
# Extract machine information.
500-
for x in tree.findall("attrs/attr[@name='machines']/attrs/attr"):
501-
name = x.get("name")
502-
cfg = config["machines"][name]
503-
defn = _create_definition(x, cfg, cfg["targetEnv"])
494+
for name, cfg in config["machines"].items():
495+
defn = _create_definition(name, cfg, cfg["targetEnv"])
504496
self.definitions[name] = defn
505497

506498
# Extract info about other kinds of resources.
507-
for x in tree.findall("attrs/attr[@name='resources']/attrs/attr"):
508-
res_type = x.get("name")
509-
for y in x.findall("attrs/attr"):
510-
name = y.get("name")
499+
for res_type, cfg in config["resources"].items():
500+
for name, y in cfg.items():
511501
defn = _create_definition(
512-
y, config["resources"][res_type][name], res_type
502+
name, config["resources"][res_type][name], res_type
513503
)
514504
self.definitions[name] = defn
515505

@@ -604,13 +594,13 @@ def do_machine(m: nixops.backends.MachineState) -> None:
604594
attrs_list = attrs_per_resource[m.name]
605595

606596
# Set system.stateVersion if the Nixpkgs version supports it.
607-
nixos_version = nixops.util.parse_nixos_version(defn.config["nixosRelease"])
597+
nixos_version = nixops.util.parse_nixos_version(defn.config.nixosRelease)
608598
if nixos_version >= ["15", "09"]:
609599
attrs_list.append(
610600
{
611601
("system", "stateVersion"): Call(
612602
RawValue("lib.mkDefault"),
613-
m.state_version or defn.config["nixosRelease"],
603+
m.state_version or defn.config.nixosRelease,
614604
)
615605
}
616606
)
@@ -1674,16 +1664,14 @@ def _subclasses(cls: Any) -> List[Any]:
16741664
return [cls] if not sub else [g for s in sub for g in _subclasses(s)]
16751665

16761666

1677-
def _create_definition(xml: Any, config: Dict[str, Any], type_name: str) -> Any:
1667+
def _create_definition(
1668+
name: str, config: Dict[str, Any], type_name: str
1669+
) -> nixops.resources.ResourceDefinition:
16781670
"""Create a resource definition object from the given XML representation of the machine's attributes."""
16791671

16801672
for cls in _subclasses(nixops.resources.ResourceDefinition):
16811673
if type_name == cls.get_resource_type():
1682-
# FIXME: backward compatibility hack
1683-
if len(inspect.getargspec(cls.__init__).args) == 2:
1684-
return cls(xml)
1685-
else:
1686-
return cls(xml, config)
1674+
return cls(name, nixops.resources.ResourceEval(config))
16871675

16881676
raise nixops.deployment.UnknownBackend(
16891677
"unknown resource type ‘{0}’".format(type_name)

0 commit comments

Comments
 (0)