Skip to content

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

generic_server . web01 top-level CI class keyword slug

resource . GitRepository . core literal keyword CI class, exact case slug

Two segments for everything with a keyword of its own; three for a second-order resource. The separator is a dot. A slug never contains one.

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:

  1. Decode UTF-8 if the value is not already character data.
  2. Strip accents.
  3. Lowercase.
  4. Replace every run of characters outside [a-z0-9_-] with a single -.
  5. 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:

  1. The CI class declares the attribute as a relationship. This is the authoritative one, and the reference table lists all 131 of them.
  2. 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.
  3. 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.