Addresses and references
An address names an object. It is what a block's type and labels spell, what a file is named after, what a reference points at, and what a filter pattern matches. Nothing in a Clarive HCL file identifies an object any other way.
| Block | Address |
|---|---|
generic_server "web01" {} |
generic_server.web01 |
resource "GitRepository" "core" {} |
resource.GitRepository.core |
role "deploy-manager" {} |
role.deploy-manager |
user "bob" {} |
user.bob |
group "developers" {} |
group.developers |
pipeline "deploy-foo" {} |
pipeline.deploy-foo |
ws "upload" {} |
ws.upload |
Grammar¶
| Form | Segments | Example |
|---|---|---|
<top-level CI class>.<slug> |
2 | generic_server.web01 |
resource.<CI class>.<slug> |
3 | resource.GitRepository.core |
<rule keyword>.<slug> |
2 | pipeline.deploy-foo |
role.<slug> |
2 | role.deploy-manager |
user.<slug> |
2 | user.bob |
group.<slug> |
2 | group.developers |
<collection keyword>.<slug> |
2 | calendar.holidays |
Address parsing never fails on an unknown leading segment. Anything that is not
a known keyword is assumed to be a CI class, so nonsense.foo parses as
resource.nonsense.foo. That is what lets a value like foo.bar in an
attribute be distinguished from an address by whether it resolves, not by its
shape alone.
Slugs¶
A slug is derived from an object's name:
- Decode UTF-8 if the value is not already character data.
- Strip accents.
- Lowercase.
- Replace every run of characters outside
[a-z0-9_-]with a single-. - Collapse runs of
-, then trim leading and trailing-.
Underscores survive. There is no length limit and no truncation.
| Name | Slug |
|---|---|
Deploy to GitHub |
deploy-to-github |
ECHO_DB_TEST |
echo_db_test |
Product#product |
product-product |
Mixed Case |
mixed-case |
a/b\c |
a-b-c |
---foo--- |
foo |
Café Olé |
cafe-ole |
Ñandú |
nandu |
web01 |
web01 |
Slugification is idempotent: slugging a slug returns it unchanged.
When the slug loses the name¶
The label carries identity. A name attribute is written only when the real
name cannot be recovered from the label:
group "developers" {
name = "Developers"
description = "Dev team"
}
Developers slugs to developers, which is not the same string, so name
appears. foo-group slugs to itself, so no name is written. On import the
name attribute wins over the label.
A user block carries username in the same role, never name:
user "baz" {
username = "BAZ"
email = "[email protected]"
}
Top-level versus second-order¶
Thirty-seven CI classes have a block keyword of their own. Every other class —
including any class created in the UI, which has no Perl file at all — is
written with the literal keyword resource and two labels.
| Written | Result |
|---|---|
generic_server "web01" {} |
the top-level spelling |
resource "generic_server" "web01" {} |
accepted, same object, exports back as the short form |
resource "GitRepository" "core" {} |
the only spelling GitRepository has |
GitRepository "core" {} |
HCL200 Unknown block type GitRepository |
The long spelling is accepted everywhere the short one is, and addresses
canonicalise to the short form. cla export always writes the short form for a
top-level class. cla fmt does not rewrite the keyword — a hand-written
long spelling survives formatting unchanged.
Writing the same object in both spellings inside one import is a duplicate
address: HCL221 Duplicate address generic_server.web01,
already declared at ..., and the first declaration wins.
The full list of top-level classes, and the attribute table for every class, is on the Resources page.
resource "user" is refused¶
resource "user" "bob" {
password = "owned"
}
HCL214:
Write this as `user "..." {}`, not as a resource -- it is not administered like one
The same for resource "UserGroup" "x", which must be written group "x".
A user is not a resource that happens to be spelled user. The family is what
the permission check reads: resources are gated on action.admin.ci.modify,
users on action.admin.users. The second-order spelling hardcoded the family
as resource whatever the class label said, so it let someone holding only the
CI action write a user — past the credential guard, which only the user decoder
has.
It is refused rather than redirected. Every one of these families has a first-class block of its own, and quietly rewriting what the author asked for is how the confusion started. An ordinary class in the second-order spelling is untouched, which is what that spelling is for.
The reading side follows the same rule. resource.user.bob as an address is
not refused — it is reclassified. The family follows the class, never the
spelling, so the lookup reports family user and the permission check gates on
action.admin.users.
Clash suffixes¶
Two objects of the same family and class whose names slug identically get
_1, _2, … appended, in creation order:
generic_server "foo" {
name = "Foo"
}
generic_server "foo_1" {
name = "FOO"
}
The suffix is an underscore and a decimal number starting at 1. The real name
is then carried by the explicit name attribute, which is how the two stay
distinguishable.
Reservation is per class namespace, not per base slug. If an object literally
named foo_1 already exists, a collided foo skips to foo_2 rather than
fighting it. Ordering is by created_on, then ts, then _id; failing all
three, by the mid with its numeric tail zero-padded, so generic_server-9
sorts before generic_server-10 and labels are stable across exports.
Suffixes are assigned before filtering and before the dependency walk, so a reference to a suffixed object points at the label it actually got rather than at a re-slugified name.
This holds for every family that can clash, not only resources: two user groups
both called Foo, two roles whose names differ only in punctuation (Foo Bar
and Foo (Bar)), two statuses both called Done. A user's groups, a grant's
id_role or id_status, a rule step's new_status all name the suffixed label
of the exact object they mean, and a partial export (--include) keeps the
labels dealt over the whole installation.
Suffixes are not generated on import. A file that says generic_server "foo_1"
declares an object whose address is generic_server.foo_1. To find the live
object behind an address, the import deals the labels over the installation the
same way the export does, so generic_server.foo_1 is the second Foo created
-- the object the export gave that label -- and never whichever Foo a name
lookup happens to return first.
Because labels follow creation order, deleting the older of two clashing objects
moves the survivor from foo_1 down to foo. Export again after such a delete
rather than importing files written before it.
References¶
generic_server "web01" {
hostname = "web01.example.invalid"
proxy = generic_server.bastion
}
generic_server "bastion" {
hostname = "bastion.example.invalid"
}
group "developers" {
grant {
id_role = role.deploy-manager
type = "project"
mid = project.foo
}
}
user "alice" {
email = "[email protected]"
groups = [group.developers]
}
user "bob" {
grant {
id_role = role.deploy-manager
type = "area"
mid = project.foo
}
}
role "deploy-manager" {
grant {
action = "action.job.create"
bounds {
bl = "PROD"
}
}
}
project "foo" {}
One file, seven objects, six cross-references. The order of blocks in a file never matters, and neither does the order files are read in. Objects are written in dependency order and references are linked in a second pass once everything exists, so two objects that point at each other are ordinary and need nothing special.
A reference that is neither in the files nor already installed is
HCL230 Unresolved reference generic_server.gone in
generic_server.web01 — an error, and the import stops. A dangling reference
is how a migration quietly corrupts a target.
--allow-missing downgrades it to a warning and skips whatever depended on it.
The skip is contagious: if B needs a missing A and C needs B, both are skipped.
Which attributes hold references¶
Three mechanisms decide, in this order:
- The CI class declares the attribute as a relationship. This is the authoritative one, and the reference table lists all 131 of them.
- The attribute name is in the reference-key table, matched at any depth
inside a nested structure:
id_role,role,user_role,id_category,category_id,categories,category,pipelines,blueprint,id_rule,rule,id_status,mid_status,statuses,id_repo,host,server,repo,repos,webhook,project,projects,bl,node. - On import, any value that merely looks like an address
(
/\A[a-z_]+\.[a-z0-9_.-]+\z/i) in a grant or bound is resolved as one.
The name table is a hint about which family to try first, not the defence. The
defence is that an id-shaped value under an id-shaped key which cannot be
turned into an address is refused outright rather than written. Id-shaped keys
are id_*, *_id, mid, *_mid, *_ids, *_mids; an id-shaped value
matches <class>-<number>.
An address that cannot be resolved on import is left as the address rather than silently becoming nothing, so the failure stays visible in the plan.
Renames¶
generic_server "web01" {
hostname = "web01.example.invalid"
}
moved {
to = generic_server.web01
names = ["oldweb", "web-01"]
}
| Attribute | Required | Type | Meaning |
|---|---|---|---|
to |
yes — HCL211 | reference | the object's current address |
names |
no, defaults [] |
list of strings | previous names, chronological, oldest first |
moved {} takes no labels. names holds bare names, not addresses — the
family and class come from to.
A moved block only does anything when there is nothing live at the new
address. The most recent old name is tried first; the first one that is live
becomes the source, and the plan entry is RENAME. If the old object is also
gone, the entry is an ordinary CREATE.
cla export re-emits moved {} from the rename history recorded by previous
imports, so a renamed object keeps its trail. --no-moved suppresses that.
Once a rename has been applied, a second import of the same files sees the
object already at its new address and plans an UPDATE rather than a second
rename.
Renames and --prune together¶
A moved {} block and --prune in the same run are safe. Any address an entry
was renamed away from is excluded from the prune candidates — otherwise the
rename would land and the prune would immediately delete the object it just
renamed.
Deletes¶
Nothing is deleted because a file stopped mentioning it. There are exactly two ways to remove an object.
removed {
at = generic_server.decommissioned
}
| Attribute | Required | Type |
|---|---|---|
at |
yes — HCL212 | reference |
A tombstone for something already gone is a warning, not an error —
HCL240 Nothing to remove at generic_server.gone, it is
already gone — because the whole point of a tombstone is that it stays in the
files after the object is gone.
The other way is cla import --prune, which proposes deleting live objects
absent from the files, scoped to the families the files actually talk about.
Scope keys are <family>, resource/<CI class> and rule/<rule type>, so
importing only roles can never propose deleting a resource, and a file holding
only pipelines can never propose deleting a workflow.
Either way the deletions appear in the plan and need the same confirmation. There is no rollback.
The clarive {} block¶
clarive { ... } parses and is silently skipped. It is reserved for a future
format header. Anything inside it is ignored.
Reserved keywords¶
These words are block keywords and cannot be used as a top-level CI class keyword, even by a class that consumes the top-level role:
blueprint board calendar category clarive dash dashboard event
form group independent moved notification pipeline removed report
report_rule resource role rule schedule topic user variable
webservice workflow ws
Two classes are exceptions because they are the rightful owner of the keyword
they collide with: the variable CI keeps variable, and the report CI
keeps report — which is why the report rule type takes the qualified
keyword report_rule. Saved reports are objects people manage and move between
installs; the report rule type is rare by comparison.
Filter patterns¶
--filter on cla export selects what is encoded. --filter on cla diff
narrows what is displayed. Both use the same grammar.
cla export --filter 'generic_server.*'
cla export --filter 'role.*,project.*'
cla export --filter '**' --filter '!generic_server.scratch-*'
cla diff --filter 'pipeline.deploy-.*'
| Construct | Meaning |
|---|---|
* |
[^.]* — stays inside one address segment |
** |
.* — crosses segments |
? |
one character other than . |
leading ! |
exclusion, applied after the includes |
, |
splits one argument into several patterns |
| repeated flag | adds patterns |
A pattern containing any of ^ $ ( ) [ ] + \ is treated as a raw, unanchored
regular expression instead of a glob. A glob is anchored at both ends. With
no patterns at all, everything matches; with only exclusions, everything starts
included.
Patterns are matched against every spelling of an address, so
resource.generic_server.* matches an object whose canonical address is
generic_server.alpha.
Addresses as filenames¶
By default cla export writes one file per object, named for its canonical
address:
cla-objects/prod/generic_server.web01.hcl
cla-objects/prod/resource.GitRepository.core.hcl
cla-objects/prod/pipeline.deploy-foo.hcl
cla-objects/prod/role.deploy-manager.hcl
cla-objects/prod/user.bob.hcl
The file name is not load-bearing. cla import reads whatever blocks it finds,
in whatever files, in any order. --pack puts everything into one file and
changes nothing about how it imports.