Skip to content

Commit e77ee37

Browse files
authored
Merge pull request #326 from larsewi/cfbs-render-input
ENT-11346: Added the 'cfbs render-input' command
2 parents 068c34a + c588d1f commit e77ee37

22 files changed

Lines changed: 1225 additions & 90 deletions

‎JSON.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,9 @@ Some modules allow for users to add module input by responding to questions expr
477477
User input can be added using the `cfbs input <module-name>` command, which stores responses in `./<module-name>/input.json`.
478478
These responses are translated into augments which will be added to `./out/masterfiles/def.json` during `cfbs build`.
479479

480+
The `cfbs render-input` command performs the same translation, but writes the augments to a file of your choosing or stdout.
481+
Augments rendered with `cfbs render-input` will not be added to `./out/masterfiles/def.json` automatically during a build.
482+
480483
### Create single file example
481484

482485
The `"input"` attribute takes a list of input definitions as illustrated below.

‎README.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,43 @@ Here is an example of an `input.json` file with responses:
150150

151151
The `input.json` file is converted and merged into the main `def.json` during the build, using the `input` build step.
152152

153+
### Render module input as an augments file
154+
155+
```
156+
cfbs render-input <module> <infile (or - for stdin)> <outfile (or - for stdout)>
157+
```
158+
159+
Converts input data for a module, as shown above, into the augments (`def.json`) format, and writes it to the outfile.
160+
This is the same conversion the `input` build step performs during `cfbs build`, except that the input data is read from the infile instead of `<module-name>/input.json`, and nothing is stored in the project.
161+
162+
Use it to render an augment for input data which is kept outside of the project, for example input entered per host group in Mission Portal:
163+
164+
```
165+
$ cfbs render-input create-single-file - - <<EOF
166+
[
167+
{
168+
"type": "string",
169+
"variable": "filename",
170+
"label": "Filename",
171+
"question": "What file should this module create?",
172+
"response": "/tmp/create-single-file.txt"
173+
}
174+
]
175+
EOF
176+
{
177+
"variables": {
178+
"cfbs:create_single_file.filename": {
179+
"value": "/tmp/create-single-file.txt",
180+
"comment": "Added by 'cfbs input'"
181+
}
182+
}
183+
}
184+
```
185+
186+
The input data must conform with the module's input definition, just like for `cfbs set-input`.
187+
A variable is only added to the augment if the input data has a `response` for it.
188+
Thus, if none of the questions have been answered, the rendered augment contains no variables at all.
189+
153190
### Deploy your policy set to a remote hub
154191

155192
```
@@ -288,6 +325,9 @@ These commands are intended to be run as part of build systems / deployment pipe
288325
Empty list `[]` is returned if the module was found, but it does not accept any input.
289326
- `cfbs install`: Run this on a hub as root to install the policy set (copy the files from `out/masterfiles` to `/var/cfengine/masterfiles`).
290327
- `cfbs pretty`: Run on a JSON file to pretty-format it. (May be expanded to other formats in the future).
328+
- `cfbs render-input`: Convert input data for a module into an augments file (`def.json`) and print it.
329+
Takes the same input data as `cfbs set-input`, validates it the same way, but stores nothing - the augment is written to the given outfile instead.
330+
Useful for rendering the augment for input data which is not stored in the project, for example input entered per host group in Mission Portal.
291331
- `cfbs set-input`: Set input data for a module.
292332
Non-interactive version of `cfbs input`, takes the input as a JSON, validates it and stores it.
293333
`cfbs set-input` and `cfbs get-input` can be thought of as ways to save and load the input file.

‎cfbs/augments.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Functions for generating CFEngine augments (def.json)
3+
"""
4+
5+
from collections import OrderedDict
6+
7+
from cfbs.utils import canonify
8+
9+
10+
def generate_augment(module_name, input_data):
11+
"""
12+
Generate augment from input data.
13+
14+
:param module_name: name of module
15+
:param input_data: input data
16+
:return: generated augment or None if input data is incomplete
17+
"""
18+
if not isinstance(input_data, list):
19+
return None
20+
21+
# OrderedDict, so that the keys are in the same order regardless of
22+
# the Python version. Dictionaries don't preserve the insertion order
23+
# before Python 3.7:
24+
augment = OrderedDict()
25+
augment["variables"] = OrderedDict()
26+
27+
for variable in input_data:
28+
if not isinstance(variable, dict) or any(
29+
key not in variable for key in ("variable", "response")
30+
):
31+
continue
32+
33+
name = variable["variable"]
34+
namespace = variable.get("namespace", "cfbs")
35+
bundle = variable.get("bundle", canonify(module_name))
36+
value = variable["response"]
37+
comment = variable.get("comment", "Added by 'cfbs input'")
38+
39+
augment_variable = OrderedDict()
40+
augment_variable["value"] = value
41+
augment_variable["comment"] = comment
42+
augment["variables"]["%s:%s.%s" % (namespace, bundle, name)] = augment_variable
43+
44+
return augment

‎cfbs/build.py‎

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@
1515
import logging as log
1616
import shutil
1717
import subprocess
18+
from cfbs.augments import generate_augment
1819
from cfbs.cfbs_config import CFBSConfig
1920
from cfbs.utils import (
2021
CFBSUserError,
21-
canonify,
2222
cli_tool_present,
2323
cp,
2424
cp_dry_overwrites,
@@ -54,39 +54,6 @@ def init_out_folder():
5454
mkdir("out/steps")
5555

5656

57-
def _generate_augment(module_name, input_data):
58-
"""
59-
Generate augment from input data.
60-
61-
:param module_name: name of module
62-
:param input_data: input data
63-
:return: generated augment or None if input data is incomplete
64-
"""
65-
if not isinstance(input_data, list):
66-
return None
67-
68-
augment = {"variables": {}}
69-
70-
for variable in input_data:
71-
if not isinstance(variable, dict) or any(
72-
key not in variable for key in ("variable", "response")
73-
):
74-
continue
75-
76-
name = variable["variable"]
77-
namespace = variable.get("namespace", "cfbs")
78-
bundle = variable.get("bundle", canonify(module_name))
79-
value = variable["response"]
80-
comment = variable.get("comment", "Added by 'cfbs input'")
81-
82-
augment["variables"]["%s:%s.%s" % (namespace, bundle, name)] = {
83-
"value": value,
84-
"comment": comment,
85-
}
86-
87-
return augment
88-
89-
9057
def _perform_replacement(n, a, b, filename):
9158
assert n and a and b and filename
9259
assert a not in b
@@ -297,7 +264,7 @@ def _perform_input_step(args, name, destination, prefix):
297264
)
298265
return
299266
extras, original = read_json(src), read_json(dst)
300-
extras = _generate_augment(name, extras)
267+
extras = generate_augment(name, extras)
301268
log.debug("Generated augment: %s", pretty(extras))
302269
if not extras:
303270
raise CFBSExitError(

‎cfbs/cfbs_config.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,8 @@ def _add_modules(
449449
]
450450
modules_already_added = self["build"]
451451

452-
assert not any(m for m in modules_to_add if "name" not in m)
452+
# check_existence() above ensures all of them are in the index:
453+
assert not any(m for m in modules_to_add if m is None or "name" not in m)
453454
assert not any(m for m in modules_already_added if "name" not in m)
454455

455456
# Find all unmet dependencies:

‎cfbs/cfbs_json.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ def _module_is_in_build(self, module):
215215
return "build" in self and module["name"] in (m["name"] for m in self["build"])
216216

217217
def get_module_from_build(self, module):
218-
for m in self["build"]:
218+
for m in self.get("build") or []:
219219
if m["name"] == module:
220220
return m
221221
return None

‎cfbs/commands.py‎

Lines changed: 55 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,14 @@ def search_command(terms: List[str]):
9292
pretty_file,
9393
CFBS_DEFAULT_SORTING_RULES,
9494
)
95+
from cfbs.augments import generate_augment
9596
from cfbs.build import (
9697
init_out_folder,
9798
perform_build,
9899
)
99100
from cfbs.cfbs_config import CFBSConfig, CFBSReturnWithoutCommit
100101
from cfbs.validate import (
102+
input_data_matches_spec,
101103
validate_config,
102104
validate_config_raise_exceptions,
103105
validate_module_name_content,
@@ -703,6 +705,9 @@ def update_command(to_update):
703705
new_module = provides[module_name]
704706
elif is_module_absolute(old_module["name"]):
705707
new_module = index.get_module_object(update.name)
708+
# Module objects for absolute modules are generated, not looked up
709+
# in the index, so this is never None:
710+
assert new_module is not None
706711
new_module["commit"] = head_commit_hash(old_module["name"])
707712
else:
708713

@@ -1268,7 +1273,10 @@ def cfbs_convert_git_commit(
12681273
raise
12691274

12701275
current_index = CFBSConfig.get_instance().index
1271-
default_version = current_index.get_module_object("masterfiles")["version"]
1276+
masterfiles = current_index.get_module_object("masterfiles")
1277+
if masterfiles is None:
1278+
raise CFBSExitError("Could not find the 'masterfiles' module in the index")
1279+
default_version = masterfiles["version"]
12721280

12731281
reference_version = analyzed_files.reference_version
12741282
if reference_version is None:
@@ -1603,48 +1611,11 @@ def set_input_command(name, infile):
16031611
return CFBSCommandGitResult(1)
16041612
log.debug("Input data for module '%s': %s" % (name, pretty(data)))
16051613

1606-
def _compare_dict(a, b, ignore=None):
1607-
assert isinstance(a, dict) and isinstance(b, dict)
1608-
ignore = ignore or set()
1609-
if set(a.keys()) != set(b.keys()) - ignore:
1610-
return False
1611-
# Avoid code duplication by converting the values of the two dicts
1612-
# into two lists in the same order and compare the lists instead
1613-
keys = a.keys()
1614-
return _compare_list([a[key] for key in keys], [b[key] for key in keys])
1615-
1616-
def _compare_list(a, b):
1617-
assert isinstance(a, list) and isinstance(b, list)
1618-
if len(a) != len(b):
1619-
return False
1620-
for x, y in zip(a, b):
1621-
if type(x) is not type(y):
1622-
return False
1623-
if isinstance(x, dict):
1624-
if not _compare_dict(x, y):
1625-
return False
1626-
elif isinstance(x, list):
1627-
if not _compare_list(x, y):
1628-
return False
1629-
else:
1630-
assert x is None or isinstance(
1631-
x, (int, float, str, bool)
1632-
), "Illegal value type"
1633-
if x != y:
1634-
return False
1635-
return True
1636-
1637-
for a, b in zip(spec, data):
1638-
if (
1639-
not isinstance(a, dict)
1640-
or not isinstance(b, dict)
1641-
or not _compare_dict(a, b, ignore=set({"response"}))
1642-
):
1643-
log.error(
1644-
"Input data for module '%s' does not conform with input definition"
1645-
% name
1646-
)
1647-
return CFBSCommandGitResult(1)
1614+
if not input_data_matches_spec(spec, data):
1615+
log.error(
1616+
"Input data for module '%s' does not conform with input definition" % name
1617+
)
1618+
return CFBSCommandGitResult(1)
16481619

16491620
path = os.path.join(name, "input.json")
16501621

@@ -1692,3 +1663,44 @@ def get_input_command(name, outfile):
16921663
log.error("Failed to write json: %s" % e)
16931664
return 1
16941665
return 0
1666+
1667+
1668+
@cfbs_command("render-input")
1669+
def render_input_command(name, infile, outfile):
1670+
config = CFBSConfig.get_instance()
1671+
config.warn_about_unknown_keys()
1672+
module = config.get_module_from_build(name)
1673+
if module is None:
1674+
module = config.index.get_module_object(name)
1675+
if module is None:
1676+
log.error("Module '%s' not found" % name)
1677+
return 1
1678+
1679+
spec = module.get("input")
1680+
if spec is None:
1681+
log.error("Module '%s' does not accept input" % name)
1682+
return 1
1683+
log.debug("Input spec for module '%s': %s" % (name, pretty(spec)))
1684+
1685+
try:
1686+
data = json.load(infile, object_pairs_hook=OrderedDict)
1687+
except json.decoder.JSONDecodeError as e:
1688+
log.error("Error reading input data for module '%s': %s" % (name, e))
1689+
return 1
1690+
log.debug("Input data for module '%s': %s" % (name, pretty(data)))
1691+
1692+
if not input_data_matches_spec(spec, data):
1693+
log.error(
1694+
"Input data for module '%s' does not conform with input definition" % name
1695+
)
1696+
return 1
1697+
1698+
augment = generate_augment(name, data)
1699+
log.debug("Generated augment: %s" % pretty(augment))
1700+
1701+
try:
1702+
outfile.write(pretty(augment) + "\n")
1703+
except OSError as e:
1704+
log.error("Failed to write json: %s" % e)
1705+
return 1
1706+
return 0

‎cfbs/index.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,23 @@ def get_module_object(
214214
module,
215215
added_by: Optional[str] = None,
216216
explicit_build_steps: Optional[List[str]] = None,
217+
default=None,
217218
):
219+
"""Get the module object for a module in the index.
220+
221+
Local ("./name") and absolute ("/path/") modules are not in the index,
222+
their module objects are generated with default build steps instead.
223+
224+
:param module: module name, "name@version", or a Module object
225+
:param added_by: what to put in the module's "added_by" field, omitted
226+
from the module object if not specified
227+
:param explicit_build_steps: build steps to use for a local subdirectory
228+
module, instead of the default "directory"
229+
build step
230+
:param default: what to return if the module, or the requested version
231+
of it, is not in the index
232+
:return: the module object, or default if it was not found
233+
"""
218234
if isinstance(module, str):
219235
module = Module(module)
220236
name = module.name
@@ -231,6 +247,8 @@ def get_module_object(
231247
# due to that, this hack is used to prevent creating the "version" field
232248
module = Module(name).to_dict()
233249
else:
250+
if name not in self:
251+
return default
234252
object = self[name]
235253
if version:
236254
try:
@@ -239,6 +257,8 @@ def get_module_object(
239257
raise CFBSExitError(
240258
"Downloading CFEngine Build Module Index failed - check your Wi-Fi / network settings."
241259
)
260+
if name not in versions or version not in versions[name]:
261+
return default
242262
new_values = versions[name][version]
243263
specifics = {
244264
k: v for (k, v) in new_values.items() if k in Module.attributes()

0 commit comments

Comments
 (0)