Design documents provide detailed implementation guidance for CCM’s resource types, providers, and internal components. They are intended for developers contributing to CCM or those seeking to understand specific implementation details.
For end-user documentation on how to use resources, see Resources.
Note
These design documents are largely written with AI assistance and reviewed before publication.
Contents
Each design document covers:
Purpose and scope: What the component does and its responsibilities
Architecture: How the component fits into CCM’s overall design
Implementation details: Key data structures, interfaces, and algorithms
Provider contracts: Requirements for implementing new providers
Testing considerations: How to test the component
Available Documents
Archive Type: Archive resource for downloading and extracting archives
Apply Type: Apply resource for composing manifests from reusable parts
Multiple actions are joined with “. " (e.g., “Would have downloaded. Would have extracted”).
Desired State Validation
After applying changes (in non-noop mode), the type verifies the archive reached the desired state by calling Status() again and checking all conditions. If validation fails, ErrDesiredStateFailed is returned.
Subsections of Archive Type
HTTP Provider
This document describes the implementation details of the HTTP archive provider for downloading and extracting archives from HTTP/HTTPS URLs.
Provider Selection
The HTTP provider is selected when:
The URL scheme is http or https
The archive file extension is supported (.tar.gz, .tgz, .tar, .zip)
The required extraction tool (tar or unzip) is available in PATH
The IsManageable() function checks these conditions and returns a priority of 1 if all are met.
Operations
Download
Process:
Parse the URL and add Basic Auth credentials if username/password provided
Create HTTP request with custom headers (if specified)
Execute GET request via util.HttpGetResponse()
Verify HTTP 200 status code
Create temporary file in the same directory as the target
Timing: Checksum is verified after download completes but before the atomic rename. This ensures:
Corrupted downloads are never placed at the target path
Temp file is cleaned up on mismatch
Clear error message with both expected and actual checksums
Security Considerations
Credential Handling
Credentials in URL are redacted in log messages via util.RedactUrlCredentials()
Basic Auth header is set by Go’s http.Request.SetBasicAuth(), not manually constructed
Archive Extraction
Extraction uses system tar/unzip commands
No path traversal protection beyond what the tools provide
ExtractParent must be an absolute path (validated in model)
Temporary Files
Created with os.CreateTemp() using pattern <archive-name>-*
Deferred removal ensures cleanup on all exit paths
Ownership set before content written
Platform Support
The provider is Unix-only due to:
Dependency on util.GetFileOwner() which uses syscall for UID/GID resolution
Dependency on util.ChownFile() for ownership management
Timeouts
Operation
Timeout
Configurable
HTTP Download
1 minute (default in HttpGetResponse)
No
Archive Extraction
1 minute
No
Large archives may require increased timeouts in future versions.
Apply Type
This document describes the design of the apply resource type for composing manifests from smaller reusable manifests.
Overview
The apply resource resolves and executes a child manifest within the parent manifest’s execution context. The child manifest shares the parent’s manager and session, allowing resource ordering and subscribe relationships across manifest boundaries.
Key behaviors:
Noop strengthening: A parent in noop mode forces all children into noop mode, regardless of the child’s noop property
Health check strengthening: Same semantics as noop; health check mode can only be strengthened, never weakened
Recursion depth limiting: Nested apply resources are capped at a configurable maximum depth (default 10) to prevent infinite loops
Transitive trust control: The allow_apply property prevents a child manifest from containing its own apply resources
Provider Interface
Apply providers must implement the ApplyProvider interface:
Exceeding the maximum depth returns an error before iterating any child resources.
Transitive Trust
The allow_apply property controls whether a child manifest may contain its own apply resources. When allow_apply is false, the child manifest is scanned for apply resources after resolution but before execution. If any are found, an error is returned.
This provides a mechanism to limit the trust boundary when including manifests authored by others.
allow_apply value
Child contains apply resources
Result
true (default)
Yes
Allowed
true (default)
No
Allowed
false
Yes
Error
false
No
Allowed
Data Handling
The data property provides key-value data to the child manifest. This data is passed through the WithOverridingResolvedData option and merged into the resolved data after the child manifest’s own data resolution.
External data (CLI overrides) always persists through the merge. The parent’s original data is restored after child execution via the state save/restore mechanism.
Subscribe Behavior
Apply resources support the standard subscribe property. Subscribe targets use the apply#name format:
The provider only strengthens noop mode, never weakens it. If the parent manager is already in noop mode, the child inherits that regardless of its own noop property. If the parent is not in noop mode and the resource sets noop: true, the provider enables noop on the manager before resolution.
Parent noop
Resource noop
Action
true
false
No change, parent noop already active
true
true
No change, parent noop already active
false
true
Enable noop on manager
false
false
No change
Health check mode follows the same strengthening pattern. The effective health check mode is true if either the parent or the resource sets it.
Execute Options:
The provider builds these options to control child manifest behavior:
Option
Condition
Purpose
WithSkipSession()
Always
Reuse parent session instead of creating a new one
WithCurrentDepth(n)
Always
Track recursion depth for nested apply resources
WithOverridingResolvedData
data property is set
Merge resource data into the child’s resolved data
WithDenyApplyResources()
allow_apply is false
Prevent child from containing apply resources
State Capture and Restore
The provider saves three pieces of manager state before manifest resolution and restores them after execution via defer. This ensures restoration runs even if resolution or execution fails.
Field
Capture
Restore
Noop mode
mgr.NoopMode()
mgr.SetNoopMode(saved)
Working directory
mgr.WorkingDirectory()
mgr.SetWorkingDirectory(saved)
Data
mgr.Data()
mgr.SetData(saved)
State capture happens before any resolve or mutation calls. This ordering is critical because ResolveManifestUrl mutates the manager’s working directory and data during resolution.
Restoration ensures that subsequent resources in the parent manifest see the original manager state. Without it, a child manifest’s working directory and data changes would leak into sibling resources.
Path Resolution
The resource name property specifies a file path relative to the parent manifest’s directory. During resolution, ResolveManifestFilePath joins relative paths with the manager’s current working directory before opening the file.
For nested apply resources, each level sets the working directory to its own manifest’s parent directory. The state restore ensures the working directory returns to the correct value after each child completes.
/opt/ccm/manifest.yaml WD = /opt/ccm/
apply: sub/manifest.yaml resolves to /opt/ccm/sub/manifest.yaml
WD = /opt/ccm/sub/
apply: lib/manifest.yaml resolves to /opt/ccm/sub/lib/manifest.yaml
WD = /opt/ccm/sub/lib/
(restore WD to /opt/ccm/sub/)
(restore WD to /opt/ccm/)
Child Resource Inspection
After execution, the provider iterates over child resources to count outcomes using the shared session:
Outcome
Detection method
Effect
Failed
mgr.IsResourceFailed
Increment fail count
Changed
mgr.ShouldRefresh
Increment change count
Skipped
Neither
Remainder
The provider builds an ApplyState with the total resource count and reports the outcome:
Child result
Provider behavior
All resources succeeded
Log informational message, return state
Some resources changed
Log warning with counts, return state
Any resource failed
Log error, return error with failure count
Logging
The provider creates a child user logger with a manifest key set to the resource name. All child resource log output includes this key, providing attribution for which parent apply resource triggered the execution.
Exec Type
This document describes the design of the exec resource type for executing commands.
Overview
The exec resource executes commands with idempotency controls:
Creates: Skip execution if a file exists
OnlyIf / Unless: Guard commands that gate execution based on exit code
Refresh Only: Only execute when triggered by a subscribed resource
Exit Codes: Validate success via configurable return codes
Provider Interface
Exec providers must implement the ExecProvider interface:
Subscribe takes precedence over all other idempotency checks - if a subscribed resource changed, the command executes regardless of creates file existence or guard command results.
Exit Code Validation
By default, exit code 0 indicates success. The returns property customizes acceptable codes:
Queries current state normally (checks creates file)
Evaluates guard commands (onlyif/unless) - these run even in noop mode
Evaluates subscribe triggers
Logs what actions would be taken
Sets appropriate NoopMessage:
“Would have executed”
“Would have executed via subscribe”
Reports Changed: true if execution would occur
Does not call provider Execute method
Desired State Validation
After execution (in non-noop mode), the type verifies success:
func (t*Type) isDesiredState(properties, status) bool {
// Creates file check takes precedenceifproperties.Creates!=""&&status.CreatesSatisfied {
returntrue }
// Guard checks only apply before execution (ExitCode is nil)ifstatus.ExitCode==nil {
ifproperties.OnlyIf!=""&& !status.OnlyIfSatisfied {
returntrue// onlyif guard failed, don't run }
ifproperties.Unless!=""&&status.UnlessSatisfied {
returntrue// unless guard succeeded, don't run }
}
// Refresh-only without execution is stableifstatus.ExitCode==nil&&properties.RefreshOnly {
returntrue }
// Check exit code against acceptable returnsreturns:= []int{0}
if len(properties.Returns) > 0 {
returns = properties.Returns }
ifstatus.ExitCode!=nil {
returnslices.Contains(returns, *status.ExitCode)
}
returnfalse}
Guard checks are gated on ExitCode == nil because after execution, the exit code determines success. The post-execution isDesiredState() call must not re-evaluate guards, which would produce incorrect results since guard state is only set on initialStatus.
If the exit code is not in the acceptable returns list, an ErrDesiredStateFailed error is returned.
Command vs Name
The command property is optional. If not specified, the name is used as the command:
# These are equivalent:- exec:
- /usr/bin/myapp --config /etc/myapp.conf:
- exec:
- run-myapp:
command: /usr/bin/myapp --config /etc/myapp.conf
Using a descriptive name with explicit command is recommended for clarity.
Environment and Path
Commands can be configured with custom environment:
This document describes the implementation details of the Posix exec provider for executing commands without a shell.
Provider Selection
The Posix provider is the default exec provider. It is always available and returns priority 1 for all exec resources unless a different provider is explicitly requested via the provider property.
To use the shell provider instead, specify provider: shell in the resource properties.
Comparison with Shell Provider
Feature
Posix
Shell
Shell invocation
No
Yes (/bin/sh -c)
Pipes (|)
Not supported
Supported
Redirections (>, <)
Not supported
Supported
Shell builtins (cd, export)
Not supported
Supported
Glob expansion
Not supported
Supported
Command substitution ($(...))
Not supported
Supported
Argument parsing
shellquote.Split()
Passed as single string
Security
Lower attack surface
Shell injection possible
When to use Posix (default):
Simple commands with arguments
When shell features are not needed
For better security (no shell injection risk)
When to use Shell:
Commands with pipes, redirections, or shell builtins
Complex command strings
When shell expansion is required
Operations
Execute
Process:
Determine command source (Command property or Name if Command is empty)
Parse command string into words using shellquote.Split()
Extract command (first word) and arguments (remaining words)
Execute via CommandRunner.ExecuteWithOptions()
Optionally log output line-by-line if LogOutput is enabled
Command Parsing:
The command string is parsed using github.com/kballard/go-shellquote, which handles:
Syntax
Example
Result
Simple words
echo hello world
["echo", "hello", "world"]
Single quotes
echo 'hello world'
["echo", "hello world"]
Double quotes
echo "hello world"
["echo", "hello world"]
Escaped spaces
echo hello\ world
["echo", "hello world"]
Mixed quoting
echo "it's a test"
["echo", "it's a test"]
Execution Options:
Option
Source
Description
Command
First word after parsing
Executable path or name
Args
Remaining words
Command arguments
Cwd
properties.Cwd
Working directory
Environment
properties.Environment
Additional env vars (KEY=VALUE format)
Path
properties.Path
Search path for executables
Timeout
properties.ParsedTimeout
Maximum execution time
Output Logging:
When LogOutput: true is set and a user logger is provided:
The model validates exec properties before execution:
Property
Validation
name
Must be parseable by shellquote (balanced quotes)
timeout
Must be valid duration format (e.g., 30s, 5m)
subscribe
Each entry must be type#name format
path
Each directory must be absolute (start with /)
environment
Each entry must be KEY=VALUE format with non-empty key and value
Platform Support
The Posix provider works on all platforms supported by Go’s os/exec package. It does not use any platform-specific system calls directly.
The command runner (model.CommandRunner) handles the actual process execution, which may have platform-specific implementations.
Security Considerations
No Shell Injection
Unlike the shell provider, the posix provider does not invoke a shell. Arguments are passed directly to the executable, preventing shell injection attacks:
# Safe with posix provider - $USER is passed literally, not expanded- exec:
- /bin/echo $USER:
provider: posix # Default# Potentially dangerous with shell provider - $USER is expanded- exec:
- /bin/echo $USER:
provider: shell
Path Validation
The path property only accepts absolute directory paths, preventing path traversal via relative paths.
Environment Validation
Environment variables must have non-empty keys and values, preventing injection of empty or malformed entries.
Shell Provider
This document describes the implementation details of the Shell exec provider for executing commands via /bin/sh.
Provider Selection
The Shell provider is selected when provider: shell is explicitly specified in the resource properties. It has a lower priority (99) than the Posix provider (1), so it is never automatically selected.
Availability: The provider checks for the existence of /bin/sh via util.FileExists(). If /bin/sh does not exist, the provider is not available.
Comparison with Posix Provider
Feature
Shell
Posix
Shell invocation
Yes (/bin/sh -c)
No
Pipes (|)
Supported
Not supported
Redirections (>, <, >>)
Supported
Not supported
Shell builtins (cd, export, source)
Supported
Not supported
Glob expansion (*.txt, ?)
Supported
Not supported
Command substitution ($(...), `...`)
Supported
Not supported
Variable expansion ($VAR, ${VAR})
Supported
Not supported
Logical operators (&&, ||)
Supported
Not supported
Argument parsing
Passed as single string
shellquote.Split()
Security
Shell injection possible
Lower attack surface
When to use Shell:
Commands with pipes: cat file.txt | grep pattern | sort
Commands with redirections: echo "data" > /tmp/file
Commands with shell builtins: cd /tmp && pwd
Commands with variable expansion: echo $HOME
Complex one-liners with logical operators
When to use Posix (default):
Simple commands with arguments
When shell features are not needed
For better security (no shell injection risk)
Operations
Execute
Process:
Determine command source (Command property or Name if Command is empty)
Validate command is not empty
Execute via CommandRunner.ExecuteWithOptions() with /bin/sh -c "<command>"
Optionally log output line-by-line if LogOutput is enabled
Execution Method:
The entire command string is passed to the shell as a single argument:
/bin/sh -c "<entire command string>"
This allows the shell to interpret all shell syntax, including:
Pipes and redirections
Variable expansion
Glob patterns
Command substitution
Logical operators
Execution Options:
Option
Value
Description
Command
/bin/sh
Shell executable path
Args
["-c", "<command>"]
Shell flag and command string
Cwd
properties.Cwd
Working directory
Environment
properties.Environment
Additional env vars (KEY=VALUE format)
Path
properties.Path
Search path for executables
Timeout
properties.ParsedTimeout
Maximum execution time
Output Logging:
When LogOutput: true is set and a user logger is provided:
The shell provider uses the same idempotency mechanisms as the posix provider:
Creates File
If creates is specified and the file exists, the command does not run:
- exec:
- extract-archive:
command: cd /opt && tar -xzf /tmp/app.tar.gzprovider: shellcreates: /opt/app/bin/app
Guard Commands
If onlyif is specified, the command only runs when the guard exits 0. If unless is specified, the command only runs when the guard exits non-zero. Guard commands are executed via /bin/sh -c and can use shell features:
Files can receive content from two mutually exclusive sources:
Property
Description
contents
Inline string content (template-resolved)
source
Path to local file to copy from
# Inline content with template- file:
- /etc/motd:
ensure: presentcontent: | Welcome to {{ lookup('facts.hostname') }}
Managed by CCMowner: rootgroup: rootmode: "0644"# Copy from source file- file:
- /etc/app/config.yaml:
ensure: presentsource: files/config.yamlowner: appgroup: appmode: "0640"
When using source, the path is relative to the manifest’s working directory if one is set.
Attribute-only Management
A file resource that omits both content and source manages only owner, group and mode. The file’s contents are left untouched. This is useful when another resource (typically an exec or package) produces the file and CCM is responsible for enforcing its permissions.
If the file exists, only owner, group and mode are adjusted. Content is never read or written.
If the file does not exist, an empty file is created with the requested owner, group and mode. This matches the behavior of Puppet’s file { ensure => present } with no content or source.
If the path exists as a directory, the apply fails. Use ensure: directory to manage directories.
Symlinks are rejected by the posix provider’s SetAttributes implementation to avoid silently mutating the target through the link.
To create an explicit empty file rather than enter attribute-only mode, set content: "". An omitted content: and content: null are equivalent and both mean “do not manage content”.
content and source remain mutually exclusive. Setting both is rejected at validation time.
Required Properties
Unlike some resources, file resources require explicit attributes:
Property
Required
Description
owner
Yes, except absent
Username or numeric UID that owns the file
group
Yes, except absent
Group name or numeric GID that owns the file
mode
Yes, except absent
Permissions in octal notation
This prevents accidental creation of files with default or inherited permissions.
When ensure: absent, owner, group, and mode are optional. They describe a desired on-disk state and are not consulted during removal. Manifests that only ever remove a path may omit them.
- file:
- /tmp/leftover.lock:
ensure: absent
A purely-numeric value for owner or group is always interpreted as a UID or GID respectively, without consulting /etc/passwd or /etc/group. This matches the semantics of chown(1) for numeric arguments and allows the resource to be applied on systems where the target account exists only by ID (containers, mounted volumes from other hosts, namespaced filesystems).
Removal Behavior
The force property controls how ensure: absent handles directories.
Property
Default
Description
force
false
When true, allow ensure: absent to remove non-empty directories
force is only valid when ensure: absent. Combining it with any other ensure value is rejected at validation time.
force: true cannot be used with name: /. All other paths are allowed; CCM does not maintain a blocklist of system directories.
For regular files and symlinks, force has no effect; both forms call into the same removal path.
If a directory is a symlink, only the symlink itself is removed. The target directory is left untouched. See the posix provider for details.
Without force, attempting to remove a non-empty directory fails with a hint that force: true is required. Once force is removed from the manifest, future applies will fail again if the directory is repopulated. That is intentional: the manifest must opt in to destructive behavior every time it is applied.
Apply Logic
βββββββββββββββββββββββββββββββββββββββββββ
β Get current state via Status() β
βββββββββββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Is current state desired state? β
βββββββββββββββββββ¬ββββββββββββββββββββββββ
Yes β No
βΌ β
βββββββββββββ β
β No change β β
βββββββββββββ β
βΌ
βββββββββββββββββββββββββββ
β What is desired ensure? β
βββββββββββββββ¬ββββββββββββ
β
βββββββββββββββββββββββββΌββββββββββββββββββββββββ
β absent β directory β present
βΌ βΌ βΌ
ββββββββββββββ βββββββββββββββββ βββββββββββββββββββββββββββββββββ
β Remove β β CreateDir β β Content managed or file β
β (provider) β β β β absent? Store. Otherwise β
ββββββββββββββ βββββββββββββββββ β SetAttributes. β
βββββββββββββββββββββββββββββββββ
Idempotency
The file resource checks multiple attributes for idempotency:
State Checks (in order)
Ensure match: Current type matches desired (present/absent/directory)
Content match: SHA256 checksum of contents matches (for ensure: present, skipped in attribute-only mode)
Owner match: Current owner matches desired, comparing by numeric UID when either side is a numeric value or resolves to one
Group match: Current group matches desired, comparing by numeric GID when either side is a numeric value or resolves to one
Mode match: Current permissions match desired
Decision Table
Desired
Current State
Action
absent
absent
None
absent
present (file or symlink)
Remove
absent
empty directory
Remove
absent
non-empty directory + force: false
Error: directory is not empty
absent
non-empty directory + force: true
Remove recursively
directory
directory + matching attrs
None
directory
absent/present
CreateDirectory
directory
directory + wrong attrs
CreateDirectory (updates attrs)
present (content set)
present + matching all
None
present (content set)
absent
Store
present (content set)
present + wrong content
Store
present (content set)
present + wrong attrs
Store
present (attrs-only)
present + matching attrs
None
present (attrs-only)
present + wrong attrs
SetAttributes
present (attrs-only)
absent
Store (creates empty file with attrs)
present (any mode)
directory
Error: path exists as a directory
Content Comparison
Content is compared using SHA256 checksums:
Source
Checksum Method
contents property
Sha256HashBytes([]byte(contents))
source property
Sha256HashFile(adjustedPath)
Existing file
Sha256HashFile(filePath)
Mode Validation
File modes are validated during resource creation:
Valid Formats:
"0644" - Standard octal
"644" - Without leading zero
"0o755" - With 0o prefix
"0O700" - With 0O prefix
Validation Rules:
Must be valid octal number (digits 0-7)
Must be β€ 0777 (no setuid/setgid/sticky via mode)
Path Validation
File paths must be:
Absolute (start with /)
Clean (no . or .. components, filepath.Clean(path) == path)
iffilepath.Clean(p.Name) !=p.Name {
returnfmt.Errorf("file path must be absolute")
}
Working Directory
When a manifest has a working directory (e.g., extracted from an archive), the source property is resolved relative to it:
This allows manifests bundled with their source files to use relative paths.
Noop Mode
In noop mode, the file type:
Queries current state normally
Computes content checksums
Logs what actions would be taken
Sets appropriate NoopMessage:
“Would have created the file”
“Would have created an empty file with requested attributes” (attribute-only mode, file absent)
“Would have updated attributes” (attribute-only mode, attribute drift)
“Would have created directory”
“Would have removed the file” (regular file or symlink)
“Would have removed the directory” (directory, force not set)
“Would have recursively removed the directory” (directory, force: true)
Reports Changed: true if changes would occur
Does not call provider Store/CreateDirectory methods
Does not remove files
Desired State Validation
After applying changes (in non-noop mode), the type verifies the file reached the desired state by calling Status() again and checking all attributes match. If validation fails, ErrDesiredStateFailed is returned.
Subsections of File Type
Posix Provider
This document describes the implementation details of the Posix file provider for managing files and directories on Unix-like systems.
Provider Selection
The Posix provider is the default and only file provider. It is always available and returns priority 1 for all file resources.
Operations
Store (Create/Update File)
Process:
Verify parent directory exists
Parse file mode from octal string
Open source file if source property is set
Create temporary file in the same directory as target
Set file permissions on temp file
Write content (from source file or contents property)
The explicit Chmod after MkdirAll is necessary because MkdirAll applies the process umask to the mode.
Remove
Process:
When force is true, the path is removed with os.RemoveAll().
When force is false, the path is removed with os.Remove().
A path that does not exist is treated as a no-op (no error).
When os.Remove() fails with syscall.ENOTEMPTY, the error is wrapped with guidance pointing the user at force: true.
Error Handling:
Condition
Behavior
Path does not exist
Return nil
force: false, directory not empty
Return wrapped error: "cannot remove <path>: directory is not empty, set 'force: true' ..."
Other syscall failure
Return error from underlying syscall
Symlink Behavior:
os.RemoveAll() does not follow symlinks during traversal. If the target path is itself a symlink, only the symlink is removed and its target is left intact. A directory tree that contains symlinks to external locations is safe to remove with force: true: the symlink entries are unlinked, but the directories they point to are not deleted.
Context Cancellation:
os.RemoveAll() does not observe ctx. Removal of a very large tree cannot be interrupted mid-walk. This is acceptable for typical CCM workloads but should be considered when scheduling removal of large directories.
Status
Process:
Initialize state with default metadata
Call os.Stat() on file path
Based on result, populate state accordingly
State Detection:
os.Stat() Result
Ensure Value
Metadata
File exists
present
Size, mtime, owner, group, mode, checksum
Directory exists
directory
Size, mtime, owner, group, mode
os.ErrNotExist
absent
None
os.ErrPermission
absent
None (logged as warning)
Other error
(unchanged)
None (logged as warning)
Metadata Collection:
Field
Source
Name
From properties
Provider
“posix”
Size
FileInfo.Size()
MTime
FileInfo.ModTime()
Owner
util.GetFileOwner() - resolves UID to username
Group
util.GetFileOwner() - resolves GID to group name
Mode
util.GetFileOwner() - octal string (e.g., “0644”)
Checksum
util.Sha256HashFile() - SHA256 hash (files only)
Note: Checksum is only calculated for regular files, not directories.
Idempotency
The file resource achieves idempotency by comparing current state against desired state:
State Checks
The isDesiredState() function checks (in order):
Ensure value matches - present, absent, or directory
Content checksum matches - SHA256 of contents vs existing file (for files only)
Owner matches - Owner comparison, normalized through util.UserIDMatches so a manifest written with a numeric UID compares equal to a named user with the same UID, and vice versa
Group matches - Group comparison, normalized through util.GroupIDMatches using the same rules
A purely-numeric value (digits only) is parsed directly as a UID or GID. The system user database is not consulted, matching the semantics of chown(1) when given a numeric argument.
Any other value is resolved through the system user database (/etc/passwd, /etc/group, or equivalent) using user.Lookup(owner) and user.LookupGroup(group).
Error Handling:
Condition
Behavior
Empty value
Return error: “user name cannot be empty” or “group name cannot be empty”
Named user not in database
Return error: “could not lookup user”
Named group not in database
Return error: “could not lookup group”
Numeric value out of int range
Return error from strconv.Atoi
Numeric values are not validated against the user database. A file may be chowned to a UID or GID that has no matching entry, which is intentional for namespaced or container scenarios.
State Comparison
State comparison reads ownership from the filesystem as the on-disk numeric UID/GID, reverse-resolved to a name when possible by util.GetFileOwner. Because the manifest may use either form, comparison is delegated to util.UserIDMatches and util.GroupIDMatches, which normalize both sides to numeric IDs before comparing. This keeps a manifest stable regardless of which form was chosen.
Working Directory Support
When a manager has a working directory set (e.g., from extracted manifest), the source property path is adjusted:
This allows manifests to use relative paths for source files bundled with the manifest.
Platform Support
The Posix provider uses Unix-specific system calls:
Operation
System Call
Get file owner/group
syscall.Stat_t (UID/GID from stat)
Set ownership
os.Chown() β chown(2)
Set permissions
os.Chmod() β chmod(2)
The provider has separate implementations for Unix and Windows (file_unix.go, file_windows.go in internal/util), with Windows returning errors for ownership operations.
Security Considerations
Atomic Writes
Files are written atomically via temp file + rename. This prevents:
Partial file reads during write
Corruption if process is interrupted
Race conditions with concurrent readers
Permission Ordering
Permissions and ownership are set on the temp file before rename:
Chmod - Set permissions
Write content
Chown - Set ownership
Rename to target
This ensures the file never exists at the target path with incorrect permissions.
Path Validation
File paths must be absolute and clean (no . or .. components):
iffilepath.Clean(p.Name) !=p.Name {
returnfmt.Errorf("file path must be absolute")
}
Required Properties
Owner, group, and mode are required properties and cannot be empty, preventing accidental creation of files with default/inherited permissions.
Package Type
This document describes the design of the package resource type for managing software packages.
Overview
The package resource manages software packages with two aspects:
Existence: Whether the package is installed or absent
Version: The specific version installed (when applicable)
Provider Interface
Package providers must implement the PackageProvider interface:
βββββββββββββββββββββββββββββββββββββββββββ
β Get current state via Status() β
βββββββββββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Is ensure = "latest"? β
βββββββββββββββββββ¬ββββββββββββββββββββββββ
Yes β No
βΌ β
βββββββββββββββββββββββ β
β Is package absent? β β
βββββββββββββββ¬ββββββββ β
Yes β No β
βΌ βΌ β
ββββββββββ ββββββββββ
βInstall β βUpgrade β
βlatest β βlatest β
ββββββββββ ββββββββββ
β
βΌ
βββββββββββββββββββββββββββ
β Is desired state met? β
βββββββββββββββ¬ββββββββββββ
Yes β No
βΌ β
βββββββββββββ β
β No change β βΌ
βββββββββββββ (Phase 2)
Phase 2: Handle Ensure Values
βββββββββββββββββββββββββββ
β What is desired ensure? β
βββββββββββββββ¬ββββββββββββ
β
βββββββββββββββββββββββββΌββββββββββββββββββββββββ
β absent β present β <version>
βΌ βΌ βΌ
ββββββββββββββ βββββββββββββββββ βββββββββββββββββ
β Uninstall β β Is absent? β β Is absent? β
ββββββββββββββ βββββββββ¬ββββββββ βββββββββ¬ββββββββ
Yes β No Yes β No
βΌ βΌ βΌ βΌ
ββββββββββ ββββββββββ ββββββββββ ββββββββββββββ
βInstall β βNo β βInstall β βCompare β
β β βchange β βversion β βversions β
ββββββββββ ββββββββββ ββββββββββ βββββββ¬βββββββ
β
ββββββββββββββββββΌβββββββββββββββββ
β current < β current = β current >
βΌ βΌ βΌ
βββββββββββββ βββββββββββββ βββββββββββββ
β Upgrade β β No change β β Downgrade β
βββββββββββββ βββββββββββββ βββββββββββββ
Version Comparison
The VersionCmp method compares two version strings:
Return Value
Meaning
-1
versionA < versionB (upgrade needed)
0
versionA == versionB (no change)
1
versionA > versionB (downgrade needed)
Version comparison is delegated to the provider, allowing platform-specific version parsing (e.g., RPM epoch handling, Debian revision suffixes).
Idempotency
The package resource is idempotent through state comparison:
Decision Table
Desired
Current State
Action
ensure: present
installed (any version)
None
ensure: present
absent
Install
ensure: absent
absent
None
ensure: absent
installed
Uninstall
ensure: latest
absent
Install latest
ensure: latest
installed
Upgrade (always runs)
ensure: <version>
same version
None
ensure: <version>
older version
Upgrade
ensure: <version>
newer version
Downgrade
ensure: <version>
absent
Install
Special Case: ensure: latest
When ensure: latest is used:
The package manager determines what “latest” means
Upgrade is always called when the package exists (package manager is idempotent)
The type cannot verify if “latest” was achieved (package managers may report stale data)
Desired state validation only checks that the package is not absent
Package Name Validation
Package names are validated to prevent injection attacks:
Allowed Characters:
Alphanumeric (a-z, A-Z, 0-9)
Period (.), underscore (_), plus (+)
Colon (:), tilde (~), hyphen (-)
Rejected:
Shell metacharacters (;, |, &, $, etc.)
Whitespace
Quotes and backticks
Path separators
Version strings (when ensure is a version) are also validated for dangerous characters.
Noop Mode
In noop mode, the package type:
Queries current state normally
Computes version comparison
Logs what actions would be taken
Sets appropriate NoopMessage:
“Would have installed latest”
“Would have upgraded to latest”
“Would have installed version X”
“Would have upgraded to X”
“Would have downgraded to X”
“Would have uninstalled”
Reports Changed: true if changes would occur
Does not call provider Install/Upgrade/Downgrade/Uninstall methods
Desired State Validation
After applying changes (in non-noop mode), the type verifies the package reached the desired state:
func (t*Type) isDesiredState(properties, state) bool {
switchproperties.Ensure {
case"present":
// Any installed version is acceptablereturnstate.Ensure!="absent"case"absent":
returnstate.Ensure=="absent"case"latest":
// Cannot verify "latest", just check not absentreturnstate.Ensure!="absent"default:
// Specific version must matchreturnVersionCmp(state.Ensure, properties.Ensure, false) ==0 }
}
If the desired state is not reached, an ErrDesiredStateFailed error is returned.
Subsections of Package Type
APT Provider
This document describes the implementation details of the APT package provider for Debian-based systems.
Environment
All commands are executed with the following environment variables to ensure non-interactive operation:
Variable
Value
Purpose
DEBIAN_FRONTEND
noninteractive
Prevents dpkg from prompting for user input
APT_LISTBUGS_FRONTEND
none
Suppresses apt-listbugs prompts
APT_LISTCHANGES_FRONTEND
none
Suppresses apt-listchanges prompts
Concurrency
A global package lock (model.PackageGlobalLock) is held during all command executions to prevent concurrent apt/dpkg operations within the same process. This prevents lock contention on /var/lib/dpkg/lock.
Helper methods: LessThan, GreaterThan, Equal, etc.
DNF Provider
This document describes the implementation details of the DNF package provider for RHEL/Fedora-based systems.
Concurrency
A global package lock (model.PackageGlobalLock) is held during all command executions to prevent concurrent dnf/rpm operations within the same process. This prevents lock contention on the RPM database.
Target directory must exist with rendered template files
absent
Managed files must be removed from the target
Template Engines
Two template engines are supported:
Engine
Library
Default Delimiters
Description
go
Go text/template
{{ / }}
Standard Go templates
jet
Jet templating
[[ / ]]
Jet template language
The engine defaults to jet if not specified. Delimiters can be customized via left_delimiter and right_delimiter properties.
Properties
Property
Type
Required
Description
source
string
Yes
Source template directory path or URL
engine
string
No
Template engine: go or jet (default: jet)
skip_empty
bool
No
Skip empty files in rendered output
left_delimiter
string
No
Custom left template delimiter
right_delimiter
string
No
Custom right template delimiter
purge
bool
No
Remove files in target not present in source
data
map[string]any
No
Custom data that replaces Hiera data for template rendering
post
[]map[string]string
No
Post-processing: glob pattern to command mapping
# Render configuration templates using Jet engine- scaffold:
- /etc/app:
ensure: presentsource: templates/appengine: jetpurge: true# Render with Go templates and custom delimiters- scaffold:
- /etc/myservice:
ensure: presentsource: templates/myserviceengine: goleft_delimiter: "<<"right_delimiter: ">>"# With post-processing commands- scaffold:
- /opt/app:
ensure: presentsource: templates/apppost:
- "*.go": "go fmt {}"# With custom data replacing Hiera data- scaffold:
- /etc/app:
ensure: presentsource: templates/appengine: jetdata:
app_name: myappversion: "{{ Facts.version }}"port: 8080
Custom Data
The data property allows supplying custom data that completely replaces the Hiera-resolved data for template rendering. When data is set and non-empty, templates receive only the custom data via data instead of the Hiera-resolved data from the manifest.
This is useful when a scaffold resource needs data that differs from or is unrelated to the global Hiera data, or when you want to provide a self-contained data set for a specific scaffold.
Behavior
When data is not set or empty: templates receive the Hiera-resolved data from the manager as normal.
When data is set and non-empty: env.Data is replaced with the custom data before calling Status() and Scaffold(). The custom data is used consistently throughout the entire apply cycle.
facts remain available regardless of whether custom data is provided.
Template Resolution in Data Values
String values in the data map support template expressions that are resolved during property template resolution. Both keys and values can contain templates:
Non-string values (integers, booleans, lists, maps) are preserved as-is without template resolution.
Apply Logic
βββββββββββββββββββββββββββββββββββββββββββ
β Get template environment from manager β
βββββββββββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Custom data set? Override env.Data β
βββββββββββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Get current state via Status() β
βββββββββββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββ
β Is current state desired state? β
βββββββββββββββββββ¬ββββββββββββββββββββββββ
Yes β No
βΌ β
βββββββββββββ β
β No change β β
βββββββββββββ β
βΌ
βββββββββββββββββββββββββββ
β What is desired ensure? β
βββββββββββββββ¬ββββββββββββ
β
βββββββββββββββββ΄ββββββββββββββββ
β absent β present
βΌ βΌ
βββββββββββββ βββββββββββββ
β Noop? β β Noop? β
βββββββ¬ββββββ βββββββ¬ββββββ
Yes β No Yes β No
βΌ β βΌ β
βββββββββββββββ βββββββββββββββ
β Set noop ββ β Set noop ββ
β message ββ β message ββ
βββββββββββββββ βββββββββββββββ
βΌ βΌ
βββββββββββββββββ βββββββββββββββββββββββ
β Remove all β β Scaffold β
β managed files β β (render templates) β
β and empty dirsβ β β
βββββββββββββββββ βββββββββββββββββββββββ
Idempotency
The scaffold resource determines idempotency by rendering templates in noop mode and comparing results against the target directory.
State Checks
Ensure absent: Target must not exist, or no managed files remain on disk (Changed and Stable lists empty). Purged files (files not belonging to the scaffold) do not affect this check.
Ensure present: The Changed list must be empty, and the Purged list must be empty when purge is enabled (all files are stable). When purge is disabled, purged files do not affect stability.
Decision Table
For ensure: absent, purged files never affect stability since they don’t belong to the scaffold. For ensure: present, purged files only affect stability when purge is enabled.
When ensure: absent, the Status method filters Changed and Stable lists to only include files that actually exist on disk, so the state reflects reality after removal rather than what the scaffold would create.
Desired
Target Exists
Changed Files
Purged Files
Purge Enabled
Stable?
absent
No
N/A
N/A
N/A
Yes
absent
Yes
None
Any
N/A
Yes (no managed files on disk)
absent
Yes
Some
Any
N/A
No (managed files remain)
present
Yes
None
None
Any
Yes
present
Yes
None
Some
No
Yes (purged files ignored)
present
Yes
None
Some
Yes
No (purge needed)
present
Yes
Some
Any
Any
No (render needed)
present
No
N/A
N/A
Any
No (target missing)
Source Resolution
The source property is resolved relative to the manager’s working directory when it is a relative path:
This allows manifests bundled with template directories to use relative paths. URL sources (with a scheme) are passed through unchanged.
Path Validation
Target paths (the resource name) must be:
Absolute (start with /)
Canonical (no . or .. components, filepath.Clean(path) == path)
Post-Processing
The post property defines commands to run on rendered files. Each entry is a map where the key is a glob pattern matched against the file’s basename and the value is a command to execute. Use {} as a placeholder for the file’s full path; if omitted, the path is appended as the last argument.
Post-processing runs immediately after each file is rendered. Validation ensures neither keys nor values are empty.
Noop Mode
In noop mode, the scaffold type queries the current state via Status() and reports what would change without modifying the filesystem. Neither Scaffold() nor Remove() are called.
For ensure: present, the affected count is the number of changed files plus purged files (when purge is enabled). For ensure: absent, the affected count is the number of changed and stable files plus purged files (when purge is enabled).
Desired
Affected Count
Message
present
Changed + Purged (if purge enabled)
Would have changed N scaffold files
absent
Changed + Stable + Purged (if purge enabled)
Would have removed N scaffold files
Changed is set to true only when the affected count is greater than zero. When the resource is already in the desired state, Changed is false and NoopMessage is empty.
Desired State Validation
After applying changes (in non-noop mode), the type verifies the scaffold reached the desired state by checking the changed and purged file lists. If validation fails, ErrDesiredStateFailed is returned.
Subsections of Scaffold Type
Choria Provider
This document describes the implementation details of the Choria scaffold provider for rendering template directories using the choria-io/scaffold library.
Provider Selection
The Choria provider is the default and only scaffold provider. It is always available and returns priority 1 for all scaffold resources.
Operations
Scaffold (Render Templates)
Process:
Check if target directory exists
Configure scaffold with source, target, engine, delimiters, post-processing, and skip_empty settings
Create scaffold instance using the appropriate engine (scaffold.New() for Go, scaffold.NewJet() for Jet)
Call Render() (real mode) or RenderNoop() (noop mode)
Categorize results into changed, stable, and purged file lists
Scaffold Configuration:
Config Field
Source Property
Description
TargetDirectory
Name
Target directory for rendered files
SourceDirectory
Source
Source template directory
MergeTargetDirectory
(always true)
Merge into existing target directory
Post
Post
Post-processing commands
SkipEmpty
SkipEmpty
Skip empty rendered files
CustomLeftDelimiter
LeftDelimiter
Custom template left delimiter
CustomRightDelimiter
RightDelimiter
Custom template right delimiter
Engine Selection:
Engine
Constructor
Default Delimiters
go
scaffold.New()
{{ / }}
jet
scaffold.NewJet()
[[ / ]]
Result Categorization:
Scaffold Action
Metadata List
Description
FileActionEqual
Stable
File content unchanged
FileActionAdd
Changed
New file created
FileActionUpdate
Changed
Existing file modified
FileActionRemove
Purged
File removed from target
File paths in the metadata lists are absolute paths, constructed by joining the target directory with the relative path from the scaffold result.
Purge Behavior:
When purge is enabled and a file has FileActionRemove, the provider deletes the file from disk during Scaffold(). In noop mode, the removal is logged but not performed. When purge is disabled, purged files are only tracked in metadata and not removed.
Status
Process:
Perform a dry-run render (noop mode) to determine what the scaffold would do
When ensure is absent, filter Changed and Stable lists to only include files that actually exist on disk
The noop render reports what would happen if the scaffold were applied. For ensure: present, this is the desired output β it shows what needs to change. For ensure: absent, the raw render output is misleading after removal (it would show files to be added), so the lists are filtered to reflect what managed files actually remain on disk.
State Detection:
Target Directory
Ensure Value
Metadata
Exists
present
Changed, stable, and purged file lists from render
Exists
absent
Changed and stable filtered to files on disk, purged from render
Does not exist
Any
Empty metadata, TargetExists: false
Remove
Process:
Collect managed files from the state’s Changed and Stable lists (purged files are not removed as they don’t belong to the scaffold)
Stop when no more empty directories can be removed
Best-effort removal of the target directory (only succeeds if empty)
File Removal Order:
Files are collected from two metadata lists:
Changed - Files that were added or modified
Stable - Files that were unchanged
Purged files are not removed because they are unrelated to the scaffold and may belong to other processes.
Directory Cleanup:
For each removed file:
Track its parent directory
Repeat:
For each tracked directory:
Skip if it is the target directory itself
Skip if not empty
Remove the directory
Track its parent directory
Until no more directories removed
Best-effort: remove the target directory (fails silently if not empty)
The target directory is removed if empty after all managed files and subdirectories are cleaned up. If unrelated files remain (purged files), the directory is preserved.
Error Handling:
Condition
Behavior
Non-absolute file path
Return error immediately
File removal fails
Log error, continue with remaining files
Directory removal fails
Log error, continue with remaining directories
File does not exist
Silently skip (os.IsNotExist check)
Target directory removal fails
Log at debug level, no error returned
Template Environment
Templates receive the full templates.Env environment, which provides access to:
facts - System facts for the managed node
data - Hiera-resolved configuration data, or custom data when the resource’s data property is set
Template helper functions
When the scaffold resource has a data property set, env.Data is replaced with the custom data before the provider’s Status() and Scaffold() methods are called. The provider receives the already-resolved environment and does not need to handle this override itself.
This allows templates to generate host-specific configurations based on facts and hierarchical data.
Logging
The provider wraps the CCM logger in a scaffold-compatible interface:
This adapter translates the scaffold library’s Debugf/Infof calls to CCM’s structured logging.
Platform Support
The Choria provider is platform-independent. It uses the choria-io/scaffold library for template rendering, which operates on standard filesystem operations. No platform-specific system calls are used.
Service Type
This document describes the design of the service resource type for managing system services.
Overview
The service resource manages system services with two independent dimensions:
Running state: Whether the service is currently running or stopped
Enabled state: Whether the service starts automatically at boot
These are managed independently, allowing combinations like “running but disabled” or “stopped but enabled”.
Provider Interface
Service providers must implement the ServiceProvider interface:
The Status method returns a ServiceState containing:
typeServiceStatestruct {
CommonResourceStateMetadata*ServiceMetadata}
typeServiceMetadatastruct {
Namestring// Service nameProviderstring// Provider name (e.g., "systemd")Enabledbool// Whether service starts at bootRunningbool// Whether service is currently running}
The Ensure field in CommonResourceState is set to:
If the desired state is not reached, an ErrDesiredStateFailed error is returned.
Service Name Validation
Service names are validated to prevent injection attacks:
Allowed Characters:
Alphanumeric (a-z, A-Z, 0-9)
Period (.), underscore (_), plus (+)
Colon (:), tilde (~), hyphen (-)
Rejected:
Shell metacharacters (;, |, &, etc.)
Whitespace
Path separators
Noop Mode
In noop mode, the service type:
Queries current state normally
Logs what actions would be taken
Sets appropriate NoopMessage (e.g., “Would have started”, “Would have enabled”)
Reports Changed: true if changes would occur
Does not call provider Start/Stop/Restart/Enable/Disable methods
Subsections of Service Type
Systemd Provider
This document describes the implementation details of the Systemd service provider for managing system services via systemctl.
Provider Selection
The Systemd provider is selected when systemctl is found in the system PATH. The provider checks for the executable using util.ExecutableInPath("systemctl").
Availability Check:
Searches PATH for systemctl
Returns priority 1 if found
Returns unavailable if not found
Concurrency
A global service lock (model.ServiceGlobalLock) is held during all systemctl command executions to prevent concurrent systemd operations within the same process. This prevents race conditions when multiple service resources are managed simultaneously.
The provider performs a systemctl daemon-reload once per provider instance before any service operations. This ensures systemd picks up any unit file changes made by other resources (e.g., file resources managing unit files).
When the file resource changes, the service is restarted
Restart only occurs if ensure: running
If service was stopped and should be running, it starts (not restarts)
Error Handling
Condition
Behavior
systemctl not in PATH
Provider unavailable
Service not found
Error from is-enabled: “service not found”
Unknown is-active output
Error: “invalid systemctl is-active output”
Unknown is-enabled output
Error: “invalid systemctl is-enabled output”
Command execution failure
Error propagated from runner
Platform Support
The Systemd provider requires:
Linux with systemd as init system
systemctl command available in PATH
It does not support:
Non-systemd init systems (SysVinit, Upstart, OpenRC)
User-level units (uses --system flag)
Windows, macOS, or BSD systems
Code Map
CCM is a declarative configuration management engine written in Go. A manifest or a
command names resources and the desired state for each; the engine drives every resource
to that state and reports what changed. This code map is a deep-dive into how the Go code
is built, for contributors and for anyone who wants to understand the machine behind the
manifests.
Snapshot
Generated 2026-07-10 against commit d1b8674 on branch main. The working tree was clean
at capture time. Commits after this one may make parts of this map stale.
The mental model
CCM has one core and many faces. The core is a small loop: a Manager holds the run’s
facts, data, and session; a Resource decides whether the system already matches the
desired state; a Provider makes the platform-specific change when it does not. Every
entry point drives that same loop. The CLI applies one resource. The apply engine walks a
manifest of many. The background agent applies manifests on a timer. A piped wire API
applies a resource sent as JSON. The logic underneath is identical, so behavior does not
drift between how a resource is invoked.
Providers are selected at run time. A resource type declares a capability interface, and
each provider registers a factory that reports whether it can manage the resource on this
host. The registry picks the best match from the gathered facts, so the same manifest runs
apt on Debian and dnf on RHEL with no change to the resource.
One core loop, many faces. Every entry point drives the same manager, resource, and provider machinery.
The CLI command surface, a source map of every package, the key types and where they are explained, and a glossary.
{class=“children children-type-tree children-sort-”}
Next
Start with the Architecture page for the package layering
and the contracts every subsystem programs against.
Subsections of Code Map
Architecture
CCM is built around a strict dependency inversion. The model package defines contracts
and almost no behavior. Concrete resource types and providers depend on those contracts and
on a global registry, never on the orchestrator. The manager package is the orchestrator
that satisfies the central contract and wires in facts, data, sessions, NATS, and logging.
That inversion is why a provider can self-register without the manager knowing it exists,
and why the same resource logic runs from the CLI, a manifest, or the agent.
Where it lives
model: the contracts and shared structs. internal/registry: the global provider
directory. manager: the concrete orchestrator, type CCM. Key files: model/resource.go,
model/provider.go, model/manager.go, model/transaction.go, internal/registry/registry.go,
manager/manager.go, manager/opts.go.
The layers
Read the stack from the outside in. Entry points build a manager and hand it work. The apply
engine turns a manifest into resources. Each resource embeds a shared base that owns the
apply flow. The base calls into the concrete type, which decides what to change and calls a
provider. Providers do the platform work. Every layer below the manager programs against
model interfaces only.
Every layer below the manager depends downward only, and only on the model contracts.
The contracts
The model package is deliberately thin. It holds interfaces and shared value structs, and
defers behavior to the packages that implement them.
Resource
The runtime behavior of a managed thing: Type, Name, ResourceId, Provider, Properties, plus Apply, Healthcheck, and Info. Implemented by the per-type Type structs under resources/*/type.go. Defined at model/resource.go:26.
ResourceProperties
The declarative desired state: CommonProperties(), Validate(), ResolveTemplates(), ResolveDeferredTemplates(), ToYamlManifest(). Every per-type properties struct embeds CommonResourceProperties. Defined at model/resource.go:43.
Provider
Intentionally tiny: only Name() string (model/provider.go:8). Each resource package widens it into a capability interface, so the registry can stay type-agnostic while types get a rich contract.
ProviderFactory
The registration unit: TypeName(), Name(), IsManageable(facts, props), and New(Logger, CommandRunner). Defined at model/provider.go:12.
Manager
The large seam the whole engine programs against: facts, data, logging, execution, sessions and events, registration, templating, and NATS. Defined at model/manager.go:25.
The registry as a plugin bus
internal/registry is a global directory keyed by resource type then provider name. Each
provider package exposes a Register() that calls registry.MustRegister(&factory{}) from
an init(). The manager triggers all of these through a single blank import,
_ "github.com/choria-io/ccm/resources" at manager/manager.go:27, so it never names a
concrete provider. Duplicate provider names within a type are rejected with
ErrDuplicateProvider.
Resolution happens when a resource applies. FindSuitableProvider (internal/registry/registry.go:168)
probes every factory for the type with IsManageable(facts, props), keeps the ones that
report they can manage the resource, sorts them ascending by the returned priority integer,
and takes the first.
Load-bearing decision
Lower priority value wins. selectProviders sorts ascending and FindSuitableProvider takes
provs[0] (internal/registry/registry.go:113). A test comment calls this the “highest
priority” even though the numeric value is the lowest, so read the number, not the word.
The manager
The CCM struct (manager/manager.go:36) is the concrete model.Manager. It is built with
NewManager(log, userLogger, opts...) and functional options from manager/opts.go
(WithNatsContext, WithSessionDirectory, WithRegistrationDestination, WithNoop,
WithEnvironmentData, and others). It defaults to an in-memory session store and a no-op
registration publisher, so a bare manager is safe to run offline.
Facts
Facts() caches gathered facts; SystemFacts() always re-gathers with a 2s default deadline; SetFacts and MergeFacts override or overlay the cache.
Data
SetData deep-merges resolved data with an external overlay that always wins; Data() returns a copy.
Sessions
StartSession, RecordEvent, SessionSummary, plus ShouldRefresh and IsResourceFailed, which read the last recorded event for a resource to drive subscribe and require.
Templating
TemplateEnvironment(ctx) assembles the render environment and injects the registration lookup and KV-get closures.
Noop
NoopMode() and SetNoopMode gate every mutating branch in the resource types.
Cross-manager safety
Some platform tooling is not safe to run in parallel. CCM guards it with process-wide
mutexes, PackageGlobalLock and ServiceGlobalLock (model/global_locks.go:14), that
serialize all package and service operations even across concurrent managers and manifests.
The stated reason is that concurrent dpkg/apt or systemd operations would corrupt their
databases.
Load-bearing decision
TransactionEvent carries the outcome flags (Changed, Refreshed, Failed, Skipped,
Noop) that are mirrored in metrics, the session summary, the CLI output, and
CommonResourceState. A comment at model/transaction.go:45 warns that a change to this
struct must be propagated to all four. Treat it as a shared schema, not a local struct.
Two type-dispatch switches are hand-maintained and carry a matching TODO:
NewResourcePropertiesFromYaml (model/resource.go:158) and ResourceInfo
(manager/manager.go:397) both map type names to constructors by hand, and the intent is to
make the registry carry that mapping so new types register once. The SelectProvider
boilerplate is likewise duplicated across every resources/*/type.go with a
// TODO: move to base.
Next
Continue to The Resource-Provider Model to see how
a concrete type and its providers are built on these contracts.
The Resource-Provider Model
Every resource type in CCM is built from three parts. A Type decides whether the system
already matches the desired state. A Provider makes the platform-specific change. A shared
base.Base owns the flow that connects them: gates, requirements, health checks, and turning
the outcome into an event. The split is deliberate. The type stays portable and unit-testable
against a mock provider, and the flow is written once for all seven resource types.
Where it lives
resources/base: the shared apply flow. resources/file, resources/package,
resources/service and their posix, apt, dnf, systemd provider subpackages: the
concrete types and their platform work. model/resource_*.go: the properties and state
structs. Key files: resources/base/base.go, resources/file/type.go,
resources/file/posix/posix.go.
The inversion
base.Base owns the apply flow but does not know any resource type. It calls back into the
concrete type through a small interface, base.EmbeddedResource (resources/base/base.go:27):
ApplyResource, SelectProvider, NewTransactionEvent, and Type. Each type embeds
*base.Base and, in its New constructor, sets Base.Resource = t so the base can call back
into it. That back-pointer is what lets one copy of the control-gating, require-checking, and
event-mapping code serve every type.
Type
Embeds *base.Base; holds the typed properties, the manager, and the resolved provider. Its ApplyResource reads status, compares against the desired state, calls a provider verb, and re-verifies.
Provider interface
Widens model.Provider per type. FileProvider adds CreateDirectory, Store, SetAttributes, Remove, Status (resources/file/file.go:18). PackageProvider and ServiceProvider add their own verbs.
Concrete provider
The only place OS mutation lives. The posix file provider is the sole caller of os.MkdirAll, os.Rename, os.Chown, and os.RemoveAll (resources/file/posix/posix.go).
Load-bearing decision
Real work must live in the provider, never in the type. The Type.ApplyResource only decides
and verifies; all platform mutation sits behind the capability interface. This keeps a type
portable across providers and keeps its decision logic OS-agnostic and testable with a mocked
provider. A type that stubbed work inline would break both properties.
Applying one resource
The flow below is base.applyOrHealthCheck (resources/base/base.go:74). The type-specific
decisions happen inside the type’s ApplyResource.
Select the provider The type resolves through registry.FindSuitableProvider, which filters providers by IsManageable(facts, props) and picks the lowest priority number. The result is cached.
Gate on controlcheckControl evaluates control.if and control.unless expressions. If they say do not manage, the event is marked Skipped and returned.
Resolve deferred templates File overrides ResolveDeferredTemplates so content and source render only after the control gate, letting unless protect against template errors on a resource that will be skipped.
Check requirements For each require entry, Manager.IsResourceFailed reads the last recorded event. Any failed or unmet requirement marks this event Skipped with UnmetRequirements and returns.
Read status and decideprovider.Status reads the actual state, and isDesiredState compares it against the properties, returning a human-readable mismatch reason.
Act, or describe A switch calls the provider verb. Every mutating branch is guarded by if !noop. In noop mode the verb is skipped and a noopMessage describes what would have happened.
Verify convergence After a real change, the type re-reads status and re-runs isDesiredState. If the system did not converge it returns ErrDesiredStateFailed with the reason.
Finalize and recordFinalizeState writes the flags onto the state, base maps them onto the event, and the caller logs and records it.
Decide, act, verify. The provider does the work; the type only decides and confirms.
Ensure states
Ensure semantics are per type, and deliberately loose where the platform is unreliable.
Type
States
Notes
file
present, absent, directory
Compares ensure, content checksum, owner, group, and mode.
package
present, absent, latest, <version>
present means “anything but absent”; latest is treated as “not absent” because the platform can lie about latest; an exact version uses VersionCmp.
service
running, stopped
Independent enable axis handled in a second switch; defaults to running.
Two invariants worth internalizing
Load-bearing decision
In noop mode, Changed stays true. The provider verbs are skipped, but the flow still records
that a change would have happened: package and service force changed = true when
noop && refreshState, and set a NoopMessage. A noop event therefore reports both
Noop=true and Changed=true. The convergence re-check is skipped under noop, since nothing
actually changed.
Convergence is verified, not assumed. After a non-noop change the type re-reads status and
re-checks the desired state, failing with the specific mismatch reason if the system did not
converge. This is why isDesiredState returns a reason string, not just a bool.
Subscribe and refresh
Only the service type uses refresh. Inside its ApplyResource it calls
ShouldRefresh(properties.Subscribe), which asks the manager whether the last recorded event
for each subscribed resource had Changed == true. A refresh is suppressed unless the service
is ensured running, and skipped when the service is currently stopped, since starting it
already covers the change. When it fires, the provider restarts the service and the event is
marked Refreshed.
Next
Continue to The Apply Engine to see how a manifest of many
resources is parsed, ordered, and executed.
The Apply Engine
The apply engine turns a manifest into a sequence of applied resources. It resolves the
manifest from a file, an HTTP tarball, or a NATS object store; layers Hiera data and
overrides over it; builds each resource; and runs them in declaration order, recording an
event after each so later resources can see what earlier ones did.
Where it lives
resources/apply: the manifest parser and executor. resources/applyresource and
resources/applyresource/ccmmanifest: the apply meta-resource that lets one manifest apply
another. Key files: resources/apply/apply.go, resources/apply/jet.go,
resources/apply/validation.go, resources/applyresource/ccmmanifest/ccmmanifest.go.
Resolving a manifest
ResolveManifestReader (resources/apply/apply.go:395) is the core resolver. It runs before
any resource executes, and its ordering is precise: data is resolved and templates are
expanded before the schema is checked, so a runtime template does not fail a structural
validation.
Resolve the sourceResolveManifestUrl dispatches on scheme: obj:// to the object store, http(s) to a tarball fetch, empty scheme to a local file. Archive paths untar, find manifest.yaml, and set the working directory.
Parse the manifest Unmarshal the top-level data, hierarchy, and overrides, plus the ccm block with pre_message, post_message, fail_on_error, resources, and resources_jet_file.
Resolve Hierahiera.ResolveYaml consumes hierarchy.order, merge, and overrides, returning the resolved data and validation rules. Overriding data is deep-merged on top, then the rules are enforced.
Publish datamgr.SetData stores the resolved data and the template environment is built from it, so resource fields can reference Data.
Produce the resource list Either the inline ccm.resources, or, if resources_jet_file is set, the rendered output of a Jet template. Multi-name blocks are flattened in place, preserving order.
Parse and template each resourceNewValidatedResourcePropertiesFromYaml builds typed properties per type, resolves templates, and validates.
Validate against the schema Last, the resolved payload is checked against schemas/manifest.json, substituting placeholders for still-deferred template fields. NO_SCHEMA_VALIDATION=1 bypasses this.
Resolve once, then loop the resources in declaration order, recording each event before the next runs.
Execution and ordering
Execute (resources/apply/apply.go:603) opens a session, then iterates the resources. For
each it builds the concrete resource through the ResourceFactory, calls Apply or
Healthcheck, logs the result, records the event, and publishes any register_when_stable
entries. When fail_on_error is set, a failed resource stops the run after the current entry.
Load-bearing decision
There is no dependency graph and no topological sort. Resources run in manifest declaration
order. require is a fail-gate, not a scheduler: a resource whose required references failed
or were themselves skipped is skipped, not reordered. Authors must place producers before
consumers. This is the single most important invariant of the engine.
Cross-resource behavior is stateful through the session. Because Execute records each event
immediately, IsResourceFailed and ShouldRefresh inspect the last event for a reference, so
a later resource sees an earlier one’s change. This is again why declaration order matters.
Generating resources with Jet
When resources_jet_file is set instead of inline resources, jetParseManifestResources
(resources/apply/jet.go:19) renders a Jet template with the delimiters [[ and ]], chosen
so they do not collide with the {{ }} templating used for scalar fields. The resolved Hiera
Data is in scope, so a template can loop over Data.packages to emit a resource per item.
The rendered YAML is then parsed exactly like inline resources. Jet generates the resource
list; ordinary templating fills in individual fields.
Nested applies
The apply resource type lets one manifest apply another. Its sole provider,
ccmmanifest.Provider (resources/applyresource/ccmmanifest/ccmmanifest.go:31), snapshots the
manager’s noop, data, and working directory, runs the child inside the parent’s session so
events are shared, and restores the manager afterward.
Load-bearing decision
A nested apply can only strengthen execution, never relax it. Noop is turned on only if the
parent is not already noop and the child requests it; health-check-only is the OR of parent and
child. A child cannot escape the parent’s noop or health-check state. A parent can also forbid
nested applies entirely with WithDenyApplyResources unless the child sets AllowApply.
Recursion is bounded by DefaultMaxRecursionDepth = 10. Note that the depth guard is not fully
threaded through the apply-resource boundary today: Type.ApplyResource calls ApplyManifest
with a hardcoded depth of 0, so the guard is effectively aspirational for deeply nested
manifests. Two TODOs in the executor also flag that a resource-factory error currently aborts
the whole run rather than recording a failed event, and that resource dispatch should move into
the registry.
Next
Continue to The Agent to see how the engine is driven continuously on
a timer.
The Agent
The agent runs CCM continuously. It resolves facts and external data, fetches manifests from
one or more sources, applies them on an interval, and runs health checks that can trigger a
remediating apply between intervals. It is a single scheduler driving many per-source workers.
Where it lives
agent: the loop and its workers. Key files: agent/agent.go (the Agent type and the
control loop), agent/worker.go (the per-manifest worker), agent/config.go,
agent/http.go and agent/object.go (remote source watchers), agent/nc.go (a caching NATS
connection).
One scheduler, many workers
The Agent (agent/agent.go:30) creates one worker per manifest. Each worker only watches
its own source and requests an apply; it never schedules one. A single applyTicker in Run
drives every scheduled apply, which serializes fact refreshes and prevents two manifests from
applying at once. Workers signal the loop through a buffered-size-one applyTrigger channel,
and the send is coalesced so a burst of source changes collapses into one priority apply.
DefaultInterval
5 minutes. The apply cadence, floored at MinInterval of 30 seconds.
MinFactUpdateInterval
2 minutes. Facts are not re-gathered more often than this, independent of the apply interval.
applyTrigger
Buffered channel of size one. A worker whose source changed pushes a priority apply that runs even inside the interval window.
Sources
Dispatched by scheme in worker.cacheManifest: obj:// to a JetStream object store watcher, http(s) to a conditional-GET fetcher, empty scheme to a local file.
The loop
Scheduled applies on the left; health checks and remediation on the right feed the same apply step.
Facts, data, and resilience
Facts and data resolution sit behind a mutex and a retry policy. getFacts skips entirely
when the cached facts are younger than the 2-minute floor. Otherwise it retries under jittered
backoff, and after a configured number of failures it falls back to the last good facts rather
than blocking the loop. getData resolves external data through Hiera on each cycle and falls
back the same way. This is why a transient NATS or HTTP outage does not stall applies: the
agent keeps running on the last known-good inputs.
Health checks are deliberately independent of applies. runHealthChecks runs each worker in
health-check-only mode and does not refresh facts or data. A worker reporting a critical result
increments a remediation counter and queues a priority apply, but the queued applies fire only
after every check completes, so applies and checks never interleave.
Metrics and shutdown
When a monitor port is configured, the agent registers Prometheus collectors and serves
/metrics. It exposes apply and health-check durations, remediation counts, manifest fetch
counts and errors, and facts and data resolve timing. Shutdown is graceful: Run returns on
context cancellation after waiting for the workers, and Stop closes the manager. Workers use
a cancel-with-cause context, so a manifest deleted from an object store propagates a readable
reason.
Load-bearing decision
All fact and data mutation happens under the agent mutex, and every scheduled or triggered
apply acquires it. The single ticker plus the size-one trigger channel is what keeps concurrent
manifests from applying over each other. Removing the shared lock or the single scheduler would
reintroduce apply races.
Two items are reserved rather than active. AgentHealthCheckTime is registered but never
observed, so the health-check duration series stays empty. A TODO in agent.go notes the
intent to watch the KV for external data and only re-fetch on change, rather than re-resolving
every cycle.
Three cooperating packages feed every run. facts gathers what is true about the host.
hiera layers a hierarchy of data blocks into one resolved map, driven by those facts.
templates renders that data into resource fields. The manager wires them together and hands
the result to the apply engine.
Where it lives
facts: system fact collectors backed by gopsutil, plus file-based facts. hiera: the
hierarchy resolver and its comment-driven validation. templates: the render environment and
the expression engines. Key files: facts/facts.go, hiera/resolver.go, hiera/validate.go,
templates/templates.go, templates/expr.go, templates/jet.go.
Facts
facts.Gather (facts/facts.go:17) builds a map from the built-in families, host,
network, partition, cpu, and memory, each backed by gopsutil and each skippable with a
config flag. It then merges file-based facts on top: for the system config directory and then
the user directory, it reads facts.json, facts.yaml, and a sorted facts.d/ directory,
deep-merging each in order so later sources win.
Load-bearing decision
File facts refuse symlinks and require absolute config directories. Symlinked fact files and
directories are rejected outright. This is a security invariant: fact data drives what gets
installed and where, so it must not be redirectable through a planted symlink.
Runtime caching lives in the manager, not the facts package. Facts() gathers once and
memoizes; SystemFacts() always re-gathers with a 2-second default deadline; SetFacts and
MergeFacts let callers override or overlay, which is how the agent reuses its last good facts
when a gather fails.
Hiera
Facts drive the hierarchy; the merged data and facts feed the template environment that renders resource fields.
Resolve (hiera/resolver.go:98) starts from the data: block as the base, then walks
hierarchy.order. Each level is a template like os:{{ lookup('facts.host.info.platformFamily') }}.
A level counts as matched only when its embedded expression produces a non-empty value, so a
level whose fact is missing is skipped. The matched string is looked up in overrides: and
merged into the base.
merge: first
The default. The first matching level wins; its top-level keys replace the base, and resolution returns immediately.
merge: deep
Every matching level accumulates. Maps merge recursively and slices concatenate, in hierarchy order.
Sources
ResolveUrl dispatches by scheme: a local YAML or JSON file, an http(s) URL with basic auth, or a NATS JetStream KV document at kv://Bucket/Key.
Validation
YAML comments @require and @validate <expr> become rules. Resolution returns the rules so a multi-source caller validates once after merging.
Load-bearing decision
Hiera resolution runs in a restricted sandbox. During resolution only facts and lookup()
are available, with missing keys returning empty rather than erroring. File, KV, and
registration functions are excluded. The full function set only exists later, at render time, in
the manager-built environment. This keeps hierarchy selection from reaching into the network.
Templates
{{ expr }} and ${ expr } are aliases. Both route through the same expr-lang evaluator, so
${ Data.package_name } and {{ Data.package_name }} are identical. A separate jet(...)
renderer handles Jet templates with [[ ]] delimiters, used mainly to generate resource lists.
Field access
Inside an expression, use Go field names: Data.app_name, Facts.os, Environ.X.
lookup(key, default)
Takes a lowercased, dotted path rooted at the environment: lookup('data.nested.key'), lookup('facts.env'). It marshals the environment to JSON and queries it with gjson.
Type preservation
When the whole string is a single expression, its native typed value is returned, so a templated port stays an integer. Mixed strings are stringified and concatenated.
Deferred fields
Struct fields tagged template:"deferred", such as file content, render in a second pass after the control gate, so an unless can protect a resource from a template error.
Load-bearing decision
kvGet(bucket, key) and the kv://Bucket/Key Hiera source are different mechanisms and are
easy to conflate. kvGet is an in-template lookup of a single KV value at render time. kv://
is a whole-document Hiera data source resolved during data resolution.
One syntax is reserved but not implemented. The Resolve doc comment and a test reference
Puppet-style env:%{env} placeholders, but only {{ }} and ${ } are recognized. A %{...}
entry is treated as a literal key with no matched expression, so it is skipped. Real
hierarchies use env:{{ lookup('facts.env') }}.
Next
Continue to Registration and Discovery to see how resolved
resources announce themselves for others to find.
Registration and Discovery
Registration is service discovery without a discovery daemon. When a managed resource reaches
a stable state, it publishes an entry into a NATS JetStream stream. Other nodes read those
entries, usually from a template, to build configuration such as a load-balancer upstream or a
Prometheus target list. Publishing is a side effect of apply; lookup is a template function
or a CLI query.
Where it lives
registration: the transport, subject grammar, stream, lookup, and watch. model/registration.go
and model/registration_ttl.go: the entry and TTL data model. Key files:
registration/subject.go, registration/stream.go, registration/nats.go,
registration/lookup.go, registration/watch.go.
The publish gate
A resource carries a register_when_stable list. After the resource applies,
publishRegistration (resources/apply/apply.go:736) decides whether to announce it. The gate
is conservative: it publishes only when the resource has entries, the manager is not in noop
mode, the event did not fail, and every health check returned OK. A resource with no health
checks passes that last test.
Load-bearing decision
A missing registration publisher is tolerated, not fatal. The manager defaults to a no-op
publisher, so a register_when_stable manifest runs safely on a node with no registration
backend. This is what lets the same manifest apply on nodes with and without NATS.
A KV store built from a stream
The stream behaves like a KV store keyed on the entry tuple; readers take the last message per subject.
Each entry maps to a single subject. The address contributes its IP with dots turned into
underscores so it is one token, and an InstanceId FNV hash of the full tuple forms the final
token, so every unique cluster, service, protocol, address, and port maps to one stable
subject. The stream captures the whole namespace with MaxMsgsPerSubject: 1 and AllowRollup,
and reliable publishes carry a Nats-Rollup: sub header.
Load-bearing decision
One message per subject plus rollup plus a hashed per-instance subject makes the stream behave
like a KV store keyed on the entry tuple. Re-registering overwrites in place, and a read is
simply “last message per subject.” Full KV semantics only hold with the JetStream destination;
the plain nats destination is best-effort and omits the TTL and rollup headers.
Expiry, lookup, and watch
Expiry is two-tiered. A per-entry Nats-TTL header sits under the stream-wide MaxAge, and on
removal the server writes a subject delete marker so watchers see the removal rather than a
silent disappearance. The stream denies hard deletes but allows purge, so JetStreamRemove can
retire a single entry.
Lookup
JetStreamLookup builds a wildcard filter subject, fetches the last message per matching subject, drops server marker messages, and returns entries sorted by address then port.
Watch
JetStreamWatch streams WatchEvents: normal messages become Register events, marker messages become Remove events with the entry reconstructed from the subject.
From a template
The manager exposes lookup as registrations(cluster, protocol, service, ip), so a rendered config file discovers its peers directly.
Prometheus
RegistrationEntries.PrometheusFileSD() converts entries into file_sd JSON, grouped by cluster, service, and protocol.
Load-bearing decision
Both lookup and watch special-case the Nats-Marker-Reason header, per ADR-43. Lookup drops
markers; watch turns them into removals. A reader that ignored markers would surface expired or
purged entries as live services.
The Priority and Annotations fields travel on every entry but, within this subsystem, are
only consumed by the Prometheus conversion. Priority is validated and sorted around but not
otherwise acted on here, reserved for downstream consumers such as SRV-style weighting.
Next
Continue to Observability to see how each apply is recorded,
measured, and health-checked.
Observability
Every applied resource produces one event. That event is recorded to a session store,
translated into Prometheus counters, and aggregated into a summary at the end of the run.
Health checks run alongside and feed the agent’s remediation. Together these are how a CCM run
is observed after the fact and monitored while it runs.
Where it lives
internal/session: the session stores. internal/metrics: the Prometheus collectors.
internal/healthcheck: the goss and nagios runners. model/transaction.go and
model/healthcheck.go: the event and health-check types. Key files: internal/session/directory.go,
internal/session/util.go, internal/metrics/metrics.go.
The event flow
One event per resource, recorded once, then fanned out to counters and the run summary.
Sessions and events
StartSession records a start event, each applied resource records a TransactionEvent, and
StopSession builds a SessionSummary from all events. The memory store keeps events in a
slice and is the default. The directory store writes one JSON file per event, named by the
event’s KSUID.
Load-bearing decision
Event IDs are KSUIDs, and that choice does double duty. KSUIDs are timestamp-prefixed and
lexicographically sortable, so sorting the .event filenames reconstructs chronological order
without reading timestamps. Parsing the ID also whitelists it to base62 with no path
separators, which is the directory store’s defense against path traversal, since the ID becomes
a filename.
The directory store is append-only and crash-friendly. Each record is an independent file
write with no shared index to corrupt, and replay is a directory scan that reads each event’s
protocol field to pick the concrete type before unmarshaling.
Metrics
Collectors live under the choria namespace and ccm subsystem. updateMetrics
(internal/session/util.go:12) runs from both stores’ RecordEvent. It increments a total and
exactly one outcome counter, chosen by a priority order: noop, then changed, skipped, refreshed,
failed, error, and stable.
Load-bearing decision
Noop is checked first in that priority order. A noop run has Changed=true on its event, but it
counts as noop, not changed, so the counters stay mutually consistent: each event increments the
total plus exactly one state counter.
Durations are recorded with timers around the work: manifest apply, resource apply, fact gather,
and per-check health-check time. RegisterMetrics registers the collectors and ListenAndServe
serves them at /metrics when a port is set.
Health checks
Health checks run for both apply and health-check-only modes. Each check dispatches by format.
nagios
Runs a command through the manager's runner and maps its exit code to a status: 0 is OK, 1 Warning, 2 Critical, anything else Unknown. Retries up to tries, sleeping between attempts, stopping on OK.
goss
Renders the goss rules through the template engine, writes a temp spec, validates it, and reports Critical when any check failed, else OK. It never emits Warning or Unknown.
Status
HealthCheckStatus values equal the nagios exit codes, so the plugin format maps directly and goss reuses the same enum.
A non-OK result or an execution error marks the event failed. In the agent, a critical result
increments a remediation counter and queues a priority apply, so a failing check drives a
corrective run. Several Agent* metrics are declared here but recorded at their call sites in
the agent, and AgentHealthCheckTime is currently registered without a call site.
Next
Continue to the Reference and Map for the CLI surface, the source
map, and a glossary.
Reference and Map
This page is the index to the codebase: the commands a user can run, the packages that back
them, and the types that recur across the subsystem pages.
Where it lives
cmd: the entire CLI, one file per command, built on choria-io/fisk. main is in
cmd/ccm.go. Shared plumbing, including manager construction and the .env reader, is in
cmd/util.go.
Command surface
Every ccm command builds a manager and drives a subsystem covered elsewhere in this map. The
global flags --debug and --info set log verbosity.
Command
Purpose
Drives
ccm ensure <type>
Manage one resource imperatively: archive, exec, file, package, scaffold, service
Every ensure subcommand and the piped API funnel through one factory,
resources.NewResourceFromProperties, so the CLI, manifests, and the wire API run identical
resource logic. The status command’s valid types are generated at run time from the registry,
so it tracks whatever providers are registered.
Source map
Package
Contents
cmd/
The fisk CLI surface, main, one file per command, shared helpers in util.go.
agent/
The continuous runner: config, worker loop, and NATS, HTTP, and object-store sources.
facts/
System fact collectors backed by gopsutil, plus file-based facts.
hiera/
The hierarchical data resolver and comment-driven validation.
templates/
The render environment and the expr, Jet, and Go template engines.
manager/
The concrete model.Manager (type CCM), its options, and logger adapters.
model/
Core interfaces and shared structs; per-resource property types; modelmocks/.
registration/
Service registration over JetStream: stream, subjects, publish, lookup, watch.
resources/
Resource implementations, the shared base, the apply engine, and provider subpackages.
internal/registry/
The global provider directory and FindSuitableProvider.
internal/session/
The directory and memory session stores.
internal/metrics/
Prometheus collectors and the /metrics server.
internal/healthcheck/
The goss and nagios health-check runners.
internal/ (other)
cmdrunner, backoff, fs (embedded schemas), and util helpers.
Integration points - Factory functions and registry
CLI commands - User-facing command line interface
JSON schemas - Validation for manifests and API requests
Documentation - User and design documentation
CCM Studio - Web-based manifest designer
File Checklist
File
Action
Purpose
model/resource_<type>.go
Create
Properties, state, metadata structs
model/resource_<type>_test.go
Create
Property validation tests
model/resource.go
Modify
Add case to factory function
resources/<type>/<type>.go
Create
Provider interface definition
resources/<type>/type.go
Create
Resource type implementation
resources/<type>/type_test.go
Create
Resource type tests
resources/<type>/provider_mock_test.go
Generate
Mock provider for tests
resources/<type>/<provider>/factory.go
Create
Provider factory
resources/<type>/<provider>/<provider>.go
Create
Provider implementation
resources/<type>/<provider>/<provider>_test.go
Create
Provider tests
resources/resources.go
Modify
Add case to NewResourceFromProperties
cmd/ensure_<type>.go
Create
CLI command handler
cmd/ensure.go
Modify
Register CLI command
internal/fs/schemas/manifest.json
Modify
Add resource schema definitions
internal/fs/schemas/resource_ensure_request.json
Modify
Add API request schema
docs/content/resources/<type>.md
Create
User documentation
docs/content/design/<type>/_index.md
Create
Design documentation
docs/content/design/<type>/<provider>.md
Create
Provider documentation
Step 1: Model Definitions
Create model/resource_<type>.go with the following components.
Constants
const (
// ResourceStatus<Type>Protocol is the protocol identifier for <type> resource stateResourceStatus<Type>Protocol = "io.choria.ccm.v1.resource.<type>.state"// <Type>TypeName is the type name for <type> resources <Type>TypeName = "<type>")
Properties Struct
The properties struct must satisfy model.ResourceProperties:
type <Type>ResourcePropertiesstruct {
CommonResourceProperties`yaml:",inline"`// All string fields are automatically template-resolved by default.// Use struct tags to control resolution behavior.Urlstring`json:"url" yaml:"url"`Checksumstring`json:"checksum,omitempty" yaml:"checksum,omitempty"`// Fields that must not be template-resolvedDelimiterstring`json:"delimiter,omitempty" yaml:"delimiter,omitempty" template:"-"`// Fields deferred until after control evaluationContentstring`json:"content,omitempty" yaml:"content,omitempty" template:"deferred"`// ...}
Key points:
Embed CommonResourceProperties with yaml:",inline" tag
Use JSON and YAML struct tags for serialization
In Validate(), call p.CommonResourceProperties.Validate() first, then add type-specific validation
Template resolution is handled automatically via reflection - see the Template Resolution section for details
ResolveDeferredTemplates() is called after control evaluation (if/unless). Override it only if you have template:"deferred" fields that need post-processing (e.g. filepath.Clean). The default no-op from CommonResourceProperties is sufficient for most types. See the file resource for an example where Contents and Source are deferred
State Struct
The state struct must satisfy model.ResourceState:
type <Type>Metadatastruct {
Namestring`json:"name" yaml:"name"`Providerstring`json:"provider,omitempty" yaml:"provider,omitempty"`// Add fields describing current system state}
type <Type>Statestruct {
CommonResourceStateMetadata*<Type>Metadata`json:"metadata,omitempty"`}
Embedding *base.Base provides implementations for Apply(), Healthcheck(), Type(), Name(), Properties(), and NewTransactionEvent(). The type must implement:
See resources/archive/type.go for a complete constructor example.
ApplyResource Method
The ApplyResource method (part of base.EmbeddedResource) contains the core logic. It should follow this pattern:
Get initial state via provider.Status()
Check if already in desired state (implement isDesiredState() helper)
If stable, call t.FinalizeState() and return early
Apply changes, respecting t.mgr.NoopMode()
Get final state and verify desired state was achieved
Call t.FinalizeState() with appropriate flags
See resources/archive/type.go:ApplyResource() for a complete example.
Provider Selection Methods
The SelectProvider() method should use registry.FindSuitableProvider() to select an appropriate provider. See resources/archive/type.go for the standard implementation pattern.
if !noop {
// Make actual changest.log.Info("Applying changes")
err = p.SomeAction(ctx, properties)
} else {
t.log.Info("Skipping changes as noop")
noopMessage = "Would have applied changes"}
Error Handling
Use sentinel errors from model/errors.go:
var (
ErrResourceInvalid = errors.New("resource invalid")
ErrProviderNotFound = errors.New("provider not found")
ErrNoSuitableProvider = errors.New("no suitable provider")
ErrDesiredStateFailed = errors.New("desired state not achieved")
)
Wrap errors with context:
err:=os.Remove(path)
iferr!=nil {
returnfmt.Errorf("could not remove file: %w", err)
}
File Ownership and Mode
When a provider creates a file by writing to a temp file and renaming it into place, apply ownership and mode by path after the rename, not by file descriptor before it:
fchown(2) via *os.File.Chown is silently dropped by some shared-filesystem backends β notably Docker Desktop bind mounts on macOS/Windows (VirtioFS, gRPC-FUSE) and various FUSE-backed shares. The fchown call returns success but the ownership change does not survive the rename, leaving the target file owned by the caller. Path-based chown(2) and chmod(2) are forwarded correctly by these layers, so applying them after the rename is portable.
See resources/file/posix/posix.go (Store, SetAttributes) and resources/archive/http/http.go (Download) for the established pattern.
Template Resolution
Template resolution uses a reflection-based struct walker (templates.ResolveStructTemplates) that automatically resolves {{ expression }} placeholders in all string-typed fields. The walker recurses into all composite types including slices, maps, nested structs, and pointer fields.
By default, all fields are template-resolved. You control behavior with the template struct tag:
Tag
Behavior
(none)
Resolved during ResolveTemplates() (phase 1)
template:"-"
Never resolved - use for enum values, literal delimiters, resource references, or fields evaluated separately (like control expressions)
template:"deferred"
Skipped in phase 1, resolved during ResolveDeferredTemplates() (phase 2, after control evaluation)
template:"resolve_keys"
For map fields, also resolve map keys (rebuilds the map). By default only map values are resolved
Fields tagged json:"-" are automatically skipped (these are internal computed fields like ParsedTimeout).
Supported types (resolved recursively):
string and named string types (e.g. type MyType string)
[]string, []any
map[string]string, map[string]any, map[string][]string, and other map variants with string keys
[]map[string]string, []map[string]any
Nested and embedded structs, *struct pointers
any / interface{} fields holding any of the above
Arbitrary nesting depth
Types that are not resolved: bool, int, float, time.Duration, []byte / yaml.RawMessage, nil pointers.
Implementation pattern - most resource types need only:
The resolveRegistrations call (inherited from CommonResourceProperties) handles RegisterWhenStable entries which need special typed resolution for the Port field.
Deferred resolution is used for fields whose template evaluation may fail when the resource would be skipped by a control (if/unless). Tag these fields with template:"deferred" and override ResolveDeferredTemplates():
This method is called by base.Base after control evaluation passes, so templates are only evaluated for resources that will actually be applied. Because deferred resolution happens at apply time rather than during manifest parsing, templates using functions like file() can access content created by earlier resources in the same run. The default no-op implementation inherited from CommonResourceProperties is sufficient for types that have no template:"deferred" fields.
Schema placeholders for deferred fields
Manifest-level JSON schema validation runs after phase 1 template resolution, so any field that is still templated at that point β typically template:"deferred" fields β would otherwise fail a pattern constraint such as ^[A-Za-z_][A-Za-z0-9_]*=.+$ on the exec environment items. To keep the schema strict while permitting template expressions in those fields, add a schema_placeholder:"..." struct tag whose value satisfies the JSON schema pattern. During validation only, any remaining ${ ... } / {{ ... }} value is rewritten to the placeholder before the schema check runs.
When a deferred field has no pattern constraint a placeholder is not required; the substitution falls back to a generic string. Note: the schema’s pattern check is only enforced on the placeholder, not on the user’s template syntax, so partial templates like "99${ x }" will not be caught at validation time β rely on the resource’s Go-side Validate() for value-level checks after deferred resolution completes.
Provider Selection
Providers declare manageability via IsManageable on the factory (see model.ProviderFactory in Step 3). Multiple providers can match; the one with highest priority is selected.
Documentation
Create user documentation in docs/content/resources/<type>.md covering:
Overview and use cases
Ensure states table
Properties table with descriptions
Usage examples (manifest, CLI, API)
Create design documentation in docs/content/design/<type>/_index.md covering:
Provider interface specification
State checking logic
Apply logic flowchart
Create provider documentation in docs/content/design/<type>/<provider>.md covering:
Provider selection criteria
Platform requirements
Implementation details
CCM Studio
CCM Studio is a web-based manifest designer. After adding a new resource type, update CCM Studio to support it:
Note
CCM Studio is a closed-source project. The maintainers will complete this step.
Add the new resource type to the resource palette
Create property editors for type-specific fields
Add validation matching the JSON schema definitions
Update any resource type documentation or help text
Docs Style Guide
This guide describes the writing conventions used throughout the CCM documentation. Follow these rules when adding or editing pages.
All sections apply to every documentation page. The Page structure section applies only to resource reference pages under resources/.
Voice and tone
Write in plain, direct North American English.
Use the present tense and active voice: “The service resource manages system services,” not “System services are managed by the service resource.”
Address the reader implicitly. Do not use “you” or “we”. State facts and give instructions: “Specify commands with their full path,” not “You should specify commands with their full path.”
Keep sentences short. One idea per sentence.
Do not editorialize or use filler (“Note that,” “It is important to,” “Simply”).
Do not use emojis.
Do not use em dashes. Use commas, periods, or semicolons instead.
Page structure
Every resource page follows this order:
Front matter: TOML (+++) with title, description, toc = true, and weight.
Opening paragraph: One or two sentences stating what the resource does.
Callout: A warning or note about common pitfalls, using > [!info] syntax.
Primary example: A tabbed block (Manifest / CLI / API Request) showing typical usage.
Brief explanation: One or two sentences describing what the example does.
Ensure values: Table of valid ensure states.
Properties: Table of all properties with short descriptions.
Additional sections: Provider notes, idempotency, authentication, behavioral details as needed.
Not every example needs all three tabs. Secondary examples deeper in a page may show only the most relevant format.
YAML
Use realistic but minimal values.
Quote version strings and octal modes: "5.9", "0644".
CLI
Use nohighlight as the fence language.
Use backslash continuations for long commands.
Add a brief comment above the command when context is needed.
JSON
Use json as the fence language.
Always include the protocol and type fields in API examples.
Callouts
Use the > [!info] blockquote syntax for warnings and notes:
> [!info] Warning
> Use absolute file paths and primary group names.
> [!info] Note
> The provider will not run `apt update` before installing a package.
Use Warning for constraints the reader must follow to avoid errors. Use Note for supplementary information. A custom label may replace Warning or Note when it adds clarity, such as > [!info] Default Hierarchy.
Version badges
Mark features with the CCM release that introduced them using a Hugo badge shortcode. Place the badge immediately after the section heading or, for new properties, at the end of the description cell:
## Manage attributes only Version0.0.29| `force` (boolean) | Allow `ensure: absent` to remove non-empty directories Version0.0.28 |
Use style="primary" and title="Version". The badge body is the release tag without a leading v.
Add a version badge only when a feature is introduced. Do not retroactively badge pre-existing content, and remove the badge once the release in question is several versions behind current.
Descriptions and explanations
After a tabbed example block, add one or two sentences explaining what the example does and why.
Describe behavior, not implementation: “The command runs only if /tmp/hello does not exist,” not “The code checks whether the file exists and skips execution if found.”
When describing how multiple options interact, use a truth table.
Terminology
Use “resource,” “provider,” “property,” “manifest” consistently.
Refer to ensure states and property names in backticks: present, name, ensure.
Reference other resources using the type#name notation in backticks: package#httpd.
When cross-referencing other documentation pages, use relative Hugo links.
General formatting
No trailing whitespace.
One blank line between sections.
No blank line between a heading and its first paragraph.
Wrap inline code, file paths, command names, property names, and values in backticks.
Do not use bold or italic for emphasis in reference content. Reserve bold for definition list terms within prose.