guide: Design Patterns for Complex Systems section
This commit is contained in:
parent
495dfc9487
commit
280771fe8a
@ -1,2 +1,313 @@
|
|||||||
Design Patterns for Complex Systems
|
Design Patterns for Complex Systems
|
||||||
===================================
|
===================================
|
||||||
|
|
||||||
|
In previous sections we've seen an overview of some different ways an
|
||||||
|
application can decode a language its has defined in terms of the HCL grammar.
|
||||||
|
For many applications, those mechanisms are sufficient. However, there are
|
||||||
|
some more complex situations that can benefit from some additional techniques.
|
||||||
|
This section lists a few of these situations and ways to use the HCL API to
|
||||||
|
accommodate them.
|
||||||
|
|
||||||
|
Interdependent Blocks
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
In some configuration languages, the variables available for use in one
|
||||||
|
configuration block depend on values defined in other blocks.
|
||||||
|
|
||||||
|
For example, in Terraform many of the top-level constructs are also implicitly
|
||||||
|
definitions of values that are available for use in expressions elsewhere:
|
||||||
|
|
||||||
|
.. code-block:: hcl
|
||||||
|
|
||||||
|
variable "network_numbers" {
|
||||||
|
type = list(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "base_network_addr" {
|
||||||
|
type = string
|
||||||
|
default = "10.0.0.0/8"
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
network_blocks = {
|
||||||
|
for x in var.number:
|
||||||
|
x => cidrsubnet(var.base_network_addr, 8, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "cloud_subnet" "example" {
|
||||||
|
for_each = local.network_blocks
|
||||||
|
|
||||||
|
cidr_block = each.value
|
||||||
|
}
|
||||||
|
|
||||||
|
output "subnet_ids" {
|
||||||
|
value = cloud_subnet.example[*].id
|
||||||
|
}
|
||||||
|
|
||||||
|
In this example, the `variable "network_numbers"` block makes
|
||||||
|
``var.base_network_addr`` available to expressions, the
|
||||||
|
``resource "cloud_subnet" "example"`` block makes ``cloud_subnet.example``
|
||||||
|
available, etc.
|
||||||
|
|
||||||
|
Terraform achieves this by decoding the top-level structure in isolation to
|
||||||
|
start. You can do this either using the low-level API or using :go:pkg:`gohcl`
|
||||||
|
with :go:type:`hcl.Body` fields tagged as "remain".
|
||||||
|
|
||||||
|
Once you have a separate body for each top-level block, you can inspect each
|
||||||
|
of the attribute expressions inside using the ``Variables`` method on
|
||||||
|
:go:type:`hcl.Expression`, or the ``Variables`` function from package
|
||||||
|
:go:pkg:`hcldec` if you will eventually use its higher-level API to decode as
|
||||||
|
Terraform does.
|
||||||
|
|
||||||
|
The detected variable references can then be used to construct a dependency
|
||||||
|
graph between the blocks, and then perform a
|
||||||
|
`topological sort <https://en.wikipedia.org/wiki/Topological_sorting>`_ to
|
||||||
|
determine the correct order to evaluate each block's contents so that values
|
||||||
|
will always be available before they are needed.
|
||||||
|
|
||||||
|
Since :go:pkg:`cty` values are immutable, it is not convenient to directly
|
||||||
|
change values in a :go:type:`hcl.EvalContext` during this gradual evaluation,
|
||||||
|
so instead construct a specialized data structure that has a separate value
|
||||||
|
per object and construct an evaluation context from that each time a new
|
||||||
|
value becomes available.
|
||||||
|
|
||||||
|
Using :go:pkg:`hcldec` to evaluate block bodies is particularly convenient in
|
||||||
|
this scenario because it produces :go:type:`cty.Value` results which can then
|
||||||
|
just be directly incorporated into the evaluation context.
|
||||||
|
|
||||||
|
Distributed Systems
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Distributed systems cause a number of extra challenges, and configuration
|
||||||
|
management is rarely the worst of these. However, there are some specific
|
||||||
|
considerations for using HCL-based configuration in distributed systems.
|
||||||
|
|
||||||
|
For the sake of this section, we are concerned with distributed systems where
|
||||||
|
at least two separate components both depend on the content of HCL-based
|
||||||
|
configuration files. Real-world examples include the following:
|
||||||
|
|
||||||
|
* **HashiCorp Nomad** loads configuration (job specifications) in its servers
|
||||||
|
but also needs these results in its clients and in its various driver plugins.
|
||||||
|
|
||||||
|
* **HashiCorp Terraform** parses configuration in Terraform Core but can write
|
||||||
|
a partially-evaluated execution plan to disk and continue evaluation in a
|
||||||
|
separate process later. It must also pass configuration values into provider
|
||||||
|
plugins.
|
||||||
|
|
||||||
|
Broadly speaking, there are two approaches to allowing configuration to be
|
||||||
|
accessed in multiple subsystems, which the following subsections will discuss
|
||||||
|
separately.
|
||||||
|
|
||||||
|
Ahead-of-time Evaluation
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Ahead-of-time evaluation is the simplest path, with the configuration files
|
||||||
|
being entirely evaluated on entry to the system, and then only the resulting
|
||||||
|
*constant values* being passed between subsystems.
|
||||||
|
|
||||||
|
This approach is relatively straightforward because the resulting
|
||||||
|
:go:type:`cty.Value` results can be losslessly serialized as either JSON or
|
||||||
|
msgpack as long as all system components agree on the expected value types.
|
||||||
|
Aside from passing these values around "on the wire", parsing and decoding of
|
||||||
|
configuration proceeds as normal.
|
||||||
|
|
||||||
|
Both Nomad and Terraform use this approach for interacting with *plugins*,
|
||||||
|
because the plugins themselves are written by various different teams that do
|
||||||
|
not coordinate closely, and so doing all expression evaluation in the core
|
||||||
|
subsystems ensures consistency between plugins and simplifies plugin development.
|
||||||
|
|
||||||
|
In both applications, the plugin is expected to describe (using an
|
||||||
|
application-specific protocol) the schema it expects for each element of
|
||||||
|
configuration it is responsible for, allowing the core subsystems to perform
|
||||||
|
decoding on the plugin's behalf and pass a value that is guaranteed to conform
|
||||||
|
to the schema.
|
||||||
|
|
||||||
|
Gradual Evaluation
|
||||||
|
^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
Although ahead-of-time evaluation is relatively straightforward, it has the
|
||||||
|
significant disadvantage that all data available for access via variables or
|
||||||
|
functions must be known by whichever subsystem performs that initial
|
||||||
|
evaluation.
|
||||||
|
|
||||||
|
For example, in Terraform, the "plan" subcommand is responsible for evaluating
|
||||||
|
the configuration and presenting to the user an execution plan for approval, but
|
||||||
|
certain values in that plan cannot be determined until the plan is already
|
||||||
|
being applied, since the specific values used depend on remote API decisions
|
||||||
|
such as the allocation of opaque id strings for objects.
|
||||||
|
|
||||||
|
In Terraform's case, both the creation of the plan and the eventual apply
|
||||||
|
of that plan *both* entail evaluating configuration, with the apply step
|
||||||
|
having a more complete set of input values and thus producing a more complete
|
||||||
|
result. However, this means that Terraform must somehow make the expressions
|
||||||
|
from the original input configuration available to the separate process that
|
||||||
|
applies the generated plan.
|
||||||
|
|
||||||
|
Good usability requires error and warning messages that are able to refer back
|
||||||
|
to specific sections of the input configuration as context for the reported
|
||||||
|
problem, and the best way to achieve this in a distributed system doing
|
||||||
|
gradual evaluation is to send the configuration *source code* between
|
||||||
|
subsystems. This is generally the most compact representation that retains
|
||||||
|
source location information, and will avoid any inconsistency caused by
|
||||||
|
introducing another intermediate serialization.
|
||||||
|
|
||||||
|
In Terraform's, for example, the serialized plan incorporates both the data
|
||||||
|
structure describing the partial evaluation results from the plan phase and
|
||||||
|
the original configuration files that produced those results, which can then
|
||||||
|
be re-evalauated during the apply step.
|
||||||
|
|
||||||
|
In a gradual evaluation scenario, the application should verify correctness of
|
||||||
|
the input configuration as completely as possible at each state. To help with
|
||||||
|
this, :go:pkg:`cty` has the concept of
|
||||||
|
`unknown values <https://github.com/zclconf/go-cty/blob/master/docs/concepts.md#unknown-values-and-the-dynamic-pseudo-type>`_,
|
||||||
|
which can stand in for values the application does not yet know while still
|
||||||
|
retaining correct type information. HCL expression evaluation reacts to unknown
|
||||||
|
values by performing type checking but then returning another unknown value,
|
||||||
|
causing the unknowns to propagate through expressions automatically.
|
||||||
|
|
||||||
|
.. code-block:: go
|
||||||
|
|
||||||
|
ctx := &hcl.EvalContext{
|
||||||
|
Variables: map[string]cty.Value{
|
||||||
|
"name": cty.UnknownVal(cty.String),
|
||||||
|
"age": cty.UnknownVal(cty.Number),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
val, moreDiags := expr.Value(ctx)
|
||||||
|
diags = append(diags, moreDiags...)
|
||||||
|
|
||||||
|
Each time an expression is re-evaluated with additional information, fewer of
|
||||||
|
the input values will be unknown and thus more of the result will be known.
|
||||||
|
Eventually the application should evaluate the expressions with no unknown
|
||||||
|
values at all, which then guarantees that the result will also be wholly-known.
|
||||||
|
|
||||||
|
Static References, Calls, Lists, and Maps
|
||||||
|
-----------------------------------------
|
||||||
|
|
||||||
|
In most cases, we care more about the final result value of an expression than
|
||||||
|
how that value was obtained. A particular list argument, for example, might
|
||||||
|
be defined by the user via a tuple constructor, by a `for` expression, or by
|
||||||
|
assigning the value of a variable that has a suitable list type.
|
||||||
|
|
||||||
|
In some special cases, the structure of the expression is more important than
|
||||||
|
the result value, or an expression may not *have* a reasonable result value.
|
||||||
|
For example, in Terraform there are a few arguments that call for the user
|
||||||
|
to name another object by reference, rather than provide an object value:
|
||||||
|
|
||||||
|
.. code-block:: hcl
|
||||||
|
|
||||||
|
resource "cloud_network" "example" {
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "cloud_subnet" "example" {
|
||||||
|
cidr_block = "10.1.2.0/24"
|
||||||
|
|
||||||
|
depends_on = [
|
||||||
|
cloud_network.example,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
The ``depends_on`` argument in the second ``resource`` block *appears* as an
|
||||||
|
expression that would construct a single-element tuple containing an object
|
||||||
|
representation of the first resource block. However, Terraform uses this
|
||||||
|
expression to construct its dependency graph, and so it needs to see
|
||||||
|
specifically that this expression refers to ``cloud_network.example``, rather
|
||||||
|
than determine a result value for it.
|
||||||
|
|
||||||
|
HCL offers a number of "static analysis" functions to help with this sort of
|
||||||
|
situation. These all live in the :go:pkg:`hcl` package, and each one imposes
|
||||||
|
a particular requirement on the syntax tree of the expression it is given,
|
||||||
|
and returns a result derived from that if the expression conforms to that
|
||||||
|
requirement.
|
||||||
|
|
||||||
|
.. go:currentpackage:: hcl
|
||||||
|
|
||||||
|
.. go:function:: func ExprAsKeyword(expr Expression) string
|
||||||
|
|
||||||
|
This function attempts to interpret the given expression as a single keyword,
|
||||||
|
returning that keyword as a string if possible.
|
||||||
|
|
||||||
|
A "keyword" for the purposes of this function is an expression that can be
|
||||||
|
understood as a valid single identifier. For example, the simple variable
|
||||||
|
reference ``foo`` can be interpreted as a keyword, while ``foo.bar``
|
||||||
|
cannot.
|
||||||
|
|
||||||
|
As a special case, the language-level keywords ``true``, ``false``, and
|
||||||
|
``null`` are also considered to be valid keywords, allowing the calling
|
||||||
|
application to disregard their usual meaning.
|
||||||
|
|
||||||
|
If the given expression cannot be reduced to a single keyword, the result
|
||||||
|
is an empty string. Since an empty string is never a valid keyword, this
|
||||||
|
result unambiguously signals failure.
|
||||||
|
|
||||||
|
.. go:function:: func AbsTraversalForExpr(expr Expression) (Traversal, Diagnostics)
|
||||||
|
|
||||||
|
This is a generalization of ``ExprAsKeyword`` that will accept anything that
|
||||||
|
can be interpreted as a *traversal*, which is a variable name followed by
|
||||||
|
zero or more attribute access or index operators with constant operands.
|
||||||
|
|
||||||
|
For example, all of ``foo``, ``foo.bar`` and ``foo[0]`` are valid
|
||||||
|
traversals, but ``foo[bar]`` is not, because the ``bar`` index is not
|
||||||
|
constant.
|
||||||
|
|
||||||
|
This is the function that Terraform uses to interpret the items within the
|
||||||
|
``depends_on`` sequence in our example above.
|
||||||
|
|
||||||
|
As with ``ExprAsKeyword``, this function has a special case that the
|
||||||
|
keywords ``true``, ``false``, and ``null`` will be accepted as if they were
|
||||||
|
variable names by this function, allowing ``null.foo`` to be interpreted
|
||||||
|
as a traversal even though it would be invalid if evaluated.
|
||||||
|
|
||||||
|
If error diagnostics are returned, the traversal result is invalid and
|
||||||
|
should not be used.
|
||||||
|
|
||||||
|
.. go:function:: func RelTraversalForExpr(expr Expression) (Traversal, Diagnostics)
|
||||||
|
|
||||||
|
This is very similar to ``AbsTraversalForExpr``, but the result is a
|
||||||
|
*relative* traversal, which is one whose first name is considered to be
|
||||||
|
an attribute of some other (implied) object.
|
||||||
|
|
||||||
|
The processing rules are identical to ``AbsTraversalForExpr``, with the
|
||||||
|
only exception being that the first element of the returned traversal is
|
||||||
|
marked as being an attribute, rather than as a root variable.
|
||||||
|
|
||||||
|
.. go:function:: func ExprList(expr Expression) ([]Expression, Diagnostics)
|
||||||
|
|
||||||
|
This function requires that the given expression be a tuple constructor,
|
||||||
|
and if so returns a slice of the element expressions in that constructor.
|
||||||
|
Applications can then perform further static analysis on these, or evaluate
|
||||||
|
them as normal.
|
||||||
|
|
||||||
|
If error diagnostics are returned, the result is invalid and should not be
|
||||||
|
used.
|
||||||
|
|
||||||
|
This is the fucntion that Terraform uses to interpret the expression
|
||||||
|
assigned to ``depends_on`` in our example above, then in turn using
|
||||||
|
``AbsTraversalForExpr`` on each enclosed expression.
|
||||||
|
|
||||||
|
.. go:function:: func ExprMap(expr Expression) ([]KeyValuePair, Diagnostics)
|
||||||
|
|
||||||
|
This function requires that the given expression be an object constructor,
|
||||||
|
and if so returns a slice of the element key/value pairs in that constructor.
|
||||||
|
Applications can then perform further static analysis on these, or evaluate
|
||||||
|
them as normal.
|
||||||
|
|
||||||
|
If error diagnostics are returned, the result is invalid and should not be
|
||||||
|
used.
|
||||||
|
|
||||||
|
.. go:function:: func ExprCall(expr Expression) (*StaticCall, Diagnostics)
|
||||||
|
|
||||||
|
This function requires that the given expression be a function call, and
|
||||||
|
if so returns an object describing the name of the called function and
|
||||||
|
expression objects representing the call arguments.
|
||||||
|
|
||||||
|
If error diagnostics are returned, the result is invalid and should not be
|
||||||
|
used.
|
||||||
|
|
||||||
|
The ``Variables`` method on :go:type:`hcl.Expression` is also considered to be
|
||||||
|
a "static analysis" helper, but is built in as a fundamental feature because
|
||||||
|
analysis of referenced variables is often important for static validation and
|
||||||
|
for implementing interdependent blocks as we saw in the section above.
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user