dynamic path through json loaded in terraform - json

I’m loading a json file with jsondecode() in terraform, and I need to dynamically lookup a path in the json tree. Eg say I have the following json in file.json:
{
"some1": {
"path1": {
"key1": value1
"key2": value2
}
}
}
If I load this into a local called myjson then I could write local.myjson.some1.path1.key1 to get value 1.
But I need the path to be an input. The following does not work:
locals {
tree = jsondecode("file.json")
path = ["some1", "path1", "key1"]
value = local.tree[local.path]
}
I looked at all the builtin functions in terraform, such as lookup, flatten, etc, I could not see any combination that would allow me to loop over elements of local.path2 to extract successively deeper elements of local.tree. Except try, works nicely but the max depth is hardcoded:
locals {
level1 = try(local.json[local.path[0]], null)
level2 = try(local.level1[local.path[1]], local.level1)
level3 = try(local.level2[local.path[2]], local.level2)
level4 = try(local.level3[local.path[3]], local.level3)
...
result = try(local.levelN[local.path[N]], local.levelN)
}
so regardless of how many levels there actually are in the local.tree, result will contain it.
I can live with hardcoded N, but is there a better way, that does not have that limitation? (short of creating a custom provider that defines a data source that does this)

The Terraform language has no built-in functionality for this sort of arbitrary dynamic traversal.
As you noted in your question, it is possible in principle for a provider to offer this functionality. It wasn't clear to me whether you didn't want to use a provider at all or if you just didn't want to be the one to write it, and so just in case it was the latter I can at least offer a provider I already wrote and published which can potentially address this need, which is called apparentlymart/javascript and exposes a JavaScript interpreter into the Terraform language which you can use for arbitrary complex data manipulation:
terraform {
required_providers {
javascript = {
source = "apparentlymart/javascript"
version = "0.0.1"
}
}
}
variable "traversal_path" {
type = list(string)
}
data "javascript" "example" {
source = <<-EOT
for (var i = 0; i < path.length; i++) {
data = data[path[i]]
}
data
EOT
vars = {
data = jsondecode(file("${path.module}/file.json"))
path = var.traversal_path
}
}
output "result" {
value = data.javascript.example.result
}
I can run this with different values of var.traversal_path to select different parts of the data structure in the JSON file:
$ terraform apply -var='traversal_path=["some1", "path1", "key1"]' -auto-approve
data.javascript.example: Reading...
data.javascript.example: Read complete after 0s
Changes to Outputs:
+ result = "value1"
You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
result = "value1"
$ terraform apply -var='traversal_path=["some1", "path1", "key2"]' -auto-approve
data.javascript.example: Reading...
data.javascript.example: Read complete after 0s
Changes to Outputs:
~ result = "value1" -> "value2"
You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
result = "value2"
$ terraform apply -var='traversal_path=["some1", "path1", "key3"]' -auto-approve
data.javascript.example: Reading...
data.javascript.example: Read complete after 0s
Changes to Outputs:
- result = "value2" -> null
You can apply this plan to save these new output values to the Terraform state, without changing any real infrastructure.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
I included the final example above to be explicit that escaping into JavaScript for this problem means adopting some of JavaScript's behaviors rather than Terraform's, and JavaScript handles looking up a non-existing object property by returning undefined rather than returning an error as Terraform would, and the javascript data source translates that undefined into a Terraform null. If you want to treat that as an error as Terraform would then you'd need to write some logic into the loop to test whether data is defined after each step. You can use the JavaScript throw statement to raise an error from inside the given script.
Of course it's not ideal to embed one language inside another like this, but since the Terraform language is intended for relatively straightforward declarations rather than general computation I think it's reasonable to use an escape-hatch like this if the overall problem fits within the Terraform language but there is one small part of it that would benefit from the generality of a general-purpose language.
Bonus chatter: if you prefer a more functional style to the for loop I used above then you can alternatively make use of the copy of Underscore.js that's embedded inside the provider, using _.propertyOf to handle the traversal in a single statement:
source = <<-EOT
_.propertyOf(data)(path)
EOT

Related

Syntax error during jsonendecode() in Terraform

here's my first bootle to the sea.
I want to create one single secret manager that contains a map of 3 passwords with Terraform IAC.
To do that, I have tried to create a aws_secretmanager_version with
resource "aws_secretsmanager_secret" "secret_master" {
name = "secret-master"
}
resource "aws_secretsmanager_secret_version" "sversion" {
secret_id = aws_secretsmanager_secret.secret_master.id
secret_string = <<EOF
{
"dbPassword": "${random_password.db_password.result}",
"awsSecretAccess": "${random_password.aws_access_key_id.result}",
"secretAccessKey": "${random_password.sec_access_key.result}"
}
EOF
}
data "aws_secretsmanager_secret" "secret_master" {
arn = aws_secretsmanager_secret.secret_master.arn
}
data "aws_secretsmanager_secret_version" "secrets" {
secret_id = data.aws_secretsmanager_secret.secret_master.id
}
locals {
secrets = jsondecode(data.aws_secretsmanager_secret_version.secrets.secret_string)
}
In fact, i followed this tutorial to understand : https://automateinfra.com/2021/03/24/how-to-create-secrets-in-aws-secrets-manager-using-terraform-in-amazon-account/
The problem is the error resulting :
│
│ on sm.tf line 60, in locals:
│ 60: secrets = jsondecode(data.aws_secretsmanager_secret_version.secrets.secret_string)
│ ├────────────────
│ │ data.aws_secretsmanager_secret_version.secrets.secret_string has a sensitive value
│
│ Call to function "jsondecode" failed: invalid character '"' after object key:value pair.
I replaced the random_passwords with strings "eeeee" and verified several times the json syntax. Nothing changed.
Could you help me to learn more about this error ?
This error is saying that your string in data.aws_secretsmanager_secret_version.secrets.secret_string does not have valid JSON syntax.
I have to assume that data.aws_secretsmanager_secret_version.secrets.secret_string is the same as aws_secretsmanager_secret_version.sversion.secret_string here, but I'm not 100% sure since I'm not an expert on AWS secrets manager.
If that is true then I expect what's happening is that one of the strings that you interpolated into the JSON string contains a " character which is therefore causing the resulting string to not be valid JSON.
To guarantee that your result will be valid JSON, you should use jsonencode to produce that string instead of string template, because then Terraform will guarantee to generate valid JSON escaping for you when needed:
resource "aws_secretsmanager_secret_version" "sversion" {
secret_id = aws_secretsmanager_secret.secret_master.id
secret_string = jsonencode({
dbPassword = random_password.db_password.result
awsSecretAccess = random_password.aws_access_key_id.result
secretAccessKey = random_password.sec_access_key.result
})
}
The above secret_string expression first constructs a Terraform object value, and then uses jsonencode to translate that into a string containing an equivalent JSON object, using the translation rules shown in the documentation for jsonencode.
This is tangential to your question, but note also that it's unnecessary and potentially problematic to read back the same object using a data block that this module is managing using a resource block.
In the example you showed is relatively harmless because Terraform can clearly see the dependency relationship between the data blocks and the resource blocks -- but unless these resource types are designed in a very unusual way all this is achieving is telling Terraform to read the same object it already had in memory anyway, which may cause you to hit rate limits faster or make your terraform apply slower.
You can refer directly to jsondecode(aws_secretsmanager_secret_version.sversion.secret_string) to use the value you assigned to that argument in other parts of the configuration, and so I would recommend doing that and not using the data blocks, unless you know that the data sources are doing some kind of transformation on that value that is important to your downstream use of it.

rl_json : Insert function

below my json :
"temperature": {
"level": 8,
"format": function (value) {
return value + ' (C°)';
},
"minimum": null,
...
...
}
My goal is to write key format, I have try this but without much conviction...
package require rl_json
namespace import rl_json::json
set rj "{}"
set s1 {function (value) {
return value + ' (C°)';
}
}
json set rj temperature [json object [list level {number 8} format [json template {{"~L:s1"}}] minimum {null}]]
Error parsing JSON value: Expecting : after object key at offset 8
What you are trying to produce is not (partial) JSON, as defined, but rather general Javascript. As such, you cannot reasonably expect JSON-specific tooling (such as rl_json) to work with it. It is usually better to keep your JSON and your Javascript separated, with the data in JSON (including any dynamic generation) and your Javascript as a static artefact (or generated from something like Typescript). Mixing code and dynamic generation requires great care to get right; it is usually better to try to write things so you don't need to be that careful!
If you must do this defintely-not-advised thing, use something like subst or format to inject the variable parts (which you can generate with rl_json) in the overall string. No code for that from me: my advice is don't do that.

CPP REST SDK JSON - How to create JSON w/ Array and write to file

I'm having troubles with the JSON classes of the CPP REST SDK. I can't figure out when to use json::value, json::object and json::array. Especially the latter two seem very alike. Also the usage of json::array is rather unintuitive to me. Finally I want to write the JSON to a file or at least to stdcout, so I can check it is correct.
It was way easier for me to use json-spirit, but since I want to make REST requests later on I thought I'd save me the string/wstring madness and use the json classes of the CPP REST SDK.
What I want to achieve is a JSON file like this:
{
"foo-list" : [
{
"bar" : "value1",
"bob" : "value2"
}
]
}
This is the code I tried:
json::value arr;
int i{0};
for(auto& thing : things)
{
json::value obj;
obj[L"bar"] = json::value::string(thing.first);
obj[L"bob"] = json::value::string(thing.second);
arr[i++] = obj;
}
json::value result;
result[L"foo-list"] = arr;
Do I really need this extra counter variable i? Seems rather inelegant. Would using json::array/json::object make things nicer? And how do I write my JSON to a file?
This could help you:
json::value output;
output[L"foo-list"][L"bar"] = json::value::string(utility::conversions::to_utf16string("value1"));
output[L"foo-list"][L"bob"] = json::value::string(utility::conversions::to_utf16string("value2"));
output[L"foo-list"][L"bobList"][0] = json::value::string(utility::conversions::to_utf16string("bobValue1"));
output[L"foo-list"][L"bobList"][1] = json::value::string(utility::conversions::to_utf16string("bobValue1"));
output[L"foo-list"][L"bobList"][2] = json::value::string(utility::conversions::to_utf16string("bobValue1"));
If you want to create list, like bobList, you really need to use some iterator variable.
Otherwise you will get only bunch of separate variables.
For output to console use
cout << output.serialize().c_str();
And finally, this will lead to
{
"foo-list":{
"bar":"value1",
"bob":"value2",
"bobList":[
"bobValue1",
"bobValue1",
"bobValue1"
]
}
}
-- To answer your first question, in arrays, JSON values are stored at ordered indices. Thus, arrays can be traversed efficiently compared to objects as objects are more like hashmaps where the input key goes through hashing mechanism every time and a match within a hashtable is found to reach the value of that key. Thus, arrays are efficient especially when we are trying to traverse through a large JSON.
-- To answer your second question. As you mentioned you need to create something like this.
{
"foo-list" : [
{
"bar" : "value1",
"bob" : "value2"
}
]
}
-- If we were to have json::object with these two json values {"bar":"value1"} and {"bob":"value2"} as the value of the key foo-list,(if we were to have curly braces instead of the square above) it can be implemented as
result[U("foo-list")][U("bar")] = "value1";
result[U("foo-list")][U("bob")] = "value2";
-- But here json::object with these two JSON values {"bar":"value1"} and {"bob":"value2"} is at index 0 of a json::array; and this array is the value of the key foo-list. Thus you need the index variable to implement something like
result[U("foo-list")][0][U("bar")] = "value1";
result[U("foo-list")][0][U("bob")] = "value2";
-- To answer your third question, as correctly pointed out by #Zdeno you can use serialize to convert json::value to string and dump it to the file

YAML or JSON library that supports inheritance

We are building a service. It has to read config from a file. We are currently using YAML and Jackson for deserializing the YAML. We have a situation where our YAML file needs to inherit/extend another YAML file(s). E.g., something like:
extends: base.yaml
appName: my-awesome-app
...
thus part of the config is stored in base.yaml. Is there any library that has support for this? Bonus points if it allows to inherit from more than one file. We could change to using JSON instead of YAML.
Neither JSON nor YAML have the ability to include files. Whatever you do will be a pre-processing step where you will be putting the base.yaml and your actual file together.
A crude way of doing this would be:
#include base.yaml
appName: my-awesome-app
Let this be your file. Upon loading, you first read the first line, and if it starts with #include, you replace it with the content of the included file. You need to do this recursively. This is basically what the C preprocessor does with C files and includes.
Drawbacks are:
even if both files are valid YAML, the result may not.
if either files includes a directive end or document end marker (--- or ...), you will end up with two separate documents in one file.
you cannot replace any values from base.yaml inside your file.
So an alternative would be to actually operate on the YAML structure. For this, you need the API of the YAML parser (SnakeYAML in your case) and parse your file with that. You should use the compose API:
private Node preprocess(final Reader myInput) {
final Yaml yaml = new Yaml();
final Node node = yaml.compose(myInput);
processIncludes(node);
return node;
}
private void processIncludes(final Node node) {
if (node instanceof MappingNode) {
final List<NodeTuple> values = ((MappingNode) node).getValue();
for (final NodeTuple tuple: values) {
if ("!include".equals(tuple.getKeyNode().getTag().getValue())) {
final String includedFilePath =
((ScalarNode) tuple.getValueNode()).getValue();
final Node content = preprocess(new FileReader(includedFilePath));
// now merge the content in your preferred way into the values list.
// that will change the content of the node.
}
}
}
}
public String executePreprocessor(final Reader source) {
final Node node = preprocess(source);
final StringWriter writer = new StringWriter();
final DumperOptions dOptions = new DumperOptions()
Serializer ser = new Serializer(new Emitter(writer, dOptions),
new Resolver(), dOptions, null);
ser.open();
ser.serialize(node);
ser.close();
return writer.toString();
}
This code would parse includes like this:
!include : base.yaml
appName: my-awesome-app
I used the private tag !include so that there will not be name clashes with any normal mapping key. Mind the space behind !include. I didn't give code to merge the included file because I did not know how you want to handle duplicate mapping keys. It should not be hard to implement though. Be aware of bugs, I have not tested this code.
The resulting String can be the input to Jackson.
Probably for the same desire, I have created this tool: jq-front.
You can do it by following syntax and combinating with yq command.
extends: [ base.yaml ]
appName: my-awesome-app
...
$ yq -j . your.yaml | jq-front | yq -y .
Note that you need to place file names to be extended in an array since the tool supports multiple inheritance.
Points potentially you don't like are
It's quite a bit slow. (But for configuration information, it might be ok since you can convert it to an expanded file once and you will never not the original one after that for your system)
Objects inside an array cannot behave as expected since the tool relies on * operator of jq.

PowerShell changes return object's type

I am using PowerShell v3 and the Windows PowerShell ISE. I have the following function that works fine:
function Get-XmlNode([xml]$XmlDocument, [string]$NodePath, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
# If a Namespace URI was not given, use the Xml document's default namespace.
if ([string]::IsNullOrEmpty($NamespaceURI)) { $NamespaceURI = $XmlDocument.DocumentElement.NamespaceURI }
# In order for SelectSingleNode() to actually work, we need to use the fully qualified node path along with an Xml Namespace Manager, so set them up.
[System.Xml.XmlNamespaceManager]$xmlNsManager = New-Object System.Xml.XmlNamespaceManager($XmlDocument.NameTable)
$xmlNsManager.AddNamespace("ns", $NamespaceURI)
[string]$fullyQualifiedNodePath = Get-FullyQualifiedXmlNodePath -NodePath $NodePath -NodeSeparatorCharacter $NodeSeparatorCharacter
# Try and get the node, then return it. Returns $null if the node was not found.
$node = $XmlDocument.SelectSingleNode($fullyQualifiedNodePath, $xmlNsManager)
return $node
}
Now, I will be creating a few similar functions, so I want to break the first 3 lines out into a new function so that I don't have to copy-paste them everywhere, so I have done this:
function Get-XmlNamespaceManager([xml]$XmlDocument, [string]$NamespaceURI = "")
{
# If a Namespace URI was not given, use the Xml document's default namespace.
if ([string]::IsNullOrEmpty($NamespaceURI)) { $NamespaceURI = $XmlDocument.DocumentElement.NamespaceURI }
# In order for SelectSingleNode() to actually work, we need to use the fully qualified node path along with an Xml Namespace Manager, so set them up.
[System.Xml.XmlNamespaceManager]$xmlNsManager = New-Object System.Xml.XmlNamespaceManager($XmlDocument.NameTable)
$xmlNsManager.AddNamespace("ns", $NamespaceURI)
return $xmlNsManager
}
function Get-XmlNode([xml]$XmlDocument, [string]$NodePath, [string]$NamespaceURI = "", [string]$NodeSeparatorCharacter = '.')
{
[System.Xml.XmlNamespaceManager]$xmlNsManager = Get-XmlNamespaceManager -XmlDocument $XmlDocument -NamespaceURI $NamespaceURI
[string]$fullyQualifiedNodePath = Get-FullyQualifiedXmlNodePath -NodePath $NodePath -NodeSeparatorCharacter $NodeSeparatorCharacter
# Try and get the node, then return it. Returns $null if the node was not found.
$node = $XmlDocument.SelectSingleNode($fullyQualifiedNodePath, $xmlNsManager)
return $node
}
The problem is that when "return $xmlNsManager" executes the following error is thrown:
Cannot convert the "System.Object[]" value of type "System.Object[]" to type "System.Xml.XmlNamespaceManager".
So even though I have explicitly cast my $xmlNsManager variables to be of type System.Xml.XmlNamespaceManager, when it gets returned from the Get-XmlNamespaceManager function PowerShell is converting it to an Object array.
If I don't explicitly cast the value returned from the Get-XmlNamespaceManager function to System.Xml.XmlNamespaceManager, then the following error is thrown from the .SelectSingleNode() function because the wrong data type is being passed into the function's 2nd parameter.
Cannot find an overload for "SelectSingleNode" and the argument count: "2".
So for some reason PowerShell is not maintaining the data type of the return variable. I would really like to get this working from a function so that I don't have to copy-paste those 3 lines all over the place. Any suggestions are appreciated. Thanks.
What's happening is PowerShell is converting your namespace manager object to a string array.
I think it has to do with PowerShell's nature of "unrolling" collections when sending objects down the pipeline. I think PowerShell will do this for any type implementing IEnumerable (has a GetEnumerator method).
As a work around you can use the comma trick to prevent this behavior and send the object as a whole collection.
function Get-XmlNamespaceManager([xml]$XmlDocument, [string]$NamespaceURI = "")
{
...
$xmlNsManager.AddNamespace("ns", $NamespaceURI)
return ,$xmlNsManager
}
More specifically, what is happening here is that your coding habit of strongly typing $fullyQualifiedModePath is trying to turn the result of the Get (which is a list of objects) into a string.
[string]$foo
will constrain the variable $foo to only be a string, no matter what came back. In this case, your type constraint is what is subtly screwing up the return and making it Object[]
Also, looking at your code, I would personally recommend you use Select-Xml (built into V2 and later), rather than do a lot of hand-coded XML unrolling. You can do namespace queries in Select-Xml with -Namespace #{x="..."}.