Skip to content

Commit

Permalink
Merge pull request #145 from olehermanse/commands
Browse files Browse the repository at this point in the history
Fixed list of commands in help and added README documentation on commands and interactivity
  • Loading branch information
olehermanse authored Nov 25, 2022
2 parents 6796821 + 3731d34 commit 52d1a0b
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 47 deletions.
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,77 @@ from pydantic import (
$
```

## Reproducible builds

When you run `cfbs build` locally, and when your hub runs it after pulling the latest changes from git, the resulting policy sets will be identical.
(This does not extend to providing reproducibility after running arbitrary combinations of `cfbs init`, `cfbs add` commands, etc.).
This gives you some assurance, that what you've tested actually matches what is running on your hub(s) and thus the rest of your machines.
Currently, two `masterfiles.tgz` gzipped tarballs of policy sets are not bit-by-bit identical due to file metadata (modification time, etc.).
The file contents themselves are (should be) identical.
There is also no testing of reproducible builds or checking to enforce that what you built locally and what your hub built are the same.
We'd like to improve all of this.
To see the progress in this area, take a look at this JIRA ticket:

https://tracker.mender.io/browse/CFE-4102

## Available commands

We expect policy writers and module authors to use the `cfbs` tooling from the command line, when working on projects, in combination with their editor of choice, much in a similar way to `git`, `cargo`, or `npm`.
When humans are running the tool in a shell, manually typing in commands, we want it to be intuitive, and have helpful prompts to make it easy to use.
However, we also expect some of the commands to be run inside scripts, and as part of the code and automation which deploys your policy to your hosts.
In these situations, prompts are undesireable, and stability is very important (you don't want the deployment to start failing).
For these reasons, we've outlined 2 categories of commands below.

**Note:** The 2 categories below are not strict rules.
They are here to communicate our development philosophy and set expectations in terms of what changes to expect (and how frequent).
We run both user-oriented and automation-oriented commands in automated tests as well as inside Build in Mission Portal.

### User-oriented / Interactive commands

These commands are centered around a user making changes to a project (manually from the shell / command line), not a computer building/deploying it:

* `cfbs add`: Add a module to the project (local files/folders, prepended with `./` are also considered modules).
* `cfbs clean`: Remove modules which were added as dependencies, but are no longer needed.
* `cfbs help`: Print the help menu.
* `cfbs info`: Print information about a module.
* `cfbs init`: Initialize a new CFEngine Build project.
* `cfbs input`: Enter input for a module which accepts input.
* `cfbs remove`: Remove a module from the project.
* `cfbs search`: Search for modules in the index.
* `cfbs show`: Same as `cfbs info`.
* `cfbs status`: Show the status of the current project, including name, description, and modules.
* `cfbs update`: Update modules to newer versions.

They try to help the user with interactive prompts / menus.
You can always add the `--non-interactive` to skip all interactive prompts (equivalent to pressing enter to use defaults).
In order to improve the user experience we change the behavior of these, especially when it comes to how prompts work, how they are presented to the user, what options are available, etc.

**Note:** Some of the commands above are not interactive yet, but they might be in the future.

### Automation-oriented / Non-interactive commands

These commands are intended to be run as part of build systems / deployment pipelines (in addition to being run by human users):

* `cfbs download`: Download all modules / dependencies for the project.
Modules are skipped if already downloaded.
* `cfbs build`: Build the project, combining all the modules into 1 output policy set.
Download modules if necessary.
Should work offline if things are already downloaded (by `cfbs download`).
* `cfbs get-input`: Get input data for a module.
Includes both the specification for what the module accepts as well as the user's responses.
Can be used on modules not yet added to project to get just the specification.
* `cfbs install`: Run this on a hub as root to install the policy set (copy the files from `out/masterfiles` to `/var/cfengine/masterfiles`).
* `cfbs pretty`: Run on a JSON file to pretty-format it. (May be expanded to other formats in the future).
* `cfbs set-input`: Set input data for a module.
Non-interactive version of `cfbs input`, takes the input as a JSON, validates it and stores it.
`cfbs set-input` and `cfbs get-input` can be though of as ways to save and load the input file.
Similar to `cfbs get-input` the JSON contains both the specification (what the module accepts and how it's presented to the user) as well as the user's responses (if present).
Expected usage is to run `cfbs get-input` to get the JSON, and then fill out the response part and run `cfbs set-input`.
* `cfbs validate`: Used to validate the [index JSON file](https://github.com/cfengine/build-index/blob/master/cfbs.json).
May be expanded to validate other files and formats in the future.

They don't have interactive prompts, you can expect fewer changes to them, and backwards compatibility is much more important than with the interactive commands above.

## The cfbs.json format

More advanced users and module authors may need to understand, write, or edit `cfbs.json` files.
Expand Down
4 changes: 1 addition & 3 deletions cfbs/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@ def print_help():

@cache
def _get_arg_parser():
command_list = [
cmd.split("_")[0] for cmd in dir(commands) if cmd.endswith("_command")
]
command_list = commands.get_command_names()
parser = argparse.ArgumentParser(description="CFEngine Build System.")
parser.add_argument(
"command",
Expand Down
46 changes: 40 additions & 6 deletions cfbs/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import copy
import logging as log
import json
import sys
from collections import OrderedDict
from cfbs.args import get_args

Expand All @@ -19,6 +20,7 @@
user_error,
strip_right,
pad_right,
ProgrammerError,
get_json,
write_json,
rm,
Expand Down Expand Up @@ -63,7 +65,27 @@ def __init__(self, message):
PLURAL_S = lambda args, _: "s" if len(args[0]) > 1 else ""
FIRST_ARG_SLIST = lambda args, _: ", ".join("'%s'" % module for module in args[0])

_commands = OrderedDict()

# Decorator to specify that a function is a command (verb in the CLI)
# Adds the name + function pair to the global dict of commands
# Does not modify/wrap the function it decorates.
def cfbs_command(name):
def inner(function):
global _commands
_commands[name] = function
return function # Unmodified, we've just added it to the dict

return inner


def get_command_names():
global _commands
names = _commands.keys()
return names


@cfbs_command("pretty")
def pretty_command(filenames: list, check: bool, keep_order: bool) -> int:
if not filenames:
user_error("Filenames missing for cfbs pretty command")
Expand Down Expand Up @@ -126,6 +148,7 @@ def pretty_command(filenames: list, check: bool, keep_order: bool) -> int:
return 0


@cfbs_command("init")
def init_command(index=None, masterfiles=None, non_interactive=False) -> int:
if is_cfbs_repo():
user_error("Already initialized - look at %s" % cfbs_filename())
Expand Down Expand Up @@ -283,6 +306,7 @@ def init_command(index=None, masterfiles=None, non_interactive=False) -> int:
return 0


@cfbs_command("status")
def status_command() -> int:

config = CFBSConfig.get_instance()
Expand Down Expand Up @@ -311,6 +335,7 @@ def status_command() -> int:
return 0


@cfbs_command("search")
def search_command(terms: list) -> int:
index = CFBSConfig.get_instance().index
results = {}
Expand Down Expand Up @@ -353,6 +378,7 @@ def search_command(terms: list) -> int:
return 0 if any(results) else 1


@cfbs_command("add")
@commit_after_command("Added module%s %s", [PLURAL_S, FIRST_ARG_SLIST])
def add_command(
to_add: list,
Expand All @@ -365,6 +391,7 @@ def add_command(
return r


@cfbs_command("remove")
@commit_after_command("Removed module%s %s", [PLURAL_S, FIRST_ARG_SLIST])
def remove_command(to_remove: list):
config = CFBSConfig.get_instance()
Expand Down Expand Up @@ -444,6 +471,7 @@ def _get_modules_by_url(name) -> list:
return Result(0, changes_made, msg, files)


@cfbs_command("clean")
@commit_after_command("Cleaned unused modules")
def clean_command(config=None):
return _clean_unused_modules(config)
Expand Down Expand Up @@ -595,6 +623,7 @@ def _update_variable(input_def, input_data):
return changes_made


@cfbs_command("update")
@commit_after_command("Updated module%s", [PLURAL_S])
def update_command(to_update):
config = CFBSConfig.get_instance()
Expand Down Expand Up @@ -772,6 +801,7 @@ def update_command(to_update):
return Result(0, changes_made, msg, files)


@cfbs_command("validate")
def validate_command():
index = CFBSConfig.get_instance().index
if not index:
Expand Down Expand Up @@ -858,18 +888,21 @@ def _download_dependencies(
counter += 1


@cfbs_command("download")
def download_command(force, ignore_versions=False):
config = CFBSConfig.get_instance()
_download_dependencies(config, redownload=force, ignore_versions=ignore_versions)


@cfbs_command("build")
def build_command(ignore_versions=False) -> int:
config = CFBSConfig.get_instance()
init_out_folder()
_download_dependencies(config, prefer_offline=True, ignore_versions=ignore_versions)
perform_build_steps(config)


@cfbs_command("install")
def install_command(args) -> int:
if len(args) > 1:
user_error(
Expand All @@ -896,8 +929,9 @@ def install_command(args) -> int:
return 0


@cfbs_command("help")
def help_command():
pass # no-op here, all *_command functions are presented in help contents
raise ProgrammerError("help_command should not be called, as we use argparse")


def _print_module_info(data):
Expand All @@ -924,6 +958,8 @@ def _print_module_info(data):
print("{}: {}".format(key.title().replace("_", " "), value))


@cfbs_command("show")
@cfbs_command("info")
def info_command(modules):
if not modules:
user_error("info/show command requires one or more module names as arguments")
Expand Down Expand Up @@ -965,11 +1001,7 @@ def info_command(modules):
return 0


# show_command here to auto-populate into help in main.py
def show_command(module):
return info_command(module)


@cfbs_command("input")
@commit_after_command("Added input for module%s", [PLURAL_S])
def input_command(args, input_from="cfbs input"):
config = CFBSConfig.get_instance()
Expand Down Expand Up @@ -1004,6 +1036,7 @@ def input_command(args, input_from="cfbs input"):
return Result(0, do_commit, None, files_to_commit)


@cfbs_command("set-input")
def set_input_command(name, infile):
config = CFBSConfig.get_instance()
module = config.get_module_from_build(name)
Expand Down Expand Up @@ -1086,6 +1119,7 @@ def _compare_list(a, b):
return 0


@cfbs_command("get-input")
def get_input_command(name, outfile):
config = CFBSConfig.get_instance()
module = config.get_module_from_build(name)
Expand Down
22 changes: 8 additions & 14 deletions cfbs/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sys

from cfbs.version import string as version
from cfbs.utils import user_error, is_cfbs_repo
from cfbs.utils import user_error, is_cfbs_repo, ProgrammerError
from cfbs.cfbs_config import CFBSConfig
from cfbs import commands
from cfbs.args import get_args, print_help
Expand Down Expand Up @@ -46,6 +46,10 @@ def main() -> int:
print("")
user_error("No command given")

if args.command not in commands.get_command_names():
print_help()
user_error("Command '%s' not found" % args.command)

if args.masterfiles and args.command != "init":
user_error(
"The option --masterfiles is only for 'cfbs init', not 'cfbs %s'"
Expand All @@ -62,17 +66,6 @@ def main() -> int:
):
user_error("The option --non-interactive is not for cfbs %s" % (args.command))

if args.non_interactive:
print(
"""
Warning: The --non-interactive option is only meant for testing (!)
DO NOT run commands with --non-interactive as part of your deployment
pipeline. Instead, run cfbs commands manually, commit the resulting
cfbs.json and only run cfbs build + cfbs install when deploying your
policy set. Thank you for your cooperation.
""".strip()
)

# Commands you can run outside a cfbs repo:
if args.command == "help":
print_help()
Expand Down Expand Up @@ -155,5 +148,6 @@ def main() -> int:
finally:
file.close()

print_help()
user_error("Command '%s' not found" % args.command)
raise ProgrammerError(
"Command '%s' not handled appropriately by the code above" % args.command
)
4 changes: 4 additions & 0 deletions cfbs/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")


class ProgrammerError(RuntimeError):
pass


def _sh(cmd: str):
# print(cmd)
try:
Expand Down
11 changes: 3 additions & 8 deletions tests/shell/031_get_set_input_pipe.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
set -e
set -x
cd tests/
source shell/common.sh
mkdir -p ./tmp/
cd ./tmp/
touch cfbs.json && rm cfbs.json
Expand Down Expand Up @@ -52,11 +53,5 @@ commit_c=$(git rev-parse HEAD)

test "x$commit_b" = "x$commit_c"

# Error if the file has never been added:
git ls-files --error-unmatch delete-files/input.json

# Error if there are staged (added, not yet commited)
git diff --exit-code --staged

# Error if there are uncommited changes (to tracked files):
git diff --exit-code
git-must-track delete-files/input.json
git-no-diffs
11 changes: 3 additions & 8 deletions tests/shell/032_set_input_unordered.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
set -e
set -x
cd tests/
source shell/common.sh
mkdir -p ./tmp/
cd ./tmp/
touch cfbs.json && rm cfbs.json
Expand Down Expand Up @@ -41,11 +42,5 @@ echo '[
}
]' | cfbs --log=debug set-input delete-files -

# Error if the file has never been added:
git ls-files --error-unmatch delete-files/input.json

# Error if there are staged (added, not yet commited)
git diff --exit-code --staged

# Error if there are uncommited changes (to tracked files):
git diff --exit-code
git-must-track delete-files/input.json
git-no-diffs
14 changes: 6 additions & 8 deletions tests/shell/033_add_commits_local_files.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
set -e
set -x
cd tests/
source shell/common.sh
mkdir -p ./tmp/
cd ./tmp/
rm -rf cfbs.json .git def.json policy.cf foo
Expand Down Expand Up @@ -45,11 +46,8 @@ EOF

cfbs --non-interactive add ./def.json ./policy.cf ./foo

# Error if the file has never been added:
git ls-files --error-unmatch def.json policy.cf foo/bar.json foo/baz.cf

# Error if there are staged (added, not yet commited)
git diff --exit-code --staged

# Error if there are uncommited changes (to tracked files):
git diff --exit-code
git-must-track def.json
git-must-track policy.cf
git-must-track foo/bar.json
git-must-track foo/baz.cf
git-no-diffs
Loading

0 comments on commit 52d1a0b

Please sign in to comment.