Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions promise-types/json/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
Promise type for manipulating `json` files

## Attributes

| Name | Type | Description |
|---------------|-----------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| `object` | `data container` | json object type. It can also be json arrays |
| `array` | `slist`, `rlist`, `ilist`, `data array` | json array type. `slist`, `rlist` and `ilist` will only create string arrays. To create array of other types, use `data array` |
| `string` | `string` | json string type |
| `number` | `real`, `int` | json number type |
| `primitive` | `string` | Primitives are values that are either `"true"`, `"false"` or `"null"` in json |

## Examples

### Write to a whole file

To write to a json file, you can do:

```cfengine3
bundle agent main
{
json:
"/tmp/newfile.json"
array => '["hello", "world"]';
}
```

The resulting `/tmp/newfile.json` will only contain the array:

```json
["hello", "world"]
```

If the `/tmp/newfile.json` doesn't exist, it will be created. If it exists and contains some data, they will be overwritten.

### Write to a specific field

Given a json file `/tmp/oldfile.json`,
```json
{
"foo": "bar"
}
```

we can modify/append a field by doing:

```cfengine3
bundle agent main
{
json:
"/tmp/oldfile.json:greeting"
array => '["hello", "world"]';
}
```

And the content of `/tmp/oldfile.json` will become:

```json
{
"foo": "bar",
"greeting": ["hello", "world"]
}
```

If the field doesn't exist, it is appended. If it already exists, its data will be overwritten.

### Writing types

In order to write compound type such as arrays containg booleans, numbers, etc... One has to use the `data container` type in the policy.

To see what happens if we use

```cfengine3
bundle agent main
{
vars:
"json_data"
data => '[1.2, true, "hello!"]';

"real_list"
rlist => {"1.2", "2.3"};
"bool_list"
slist => {"true", "false"};

json:
"/tmp/example_1.json:json_data"
array => "$(json_data)";

"/tmp/example_2.json:real_list"
array => "$(real_list)";
"/tmp/example_2.json:bool_list"
array => "$(bool_list)";
}
```

We can compare the content of `/tmp/example_1.json` and `/tmp/example_2.json`:

```json
{
"json_data": [1.2, true, "hello!"]
}
```

```json
{
"real_list": ["1.2", "2.3"],
"bool_list": ["true", "false"]
}
```

As we can see, using slist, rlist or ilist to write arrays will always result in array of strings. If we want more complex arrays using containg number, true, false or null, then we need to use the `data container` type.

## Not implemented yet

The copy attribute allows to copy the content of a json file into another json file. For example, `/tmp/oldfile.json` contains the following:

```json
{
"hello": "world"
}
```

We can copy it into the `/tmp/newfile.json` in the field `"oldfile"` by doing:

```cfengine3
bundle agent main
{
json:
"/tmp/newfile.json:oldfile"
copy => "/tmp/oldfile.json";
}
```

```json
{
"oldfile": {
"hello": "world"
}
}
```


## Authors

This software was created by the team at [Northern.tech](https://northern.tech), with many contributions from the community.
Thanks everyone!

## Contribute

Feel free to open pull requests to expand this documentation, add features, or fix problems.
You can also pick up an existing task or file an issue in [our bug tracker](https://northerntech.atlassian.net/).

## License

This software is licensed under the MIT License. See LICENSE in the root of the repository for the full license text.
6 changes: 6 additions & 0 deletions promise-types/json/enable.cf
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
promise agent json
# @brief Define json promise type
{
path => "$(sys.workdir)/modules/promises/json_promise_type.py";
interpreter => "/usr/bin/python3";
}
13 changes: 13 additions & 0 deletions promise-types/json/example.cf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
promise agent json
# @brief Define json promise type
{
path => "$(sys.workdir)/modules/promises/json_promise_type.py";
interpreter => "/usr/bin/python3";
}

bundle agent main
{
json:
"/tmp/myusers.json:name"
string => "John"
}
152 changes: 152 additions & 0 deletions promise-types/json/json_promise_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import os
import json

from cfengine import PromiseModule, ValidationError, Result, AttributeObject


def is_number(num):
try:
float(num)
return True
except ValueError:
return False


def is_json_serializable(string):
try:
json.loads(string)
return True
except json.JSONDecodeError:
return False


class JsonPromiseTypeModule(PromiseModule):

def __init__(self, **kwargs):
super(JsonPromiseTypeModule, self).__init__(
name="json_promise_module", version="0.0.1", **kwargs
)

self.types = ["object", "array", "string", "number", "primitive"]
self.valid_attributes = (
self.types
) # for now, the only valid attributes are the types.

def create_attribute_object(self, attributes):
data = {t: None for t in self.valid_attributes}
for attr, val in attributes.items():
data[attr] = val
return AttributeObject(data)

def validate_promise(self, promiser, attributes, metadata):

for attr in attributes:
if attr not in self.valid_attributes:
raise ValidationError("Unknown attribute '{}'".format(attr))

present_types = [t for t in self.types if t in attributes]
if present_types == 0:
raise ValidationError(
"The promiser '{}' is missing a type attribute. The possible types are {}".format(
promiser, str(self.types)
)
)
elif len(present_types) > 1:
raise ValidationError(
"The attributes {} cannot be together".format(str(self.types))
)

filename, _, _ = promiser.partition(":")
if os.path.exists(filename) and not os.path.isfile(filename):
raise ValidationError(
"'{}' already exists and is not a file".format(filename)
)

if not filename.endswith(".json"):
raise ValidationError("'{}' is not a json file")

model = self.create_attribute_object(attributes)
if (
model.object
and isinstance(model.object, str)
and not is_json_serializable(model.object)
):
raise ValidationError(
"'{}' is not a valid data container".format(model.object)
)

if model.array:
if isinstance(model.array, str):
if not is_json_serializable(model.array):
raise ValidationError(
"'{}' is not a valid list".format(model.array)
)

if not isinstance(json.loads(model.array), list):
raise ValidationError(
"'{}' is not a valid data".format(model.array)
)

elif not isinstance(model.array, list):
raise ValidationError(
"'{}' is not a valid data array".format(model.array)
)

if model.number and not is_number(model.number):
raise ValidationError(
"'{}' is not a valid int or real".format(model.number)
)

if model.primitive and model.primitive not in ["true", "false", "null"]:
raise ValidationError(
"expected 'true', 'false' or 'null' but got '{}".format(model.primitive)
)

def evaluate_promise(self, promiser, attributes, metadata):
model = self.create_attribute_object(attributes)
filename, _, field = promiser.partition(":")

# type conversion

datatype = next(t for t in self.types if t in attributes)

match datatype:
case "object" | "array":
data = (
json.loads(attributes[datatype])
if isinstance(attributes[datatype], str)
else attributes[datatype]
)
case "number":
data = float(model.number) if "." in model.number else int(model.number)
case "primitive":
data = None if model.primitive == "null" else model.primitive == "true"
case _: # strings
data = attributes[datatype]

# json manipulation

try:
with open(filename, "r+") as f:
content = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
content = {}

if field:
if field in content and content[field] == data:
Result.KEPT
content[field] = data
else:
if content == data:
Result.KEPT
content = data

with open(filename, "w") as f:
json.dump(content, f, indent=4)

self.log_info("Updated '{}'".format(filename))
Result.REPAIRED


if __name__ == "__main__":
JsonPromiseTypeModule().start()
Loading