Design Documents

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
  • Exec Type: Exec resource for command execution
  • File Type: File resource for managing files and directories
  • Package Type: Package resource for system package management
  • Scaffold Type: Scaffold resource for template directory rendering
  • Service Type: Service resource for system service management
  • Code Map: A guided deep-dive into the CCM codebase.
  • Adding a Type: How to add a new resource type to CCM
  • Docs Style Guide: Define writing conventions for CCM documentation

Subsections of Design Documents

Archive Type

This document describes the design of the archive resource type for downloading and extracting archives.

Overview

The archive resource manages remote archives with three phases:

  • Download: Fetch archive from a URL to local filesystem
  • Extract: Unpack archive contents to a target directory
  • Cleanup: Optionally remove the archive file after extraction

These phases are conditional based on current state and configuration.

Provider Interface

Archive providers must implement the ArchiveProvider interface:

type ArchiveProvider interface {
    model.Provider

    Download(ctx context.Context, properties *model.ArchiveResourceProperties, log model.Logger) error
    Extract(ctx context.Context, properties *model.ArchiveResourceProperties, log model.Logger) error
    Status(ctx context.Context, properties *model.ArchiveResourceProperties) (*model.ArchiveState, error)
}

Method Responsibilities

MethodResponsibility
StatusQuery archive file existence, checksum, attributes, and creates file
DownloadFetch archive from URL, verify checksum, set ownership
ExtractUnpack archive contents to extract parent directory

Status Response

The Status method returns an ArchiveState containing:

type ArchiveState struct {
    CommonResourceState
    Metadata *ArchiveMetadata
}

type ArchiveMetadata struct {
    Name          string    // Archive file path
    Checksum      string    // SHA256 hash of archive
    ArchiveExists bool      // Whether archive file exists
    CreatesExists bool      // Whether creates marker file exists
    Owner         string    // Archive file owner
    Group         string    // Archive file group
    MTime         time.Time // Modification time
    Size          int64     // File size in bytes
    Provider      string    // Provider name (e.g., "http")
}

The Ensure field in CommonResourceState is set to:

  • present if the archive file exists
  • absent if the archive file does not exist

Available Providers

ProviderSourceDocumentation
httpHTTP/HTTPS URLsHTTP

Ensure States

ValueDescription
presentArchive must be downloaded (and optionally extracted)
absentArchive file must not exist

Supported Archive Formats

ExtensionDescription
.tar.gz, .tgzGzip-compressed tar archive
.tarUncompressed tar archive
.zipZIP archive

The URL and local file name must have matching archive type extensions.

Apply Logic

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Get current state via Status()          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Is current state desired state?         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              Yes β”‚         No
                  β–Ό         β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
          β”‚ No change β”‚     β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
                            β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ What is desired ensure? β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚ absent                        β”‚ present
            β–Ό                               β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Remove archiveβ”‚             β”‚ Download needed?    β”‚
    β”‚ file          β”‚             β”‚ (checksum mismatch  β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚  or file missing)   β”‚
                                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                        Yes β”‚         No
                                            β–Ό         β”‚
                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
                                    β”‚ Download  β”‚     β”‚
                                    β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜     β”‚
                                          β”‚           β”‚
                                          β–Ό           β–Ό
                                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                  β”‚ Extract needed?         β”‚
                                  β”‚ (extract_parent set AND β”‚
                                  β”‚  (download occurred OR  β”‚
                                  β”‚   creates file missing))β”‚
                                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                        Yes β”‚         No
                                            β–Ό         β”‚
                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
                                    β”‚ Extract   β”‚     β”‚
                                    β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜     β”‚
                                          β”‚           β”‚
                                          β–Ό           β–Ό
                                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                  β”‚ Cleanup enabled?        β”‚
                                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                        Yes β”‚         No
                                            β–Ό         β–Ό
                                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”
                                    β”‚ Remove    β”‚ β”‚ Done  β”‚
                                    β”‚ archive   β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”˜
                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Idempotency

The archive resource uses multiple checks for idempotency:

State Checks (in order)

  1. Ensure absent: Archive file must not exist
  2. Creates file: If creates is set, the marker file must exist
  3. Archive existence: If cleanup: false, archive must exist
  4. Owner/Group: Archive file attributes must match
  5. Checksum: If specified, archive checksum must match

Decision Table

ConditionStable?
ensure: absent + archive missingYes
ensure: absent + archive existsNo (remove)
creates file existsYes (skip all)
creates file missingNo (extract needed)
cleanup: false + archive missingNo (download needed)
Archive checksum mismatchNo (re-download needed)
Archive owner/group mismatchNo (re-download needed)

Creates Property

The creates property provides idempotency for extraction:

- archive:
    - /tmp/app.tar.gz:
        url: https://example.com/app.tar.gz
        extract_parent: /opt/app
        creates: /opt/app/bin/app
        owner: root
        group: root

Behavior:

  • If /opt/app/bin/app exists, skip download and extraction
  • Useful when extracted files indicate successful prior extraction
  • Prevents re-extraction on every run

Cleanup Property

The cleanup property removes the archive after extraction:

- archive:
    - /tmp/app.tar.gz:
        url: https://example.com/app.tar.gz
        extract_parent: /opt/app
        creates: /opt/app/bin/app
        cleanup: true
        owner: root
        group: root

Requirements:

  • extract_parent must be set (cleanup only makes sense with extraction)
  • creates must be set to track extraction state

Behavior:

  • After successful extraction, remove the archive file
  • On subsequent runs, creates file prevents re-download

Checksum Verification

When checksum is specified:

- archive:
    - /tmp/app.tar.gz:
        url: https://example.com/app.tar.gz
        checksum: "a1b2c3d4..."
        owner: root
        group: root

Behavior:

  • Downloaded file is verified against SHA256 checksum
  • Existing file checksum is compared to detect changes
  • Checksum mismatch triggers re-download
  • Download fails if fetched content doesn’t match

Authentication

Archives support two authentication methods:

Basic Authentication

- archive:
    - /tmp/app.tar.gz:
        url: https://private.example.com/app.tar.gz
        username: deploy
        password: "{{ lookup('data.password') }}"
        owner: root
        group: root

Custom Headers

- archive:
    - /tmp/app.tar.gz:
        url: https://api.example.com/releases/app.tar.gz
        headers:
          Authorization: "Bearer {{ lookup('data.token') }}"
        owner: root
        group: root

Required Properties

PropertyRequiredDescription
urlYesSource URL for download
ownerYesUsername that owns the archive file
groupYesGroup that owns the archive file

URL Validation

URLs are validated during resource creation:

  • Must be valid URL format
  • Scheme must be http or https
  • Path must end with supported archive extension
  • Extension must match the name property extension

Noop Mode

In noop mode, the archive type:

  1. Queries current state normally
  2. Computes what actions would be taken
  3. Sets appropriate NoopMessage:
    • “Would have downloaded”
    • “Would have extracted”
    • “Would have cleaned up”
    • “Would have removed”
  4. Reports Changed: true if changes would occur
  5. Does not call provider Download/Extract methods
  6. Does not remove files

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:

  1. The URL scheme is http or https
  2. The archive file extension is supported (.tar.gz, .tgz, .tar, .zip)
  3. 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:

  1. Parse the URL and add Basic Auth credentials if username/password provided
  2. Create HTTP request with custom headers (if specified)
  3. Execute GET request via util.HttpGetResponse()
  4. Verify HTTP 200 status code
  5. Create temporary file in the same directory as the target
  6. Set ownership on temp file before writing content
  7. Copy response body to temp file
  8. Verify checksum if provided
  9. Atomic rename temp file to target path

Atomic Write Pattern:

[parent dir]/archive-name-* (temp file)
    ↓ write content
    ↓ set owner/group
    ↓ verify checksum
    ↓ rename
[parent dir]/archive-name (final file)

The temp file is created in the same directory as the target to ensure os.Rename() is atomic (same filesystem).

Error Handling:

ConditionBehavior
HTTP non-200Return error with status code
Write failureClean up temp file, return error
Checksum mismatchClean up temp file, return error with expected vs actual
Rename failureTemp file cleaned up by defer

Authentication:

MethodImplementation
Basic AuthURL userinfo is passed to HttpGetResponse() which sets Authorization header
Username/Password propertiesEmbedded in URL before request: url.UserPassword(username, password)
Custom HeadersAdded to request via http.Header.Add()

Extract

Process:

  1. Validate ExtractParent is set
  2. Create ExtractParent directory if it doesn’t exist (mode 0755)
  3. Determine archive type from file extension
  4. Execute appropriate extraction command

Extraction Commands:

ExtensionCommand
.tar.gz, .tgztar -xzf <archive> -C <extract_parent>
.tartar -xf <archive> -C <extract_parent>
.zipunzip -d <extract_parent> <archive>

Command Execution:

Commands are executed via model.CommandRunner.ExecuteWithOptions() with:

OptionValue
Commandtar or unzip
ArgsExtraction flags and paths
CwdExtractParent directory
Timeout1 minute

Error Handling:

ConditionBehavior
Unsupported extensionReturn “archive type not supported” error
Command not foundRunner returns error
Non-zero exit codeReturn error with exit code and stderr

Status

Process:

  1. Initialize state with EnsureAbsent default
  2. Check if archive file exists via os.Stat()
  3. If exists: set EnsurePresent, populate metadata (size, mtime, owner, group, checksum)
  4. If Creates property set: check if creates file exists

Metadata Collected:

FieldSource
NameFrom properties
Provider“http”
ArchiveExistsos.Stat() success
SizeFileInfo.Size()
MTimeFileInfo.ModTime()
Ownerutil.GetFileOwner() - resolves UID to username
Grouputil.GetFileOwner() - resolves GID to group name
Checksumutil.Sha256HashFile()
CreatesExistsos.Stat() on Creates path

Idempotency

The provider supports idempotency through the type’s isDesiredState() function:

State Checks (in order)

  1. Ensure Absent: If ensure: absent, archive must not exist
  2. Creates File: If Creates set and file doesn’t exist β†’ not stable
  3. Archive Existence: If cleanup: false, archive must exist
  4. Owner/Group: Must match properties
  5. Checksum: If specified in properties, must match

Note: When cleanup: true, the creates property is required (enforced at validation time).

Decision Matrix

Archive ExistsCreates ExistsChecksum MatchCleanupStable?
NoNoN/AfalseNo (download needed)
YesNoYesfalseNo (extract needed)
YesYesYesfalseYes
NoYesN/AtrueYes
YesYesYestrueNo (cleanup needed)

Checksum Verification

Algorithm: SHA-256

Implementation:

sum, err := util.Sha256HashFile(tempFile)
if sum != properties.Checksum {
    return fmt.Errorf("checksum mismatch, expected %q got %q", properties.Checksum, sum)
}

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

OperationTimeoutConfigurable
HTTP Download1 minute (default in HttpGetResponse)No
Archive Extraction1 minuteNo

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:

type ApplyProvider interface {
    model.Provider

    ApplyManifest(ctx context.Context, mgr model.Manager, properties *model.ApplyResourceProperties, currentDepth int, healthCheckOnly bool, log model.Logger) (*model.ApplyState, error)
}

Method Responsibilities

MethodResponsibility
ApplyManifestResolve and execute a child manifest, handling state save/restore and overrides

State Response

The ApplyManifest method returns an ApplyState containing:

type ApplyState struct {
    CommonResourceState
    ResourceCount int // Number of resources in the child manifest
}

Available Providers

ProviderSourceDocumentation
ccmmanifestLocal manifest fileCCM Manifest

Ensure States

ValueDescription
presentResolve and execute the child manifest

Only present is valid. The ensure property defaults to present if not specified.

Properties

PropertyTypeDescription
namestringFile path to the child manifest
noopboolExecute child in noop mode (can only strengthen)
health_check_onlyboolExecute child in health check mode (can only strengthen)
allow_applyboolAllow the child manifest to contain apply resources (default true)
datamap[string]anyData to pass to the child manifest, merged with external data

Apply Logic

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Save parent state (noop, data, wd)      β”‚
β”‚ (restored via defer on all paths)       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Strengthen noop and health_check_only   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Resolve child manifest                  β”‚
β”‚ (applies data overrides, sets wd)       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Recursion depth within    β”‚
    β”‚ limit?                    β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              Yes β”‚         No
                  β”‚         β”‚
                  β–Ό         β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”
    β”‚ allow_apply     β”‚ β”‚ Error β”‚
    β”‚ satisfied?      β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”˜
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”˜
              Yes β”‚         No
                  β”‚         β”‚
                  β–Ό         β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Execute child   β”‚ β”‚ Error β”‚
    β”‚ resources       β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”˜
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Report resource count, changed, failed  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Noop Strengthening

Noop mode follows a strict strengthening rule: a child manifest can run in noop mode when the parent does not, but a child can never weaken noop mode.

Parent noopChild noop propertyEffective child noopBehavior
truefalsetrueWarning logged, parent noop applies
truetruetrueBoth agree
falsetruetrueChild strengthens to noop
falsefalsefalseNormal execution

Health check mode follows the same strengthening pattern.

State Save and Restore

The provider saves three pieces of manager state before manifest resolution and restores them after execution:

StateSave methodRestore methodReason
Noop modeNoopMode()SetNoopMode(saved)Child noop must not leak to subsequent resources
Working directoryWorkingDirectory()SetWorkingDirectory(wd)ResolveManifestUrl changes working directory
DataData()SetData(saved)Child data overrides must not persist

State is saved before calling any resolve functions because ResolveManifestUrl mutates the manager’s working directory and data during resolution.

Recursion Depth Limiting

Nested apply resources increment a depth counter passed through Execute() options. The default maximum depth is 10.

parent.yaml (depth 0)
  +-- child.yaml (depth 1)
        +-- grandchild.yaml (depth 2)
              +-- ... (up to depth 10)

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 valueChild contains apply resourcesResult
true (default)YesAllowed
true (default)NoAllowed
falseYesError
falseNoAllowed

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:

- apply:
    - child.yaml:

- exec:
    - post-apply:
        command: /usr/local/bin/notify.sh
        refresh_only: true
        subscribe:
          - apply#child.yaml

Child Manifest Failures

After child manifest execution, the provider inspects the result to determine the outcome:

Child resultParent behavior
All resources succeededLog success, report unchanged
Some resources changedLog warning, report changed
Any resource failedReturn error with failure count

Subsections of Apply Type

CCM Manifest Provider

This document describes the implementation details of the CCM Manifest provider for resolving and executing child manifests.

Provider Selection

The CCM Manifest provider is the only apply provider. It is always available and returns priority 1 for all apply resources.

Operations

ApplyManifest

Process:

  1. Capture parent state (noop mode, working directory, data)
  2. Strengthen noop mode if the parent is in noop mode or the resource has noop: true
  3. Build execution options for the child manifest
  4. Resolve the child manifest via apply.ResolveManifestUrl()
  5. Execute the resolved manifest via resolvedApply.Execute()
  6. Inspect child resource outcomes (changed, failed, skipped)
  7. Restore parent state via deferred restore

Noop Strengthening:

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 noopResource noopAction
truefalseNo change, parent noop already active
truetrueNo change, parent noop already active
falsetrueEnable noop on manager
falsefalseNo 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:

OptionConditionPurpose
WithSkipSession()AlwaysReuse parent session instead of creating a new one
WithCurrentDepth(n)AlwaysTrack recursion depth for nested apply resources
WithOverridingResolvedDatadata property is setMerge resource data into the child’s resolved data
WithDenyApplyResources()allow_apply is falsePrevent 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.

FieldCaptureRestore
Noop modemgr.NoopMode()mgr.SetNoopMode(saved)
Working directorymgr.WorkingDirectory()mgr.SetWorkingDirectory(saved)
Datamgr.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:

OutcomeDetection methodEffect
Failedmgr.IsResourceFailedIncrement fail count
Changedmgr.ShouldRefreshIncrement change count
SkippedNeitherRemainder

The provider builds an ApplyState with the total resource count and reports the outcome:

Child resultProvider behavior
All resources succeededLog informational message, return state
Some resources changedLog warning with counts, return state
Any resource failedLog 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:

type ExecProvider interface {
    model.Provider

    Execute(ctx context.Context, properties *model.ExecResourceProperties, log model.Logger) (int, error)
    EvaluateGuard(ctx context.Context, command string, properties *model.ExecResourceProperties) (bool, error)
    Status(ctx context.Context, properties *model.ExecResourceProperties) (*model.ExecState, error)
}

Method Responsibilities

MethodResponsibility
StatusCheck if creates file exists, return current state
ExecuteRun the command, return exit code
EvaluateGuardRun a guard command, return true if it exits 0, false if non-zero

Status Response

The Status method returns an ExecState containing:

type ExecState struct {
    CommonResourceState

    ExitCode         *int // Exit code from last execution (nil if not run)
    CreatesSatisfied bool // Whether creates file exists
    OnlyIfSatisfied  bool // Whether onlyif guard command exited 0
    UnlessSatisfied  bool // Whether unless guard command exited 0
}

The Ensure field in CommonResourceState is set to:

  • present if the creates file exists
  • absent if the creates file does not exist (or not specified)

Available Providers

ProviderExecution MethodDocumentation
posixDirect exec (no shell)Posix
shellVia /bin/sh -cShell

Properties

PropertyTypeDescription
commandstringCommand to run (defaults to name if not set)
cwdstringWorking directory for command execution
environment[]stringAdditional environment variables (KEY=value)
pathstringSearch path for executables (colon-separated)
returns[]intAcceptable exit codes (default: [0])
timeoutstringMaximum execution time (e.g., 30s, 5m)
createsstringFile path; skip execution if exists
onlyifstringGuard command; exec runs only if it exits 0
unlessstringGuard command; exec runs only if it exits non-zero
refresh_onlyboolOnly execute via subscribe refresh
subscribe[]stringResources to watch for changes (type#name)
logoutputboolLog command output

Apply Logic

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Get current state via Status()          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Evaluate guard commands (onlyif/unless) β”‚
β”‚ via EvaluateGuard()                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Check for subscribe refresh             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Subscribed resource       β”‚
    β”‚ changed?                  β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              Yes β”‚         No
                  β–Ό         β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
          β”‚ Execute   β”‚     β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
                            β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ Is desired state met?       β”‚
              β”‚ (creates file exists OR     β”‚
              β”‚  onlyif guard failed OR     β”‚
              β”‚  unless guard succeeded OR  β”‚
              β”‚  refresh_only is true)      β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        Yes β”‚         No
                            β–Ό         β”‚
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
                    β”‚ Skip      β”‚     β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
                                      β–Ό
                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚ Is refresh_only = true? β”‚
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                  Yes β”‚         No
                                      β–Ό         β”‚
                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
                              β”‚ Skip      β”‚     β”‚
                              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
                                                β–Ό
                                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                        β”‚ Execute   β”‚
                                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Idempotency

The exec resource provides idempotency through several mechanisms:

Creates Property

The creates property specifies a file that indicates successful prior execution:

- exec:
    - extract-archive:
        command: tar xzf /tmp/app.tar.gz -C /opt
        creates: /opt/app/bin/app

Behavior:

  • If /opt/app/bin/app exists, skip execution
  • Useful for one-time setup commands
  • Provider checks file existence via Status()

Guard Commands (OnlyIf / Unless)

The onlyif and unless properties specify guard commands that control whether the exec runs:

- exec:
    - install-app:
        command: /usr/local/bin/install-app.sh
        onlyif: test -f /tmp/app-package.tar.gz

    - configure-firewall:
        command: /usr/sbin/iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
        unless: /usr/sbin/iptables -C INPUT -p tcp --dport 8080 -j ACCEPT

Behavior:

  • onlyif: Exec runs only if the guard command exits 0
  • unless: Exec runs only if the guard command exits non-zero
  • Guard commands are evaluated via EvaluateGuard(), not inside Status()
  • Guards share the exec’s cwd, environment, path, and timeout
  • Guards run even in noop mode to accurately report what would happen
  • creates takes precedence: if the creates file exists, guards are not checked
  • Subscribe-triggered refreshes override guards

Error handling:

  • A non-zero exit code from a guard is not an error; it simply means the condition is not met
  • An actual execution failure (command not found, permission denied) is propagated as an error

Refresh Only Property

The refresh_only property limits execution to subscribe refreshes:

- exec:
    - reload-nginx:
        command: systemctl reload nginx
        refresh_only: true
        subscribe:
          - file#/etc/nginx/nginx.conf

Behavior:

  • Command only runs when subscribed resource changes
  • Without a subscribe trigger, command is skipped
  • Useful for reload/restart commands

Decision Table

ConditionAction
Subscribe triggeredExecute
creates file existsSkip
onlyif guard exits non-zeroSkip
unless guard exits 0Skip
refresh_only: true + no triggerSkip
refresh_only: false + no guardsExecute

Subscribe Behavior

Exec resources can subscribe to other resources and execute when they change:

- file:
    - /etc/app/config.yaml:
        ensure: present
        content: "..."

- exec:
    - reload-app:
        command: systemctl reload app
        refresh_only: true
        subscribe:
          - file#/etc/app/config.yaml

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:

- exec:
    - check-status:
        command: /usr/local/bin/check-health
        returns:
          - 0
          - 1
          - 2

Behavior:

  • Command succeeds if exit code is in returns list
  • Command fails if exit code is not in returns list
  • Used for desired state validation after execution

Noop Mode

In noop mode, the exec type:

  1. Queries current state normally (checks creates file)
  2. Evaluates guard commands (onlyif/unless) - these run even in noop mode
  3. Evaluates subscribe triggers
  4. Logs what actions would be taken
  5. Sets appropriate NoopMessage:
    • “Would have executed”
    • “Would have executed via subscribe”
  6. Reports Changed: true if execution would occur
  7. 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 precedence
    if properties.Creates != "" && status.CreatesSatisfied {
        return true
    }

    // Guard checks only apply before execution (ExitCode is nil)
    if status.ExitCode == nil {
        if properties.OnlyIf != "" && !status.OnlyIfSatisfied {
            return true // onlyif guard failed, don't run
        }
        if properties.Unless != "" && status.UnlessSatisfied {
            return true // unless guard succeeded, don't run
        }
    }

    // Refresh-only without execution is stable
    if status.ExitCode == nil && properties.RefreshOnly {
        return true
    }

    // Check exit code against acceptable returns
    returns := []int{0}
    if len(properties.Returns) > 0 {
        returns = properties.Returns
    }

    if status.ExitCode != nil {
        return slices.Contains(returns, *status.ExitCode)
    }

    return false
}

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:

- exec:
    - build-app:
        command: make build
        cwd: /opt/app
        environment:
          - CC=gcc
          - CFLAGS=-O2
        path: /usr/local/bin:/usr/bin:/bin

Environment:

  • Added to the command’s environment
  • Format: KEY=value
  • Does not replace existing environment

Path:

  • Sets the PATH for executable lookup
  • Must be absolute directories
  • Colon-separated list

Subsections of Exec Type

Posix Provider

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

FeaturePosixShell
Shell invocationNoYes (/bin/sh -c)
Pipes (|)Not supportedSupported
Redirections (>, <)Not supportedSupported
Shell builtins (cd, export)Not supportedSupported
Glob expansionNot supportedSupported
Command substitution ($(...))Not supportedSupported
Argument parsingshellquote.Split()Passed as single string
SecurityLower attack surfaceShell 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:

  1. Determine command source (Command property or Name if Command is empty)
  2. Parse command string into words using shellquote.Split()
  3. Extract command (first word) and arguments (remaining words)
  4. Execute via CommandRunner.ExecuteWithOptions()
  5. 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:

SyntaxExampleResult
Simple wordsecho hello world["echo", "hello", "world"]
Single quotesecho 'hello world'["echo", "hello world"]
Double quotesecho "hello world"["echo", "hello world"]
Escaped spacesecho hello\ world["echo", "hello world"]
Mixed quotingecho "it's a test"["echo", "it's a test"]

Execution Options:

OptionSourceDescription
CommandFirst word after parsingExecutable path or name
ArgsRemaining wordsCommand arguments
Cwdproperties.CwdWorking directory
Environmentproperties.EnvironmentAdditional env vars (KEY=VALUE format)
Pathproperties.PathSearch path for executables
Timeoutproperties.ParsedTimeoutMaximum execution time

Output Logging:

When LogOutput: true is set and a user logger is provided:

scanner := bufio.NewScanner(bytes.NewReader(stdout))
for scanner.Scan() {
    log.Info(scanner.Text())
}

Each line of stdout is logged as a separate Info message.

Error Handling:

ConditionBehavior
Empty command stringReturn error: “no command specified”
Invalid shell quotingReturn parsing error (e.g., “Unterminated single quote”)
Runner not configuredReturn error: “no command runner configured”
Command execution failsReturn error from runner
Non-zero exit codeReturn exit code (not an error by itself)

EvaluateGuard

Process:

  1. Parse guard command string into words using shellquote.Split()
  2. Extract command (first word) and arguments (remaining words)
  3. Execute via CommandRunner.ExecuteWithOptions() using the same cwd, environment, path, and timeout from the exec properties
  4. Return true if exit code is 0, false if non-zero

Error Handling:

ConditionBehavior
Empty command stringReturn error: “empty guard command”
Invalid shell quotingReturn error: “invalid guard command: …”
Runner not configuredReturn error: “no command runner configured”
Command execution failsReturn error from runner
Non-zero exit codeReturn false (not an error)

Status

Process:

  1. Create state with EnsurePresent (exec resources are always “present”)
  2. Check if Creates file exists via util.FileExists()
  3. Set CreatesSatisfied accordingly

State Fields:

FieldValue
Protocolio.choria.ccm.v1.resource.exec.state
ResourceTypeexec
NameResource name
Ensurepresent (always)
CreatesSatisfiedtrue if Creates file exists

Idempotency

The exec resource achieves idempotency through multiple mechanisms:

Creates File

If creates is specified and the file exists, the command does not run:

- exec:
    - /usr/bin/tar -xzf app.tar.gz:
        creates: /opt/app/bin/app
        cwd: /opt

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:

- exec:
    - install-app:
        command: /usr/local/bin/install-app.sh
        onlyif: test -f /tmp/app-package.tar.gz

    - configure-firewall:
        command: /usr/sbin/iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
        unless: /usr/sbin/iptables -C INPUT -p tcp --dport 8080 -j ACCEPT

RefreshOnly Mode

When refreshonly: true, the command only runs when triggered by a subscribed resource:

- exec:
    - systemctl reload httpd:
        refreshonly: true
        subscribe:
          - file#/etc/httpd/conf/httpd.conf

Exit Code Validation

The returns property specifies acceptable exit codes (default: [0]):

- exec:
    - /opt/app/healthcheck:
        returns: [0, 1, 2]  # 0=healthy, 1=degraded, 2=warning

Decision Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Should resource be applied?             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
                  β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Subscribe triggered?                     β”‚
β”‚ (subscribed resource changed)            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚ Yes           β”‚ No
              β–Ό               β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Execute command β”‚   β”‚ Creates satisfied? β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                      β”‚ Yes               β”‚ No
                      β–Ό                   β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ Skip (stable) β”‚   β”‚ Guard commands pass?  β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚ (onlyif=0, unlessβ‰ 0) β”‚
                                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                            β”‚
                                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                  β”‚ No                β”‚ Yes
                                  β–Ό                   β–Ό
                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                          β”‚ Skip (stable) β”‚   β”‚ RefreshOnly mode? β”‚
                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                        β”‚
                                              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                              β”‚ Yes               β”‚ No
                                              β–Ό                   β–Ό
                                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                      β”‚ Skip (stable) β”‚   β”‚ Execute       β”‚
                                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Properties Validation

The model validates exec properties before execution:

PropertyValidation
nameMust be parseable by shellquote (balanced quotes)
timeoutMust be valid duration format (e.g., 30s, 5m)
subscribeEach entry must be type#name format
pathEach directory must be absolute (start with /)
environmentEach 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

FeatureShellPosix
Shell invocationYes (/bin/sh -c)No
Pipes (|)SupportedNot supported
Redirections (>, <, >>)SupportedNot supported
Shell builtins (cd, export, source)SupportedNot supported
Glob expansion (*.txt, ?)SupportedNot supported
Command substitution ($(...), `...`)SupportedNot supported
Variable expansion ($VAR, ${VAR})SupportedNot supported
Logical operators (&&, ||)SupportedNot supported
Argument parsingPassed as single stringshellquote.Split()
SecurityShell injection possibleLower 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:

  1. Determine command source (Command property or Name if Command is empty)
  2. Validate command is not empty
  3. Execute via CommandRunner.ExecuteWithOptions() with /bin/sh -c "<command>"
  4. 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:

OptionValueDescription
Command/bin/shShell executable path
Args["-c", "<command>"]Shell flag and command string
Cwdproperties.CwdWorking directory
Environmentproperties.EnvironmentAdditional env vars (KEY=VALUE format)
Pathproperties.PathSearch path for executables
Timeoutproperties.ParsedTimeoutMaximum execution time

Output Logging:

When LogOutput: true is set and a user logger is provided:

scanner := bufio.NewScanner(bytes.NewReader(stdout))
for scanner.Scan() {
    log.Info(scanner.Text())
}

Each line of stdout is logged as a separate Info message.

Error Handling:

ConditionBehavior
Empty command stringReturn error: “no command to execute”
Runner not configuredReturn error: “no command runner configured”
Shell not foundProvider not available (checked at selection time)
Command execution failsReturn error from runner
Non-zero exit codeReturn exit code (not an error by itself)

EvaluateGuard

Process:

  1. Validate command is not empty
  2. Execute via CommandRunner.ExecuteWithOptions() with /bin/sh -c "<command>" using the same cwd, environment, path, and timeout from the exec properties
  3. Return true if exit code is 0, false if non-zero

The shell provider is well-suited for guard commands that use shell features:

- exec:
    - add-repo:
        command: /usr/bin/add-apt-repository ppa:example/ppa
        provider: shell
        unless: grep -q example /etc/apt/sources.list.d/*.list

Error Handling:

ConditionBehavior
Empty command stringReturn error: “empty guard command”
Runner not configuredReturn error: “no command runner configured”
Command execution failsReturn error from runner
Non-zero exit codeReturn false (not an error)

Status

Process:

  1. Create state with EnsurePresent (exec resources are always “present”)
  2. Check if Creates file exists via util.FileExists()
  3. Set CreatesSatisfied accordingly

State Fields:

FieldValue
Protocolio.choria.ccm.v1.resource.exec.state
ResourceTypeexec
NameResource name
Ensurepresent (always)
CreatesSatisfiedtrue if Creates file exists

Use Cases

Pipes and Filters

- exec:
    - filter-logs:
        command: cat /var/log/app.log | grep ERROR | tail -100 > /tmp/errors.txt
        provider: shell
        creates: /tmp/errors.txt

Conditional Execution

- exec:
    - ensure-running:
        command: pgrep myapp || /opt/myapp/bin/start
        provider: shell

Complex Scripts

- exec:
    - deploy-app:
        command: |
          cd /opt/app &&
          git pull origin main &&
          npm install &&
          npm run build &&
          systemctl restart app
        provider: shell
        timeout: 5m

Variable Expansion

- exec:
    - backup-home:
        command: tar -czf /backup/home-$(date +%Y%m%d).tar.gz $HOME
        provider: shell

Idempotency

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.gz
        provider: shell
        creates: /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:

- exec:
    - configure-firewall:
        command: /usr/sbin/iptables -A INPUT -p tcp --dport 8080 -j ACCEPT
        provider: shell
        unless: /usr/sbin/iptables -C INPUT -p tcp --dport 8080 -j ACCEPT 2>/dev/null

RefreshOnly Mode

When refreshonly: true, the command only runs when triggered by a subscribed resource:

- exec:
    - reload-nginx:
        command: nginx -t && systemctl reload nginx
        provider: shell
        refreshonly: true
        subscribe:
          - file#/etc/nginx/nginx.conf

Exit Code Validation

The returns property specifies acceptable exit codes (default: [0]):

- exec:
    - check-service:
        command: systemctl is-active myapp || true
        provider: shell
        returns: [0]

Security Considerations

Shell Injection Risk

The shell provider passes the command string directly to /bin/sh, making it vulnerable to shell injection if user input is incorporated:

# DANGEROUS if filename comes from untrusted input
- exec:
    - process-file:
        command: cat {{ user_provided_filename }} | process
        provider: shell

Mitigations:

  • Validate and sanitize any templated values
  • Use the posix provider when shell features aren’t needed
  • Prefer explicit file paths over user-provided values

Environment Variable Exposure

Shell commands can access environment variables, including sensitive ones:

# $SECRET_KEY will be expanded by the shell
- exec:
    - use-secret:
        command: myapp --key=$SECRET_KEY
        provider: shell

Consider using the environment property to explicitly pass required variables rather than relying on inherited environment.

Command Logging

The full command string (including any expanded variables) may appear in logs. Avoid embedding secrets directly in commands:

# BAD - password visible in logs
- exec:
    - bad-example:
        command: mysql -p'secret123' -e 'SELECT 1'
        provider: shell

# BETTER - use environment variable
- exec:
    - better-example:
        command: mysql -p"$MYSQL_PWD" -e 'SELECT 1'
        provider: shell
        environment:
          - MYSQL_PWD={{ lookup('data.mysql_password') }}

Platform Support

The shell provider requires /bin/sh to be available. This is standard on:

  • Linux distributions
  • macOS
  • BSD variants
  • Most Unix-like systems

On Windows, the provider will not be available unless /bin/sh exists (e.g., via WSL or Cygwin).

Shell Compatibility

The provider uses /bin/sh, which is typically:

  • Linux: Often a symlink to bash, dash, or another POSIX-compliant shell
  • macOS: /bin/sh is bash (older) or zsh (newer) in POSIX mode
  • BSD: Usually ash or similar

For maximum portability, use POSIX shell syntax and avoid bash-specific features like:

  • Arrays (arr=(1 2 3))
  • [[ conditionals (use [ instead)
  • source (use . instead)
  • Process substitution (<(command))

File Type

This document describes the design of the file resource type for managing files and directories.

Overview

The file resource manages files and directories with three aspects:

  • Existence: Whether the file/directory exists or is absent
  • Content: The contents of a file (from inline content or source file)
  • Attributes: Owner, group, and permissions

Provider Interface

File providers must implement the FileProvider interface:

type FileProvider interface {
    model.Provider

    CreateDirectory(ctx context.Context, dir string, owner string, group string, mode string) error
    Store(ctx context.Context, file string, contents []byte, source string, owner string, group string, mode string) error
    SetAttributes(ctx context.Context, file string, owner string, group string, mode string) error
    Remove(ctx context.Context, file string, force bool) error
    Status(ctx context.Context, file string) (*model.FileState, error)
}

Method Responsibilities

MethodResponsibility
StatusQuery current file state (existence, type, content hash, attributes)
StoreCreate or update a file with content and attributes
SetAttributesUpdate owner, group and mode on an existing file without changing its content
CreateDirectoryCreate a directory with attributes
RemoveRemove a file or directory; honors force for non-empty directories

Status Response

The Status method returns a FileState containing:

type FileState struct {
    CommonResourceState
    Metadata *FileMetadata
}

type FileMetadata struct {
    Name     string         // File path
    Checksum string         // SHA256 hash of contents (files only)
    Owner    string         // Owner username
    Group    string         // Group name
    Mode     string         // Permissions in octal (e.g., "0644")
    Provider string         // Provider name (e.g., "posix")
    MTime    time.Time      // Modification time
    Size     int64          // File size in bytes
    Extended map[string]any // Provider-specific metadata
}

The Ensure field in CommonResourceState is set to:

  • present if a regular file exists
  • directory if a directory exists
  • absent if the path does not exist

Available Providers

ProviderPlatformDocumentation
posixUnix/LinuxPosix

Ensure States

ValueDescription
presentPath must be a regular file with specified content
absentPath must not exist (see Removal Behavior)
directoryPath must be a directory

Content Sources

Files can receive content from two mutually exclusive sources:

PropertyDescription
contentsInline string content (template-resolved)
sourcePath to local file to copy from
# Inline content with template
- file:
    - /etc/motd:
        ensure: present
        content: |
          Welcome to {{ lookup('facts.hostname') }}
          Managed by CCM
        owner: root
        group: root
        mode: "0644"

# Copy from source file
- file:
    - /etc/app/config.yaml:
        ensure: present
        source: files/config.yaml
        owner: app
        group: app
        mode: "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.

- file:
    - /etc/sysconfig/myapp:
        ensure: present
        owner: root
        group: root
        mode: "0640"

Rules:

  • 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:

PropertyRequiredDescription
ownerYes, except absentUsername or numeric UID that owns the file
groupYes, except absentGroup name or numeric GID that owns the file
modeYes, except absentPermissions 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.

PropertyDefaultDescription
forcefalseWhen true, allow ensure: absent to remove non-empty directories
- file:
    - /var/lib/myapp:
        ensure: absent
        force: true
        owner: root
        group: root
        mode: "0755"

Rules:

  • 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)

  1. Ensure match: Current type matches desired (present/absent/directory)
  2. Content match: SHA256 checksum of contents matches (for ensure: present, skipped in attribute-only mode)
  3. Owner match: Current owner matches desired, comparing by numeric UID when either side is a numeric value or resolves to one
  4. Group match: Current group matches desired, comparing by numeric GID when either side is a numeric value or resolves to one
  5. Mode match: Current permissions match desired

Decision Table

DesiredCurrent StateAction
absentabsentNone
absentpresent (file or symlink)Remove
absentempty directoryRemove
absentnon-empty directory + force: falseError: directory is not empty
absentnon-empty directory + force: trueRemove recursively
directorydirectory + matching attrsNone
directoryabsent/presentCreateDirectory
directorydirectory + wrong attrsCreateDirectory (updates attrs)
present (content set)present + matching allNone
present (content set)absentStore
present (content set)present + wrong contentStore
present (content set)present + wrong attrsStore
present (attrs-only)present + matching attrsNone
present (attrs-only)present + wrong attrsSetAttributes
present (attrs-only)absentStore (creates empty file with attrs)
present (any mode)directoryError: path exists as a directory

Content Comparison

Content is compared using SHA256 checksums:

SourceChecksum Method
contents propertySha256HashBytes([]byte(contents))
source propertySha256HashFile(adjustedPath)
Existing fileSha256HashFile(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)
if filepath.Clean(p.Name) != p.Name {
    return fmt.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:

if properties.Source != "" && mgr.WorkingDirectory() != "" {
    source = filepath.Join(mgr.WorkingDirectory(), properties.Source)
}

This allows manifests bundled with their source files to use relative paths.

Noop Mode

In noop mode, the file type:

  1. Queries current state normally
  2. Computes content checksums
  3. Logs what actions would be taken
  4. 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)
  5. Reports Changed: true if changes would occur
  6. Does not call provider Store/CreateDirectory methods
  7. 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:

  1. Verify parent directory exists
  2. Parse file mode from octal string
  3. Open source file if source property is set
  4. Create temporary file in the same directory as target
  5. Set file permissions on temp file
  6. Write content (from source file or contents property)
  7. Set ownership (chown) on temp file
  8. Close temp file
  9. Atomic rename temp file to target path

Atomic Write Pattern:

[parent dir]/<basename>.* (temp file)
    ↓ chmod (set permissions)
    ↓ write content
    ↓ chown (set owner/group)
    ↓ close
    ↓ rename
[parent dir]/<basename> (final file)

The temp file is created in the same directory as the target to ensure os.Rename() is atomic (same filesystem).

Content Sources:

PropertyBehavior
contentsWrite string directly to file (template-resolved)
sourceCopy from local file path (adjusted for working directory)

If both are empty, an empty file is created.

Error Handling:

ConditionBehavior
Parent directory doesn’t existReturn error: “is not a directory”
Invalid mode formatReturn error from strconv.ParseUint
Source file not foundReturn error from os.Open
Permission deniedReturn error from underlying syscall
Rename failureReturn error: “could not rename temporary file”

CreateDirectory

Process:

  1. Parse file mode from octal string
  2. Create directory and parents via os.MkdirAll()
  3. Lookup numeric UID/GID from owner/group names
  4. Set permissions via os.Chmod() (ensures correct mode even if umask affected MkdirAll)
  5. Set ownership via os.Chown()

Command Sequence:

os.MkdirAll(dir, parsedMode)    // Create directory tree
os.Chmod(dir, parsedMode)        // Ensure correct permissions
os.Chown(dir, uid, gid)          // Set ownership

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:

ConditionBehavior
Path does not existReturn nil
force: false, directory not emptyReturn wrapped error: "cannot remove <path>: directory is not empty, set 'force: true' ..."
Other syscall failureReturn 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:

  1. Initialize state with default metadata
  2. Call os.Stat() on file path
  3. Based on result, populate state accordingly

State Detection:

os.Stat() ResultEnsure ValueMetadata
File existspresentSize, mtime, owner, group, mode, checksum
Directory existsdirectorySize, mtime, owner, group, mode
os.ErrNotExistabsentNone
os.ErrPermissionabsentNone (logged as warning)
Other error(unchanged)None (logged as warning)

Metadata Collection:

FieldSource
NameFrom properties
Provider“posix”
SizeFileInfo.Size()
MTimeFileInfo.ModTime()
Ownerutil.GetFileOwner() - resolves UID to username
Grouputil.GetFileOwner() - resolves GID to group name
Modeutil.GetFileOwner() - octal string (e.g., “0644”)
Checksumutil.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):

  1. Ensure value matches - present, absent, or directory
  2. Content checksum matches - SHA256 of contents vs existing file (for files only)
  3. 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
  4. Group matches - Group comparison, normalized through util.GroupIDMatches using the same rules
  5. Mode matches - Octal permission string comparison

Decision Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ What is the desired ensure state?       β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ absent      β”‚ directory   β”‚ present     β”‚
    β–Ό             β–Ό             β–Ό             β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚ File      β”‚ β”‚ Directory β”‚ β”‚ File exists?  β”‚ β”‚
β”‚ exists?   β”‚ β”‚ exists?   β”‚ β”‚ Content match?β”‚ β”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ Owner match?  β”‚ β”‚
      β”‚             β”‚       β”‚ Group match?  β”‚ β”‚
  Yes β”‚ No      Yes β”‚ No    β”‚ Mode match?   β”‚ β”‚
      β–Ό             β–Ό       β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”           β”‚         β”‚
β”‚ Remove  β”‚ β”‚ Stable    β”‚     All Yesβ”‚    No   β”‚
β”‚ file    β”‚ β”‚           β”‚           β–Ό         β–Ό
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                            β”‚ Stable    β”‚ β”‚ Store     β”‚
                            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Content Comparison

For files with ensure: present:

Content SourceChecksum Calculation
contents propertySha256HashBytes([]byte(contents))
source propertySha256HashFile(adjustedSourcePath)

The source path is adjusted based on the manager’s working directory when set.

Mode Validation

File modes are validated during resource creation:

  1. Strip optional 0o or 0O prefix
  2. Parse as octal number (base 8)
  3. Validate range: must be ≀ 0777

Valid Mode Examples:

InputParsed Value
"0644"0o644
"644"0o644
"0o755"0o755
"0O700"0o700

Invalid Mode Examples:

InputError
"0888"Invalid octal digit
"1777"Exceeds maximum (setuid/setgid not supported via mode)
"rw-r--r--"Not octal format

Ownership Resolution

Owner and group values are resolved to numeric UID/GID via:

uid, gid, err := util.LookupOwnerGroup(owner, group)

Resolution depends on the value’s form:

  • 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:

ConditionBehavior
Empty valueReturn error: “user name cannot be empty” or “group name cannot be empty”
Named user not in databaseReturn error: “could not lookup user”
Named group not in databaseReturn error: “could not lookup group”
Numeric value out of int rangeReturn 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:

func (t *Type) adjustedSource(properties *model.FileResourceProperties) string {
    source := properties.Source
    if properties.Source != "" && t.mgr.WorkingDirectory() != "" {
        source = filepath.Join(t.mgr.WorkingDirectory(), properties.Source)
    }
    return source
}

This allows manifests to use relative paths for source files bundled with the manifest.

Platform Support

The Posix provider uses Unix-specific system calls:

OperationSystem Call
Get file owner/groupsyscall.Stat_t (UID/GID from stat)
Set ownershipos.Chown() β†’ chown(2)
Set permissionsos.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:

  1. Chmod - Set permissions
  2. Write content
  3. Chown - Set ownership
  4. 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):

if filepath.Clean(p.Name) != p.Name {
    return fmt.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:

type PackageProvider interface {
    model.Provider

    Install(ctx context.Context, pkg string, version string) error
    Upgrade(ctx context.Context, pkg string, version string) error
    Downgrade(ctx context.Context, pkg string, version string) error
    Uninstall(ctx context.Context, pkg string) error
    Status(ctx context.Context, pkg string) (*model.PackageState, error)
    VersionCmp(versionA, versionB string, ignoreTrailingZeroes bool) (int, error)
}

Method Responsibilities

MethodResponsibility
StatusQuery current package state (installed version or absent)
InstallInstall package at specified version (or latest if “latest”)
UpgradeUpgrade package to specified version
DowngradeDowngrade package to specified version
UninstallRemove the package
VersionCmpCompare two version strings (-1, 0, 1)

Status Response

The Status method returns a PackageState containing:

type PackageState struct {
    CommonResourceState
    Metadata *PackageMetadata
}

type PackageMetadata struct {
    Name        string         // Package name
    Version     string         // Installed version
    Arch        string         // Architecture (e.g., "x86_64")
    License     string         // Package license
    URL         string         // Package URL
    Summary     string         // Short description
    Description string         // Full description
    Provider    string         // Provider name (e.g., "dnf")
    Extended    map[string]any // Provider-specific metadata
}

The Ensure field in CommonResourceState is set to:

  • The installed version string if the package is installed
  • absent if the package is not installed

Available Providers

ProviderPackage ManagerDocumentation
dnfDNF (Fedora/RHEL)DNF
aptAPT (Debian/Ubuntu)APT

Ensure States

ValueDescription
presentPackage must be installed (any version)
absentPackage must not be installed
latestPackage must be upgraded to latest available
<version>Package must be at specific version
# Install any version
- package:
    - vim:
        ensure: present

# Install latest version
- package:
    - vim:
        ensure: latest

# Install specific version
- package:
    - nginx:
        ensure: "1.24.0-1.el9"

# Remove package
- package:
    - telnet:
        ensure: absent

Apply Logic

Phase 1: Handle Special Cases

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ 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 ValueMeaning
-1versionA < versionB (upgrade needed)
0versionA == versionB (no change)
1versionA > 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

DesiredCurrent StateAction
ensure: presentinstalled (any version)None
ensure: presentabsentInstall
ensure: absentabsentNone
ensure: absentinstalledUninstall
ensure: latestabsentInstall latest
ensure: latestinstalledUpgrade (always runs)
ensure: <version>same versionNone
ensure: <version>older versionUpgrade
ensure: <version>newer versionDowngrade
ensure: <version>absentInstall

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:

  1. Queries current state normally
  2. Computes version comparison
  3. Logs what actions would be taken
  4. 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”
  5. Reports Changed: true if changes would occur
  6. 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 {
    switch properties.Ensure {
    case "present":
        // Any installed version is acceptable
        return state.Ensure != "absent"

    case "absent":
        return state.Ensure == "absent"

    case "latest":
        // Cannot verify "latest", just check not absent
        return state.Ensure != "absent"

    default:
        // Specific version must match
        return VersionCmp(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:

VariableValuePurpose
DEBIAN_FRONTENDnoninteractivePrevents dpkg from prompting for user input
APT_LISTBUGS_FRONTENDnoneSuppresses apt-listbugs prompts
APT_LISTCHANGES_FRONTENDnoneSuppresses 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.

Operations

Status Check

Command:

dpkg-query -W -f='${Package} ${Version} ${Architecture} ${db:Status-Status}' <package>

Behavior:

  • Exit code 0 with installed status β†’ Package is present, returns version info
  • Exit code non-zero OR status not installed β†’ Package is absent

Package States: The db:Status-Status field can return various values. Only installed is treated as present:

StatusTreated AsDescription
installedPresentPackage fully installed
config-filesAbsentRemoved but config files remain
half-installedAbsentInstallation started but failed
half-configuredAbsentConfiguration failed
unpackedAbsentUnpacked but not configured
not-installedAbsentNot installed

Treating non-installed states as absent allows apt-get install to repair broken installations.

Install

Ensure Present:

apt-get install -y -q -o DPkg::Options::=--force-confold <package>

Ensure Latest:

apt-cache policy <package>                    # Get candidate version
apt-get install -y -q -o DPkg::Options::=--force-confold <package>=<version>

Specific Version:

apt-get install -y -q -o DPkg::Options::=--force-confold --allow-downgrades <package>=<version>

Flags:

FlagPurpose
-yAssume yes to prompts
-qQuiet output
-o DPkg::Options::=--force-confoldKeep existing config files on upgrade
--allow-downgradesAllow installing older versions (specific version only)

Upgrade

Delegates to Install() with the target version. The --allow-downgrades flag is only added for specific version requests, not for latest.

Downgrade

Delegates to Install() with the target version. The --allow-downgrades flag enables this operation.

Uninstall

Command:

apt-get -q -y remove <package>

Note: Uses remove not purge, so configuration files are preserved. A subsequent install will find existing config files.

Latest Available Version

Command:

apt-cache policy <package>

Parsing: Extracts the Candidate: line from output.

Example output:

zsh:
  Installed: 5.9-8+b18
  Candidate: 5.9-8+b18
  Version table:
 *** 5.9-8+b18 500
        500 http://deb.debian.org/debian trixie/main amd64 Packages
        100 /var/lib/dpkg/status

Version Comparison

Version comparison follows the Debian Policy Manual algorithm.

Version Format

[epoch:]upstream_version[-debian_revision]
ComponentRequiredDescription
epochNoInteger, default 0. Higher epoch always wins.
upstream_versionYesThe main version from upstream
debian_revisionNoDebian-specific revision

Examples:

  • 1.0 β†’ epoch=0, upstream=1.0, revision=""
  • 1:2.0-3 β†’ epoch=1, upstream=2.0, revision=3
  • 2:1.0.0+git-20190109-0ubuntu2 β†’ epoch=2, upstream=1.0.0+git-20190109, revision=0ubuntu2

Comparison Algorithm

  1. Compare epochs numerically - Higher epoch wins regardless of other components

  2. Compare upstream_version and debian_revision using the Debian string comparison:

    The string is processed left-to-right in segments:

    a. Tildes (~) - Compared first. More tildes = earlier version. Tilde sorts before everything, even empty string.

    • 1.0~alpha < 1.0 (tilde before empty)
    • 1.0~~ < 1.0~ (more tildes = earlier)

    b. Letters (A-Za-z) - Compared lexically (ASCII order)

    • Letters sort before non-letters

    c. Non-letters (., +, -) - Compared lexically

    d. Digits - Compared numerically (not lexically)

    • 9 < 13 (numeric comparison)

    These steps repeat until a difference is found or both strings are exhausted.

Comparison Examples

ABResultReason
1.02.0A < BNumeric comparison
1:1.02.0A > BEpoch 1 > epoch 0
1.0~alpha1.0A < BTilde sorts before empty
1.0~alpha1.0~betaA < BLexical: alpha < beta
1.0.11.0.2A < BNumeric: 1 < 2
1.0-11.0-2A < BRevision comparison
1.0a1.0-A < BLetters sort before non-letters

Implementation

The version comparison is implemented in version.go, ported from Puppet’s Puppet::Util::Package::Version::Debian module. It provides:

  • ParseVersion(string) - Parse a version string into components
  • CompareVersionStrings(a, b) - Compare two version strings directly
  • Version.Compare(other) - Compare parsed versions (-1, 0, 1)
  • 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.

Operations

Status Check

Command:

rpm -q <package> --queryformat '%{NAME} %|EPOCH?{%{EPOCH}}:{0}| %{VERSION} %{RELEASE} %{ARCH}'

Query Format (NEVRA): The query format extracts the full NEVRA (Name, Epoch, Version, Release, Architecture):

FieldDescription
%{NAME}Package name
%|EPOCH?{%{EPOCH}}:{0}|Epoch (0 if not set)
%{VERSION}Upstream version
%{RELEASE}Release/build number
%{ARCH}Architecture (x86_64, noarch, etc.)

Example output:

zsh 0 5.8 9.el9 x86_64

Behavior:

  • Exit code 0 β†’ Package is present, parses NEVRA components
  • Exit code non-zero β†’ Package is absent

Returned Version Format: The version returned combines VERSION and RELEASE: 5.8-9.el9

The epoch and release are stored separately in the Extended metadata.

Install

Ensure Present or Latest:

dnf install -y <package>

Specific Version:

dnf install -y <package>-<version>

Flags:

FlagPurpose
-yAssume yes to all prompts

Note: DNF uses - (hyphen) to separate package name from version, unlike APT which uses =.

Upgrade

Delegates to Install(). DNF’s install command handles upgrades automatically when a newer version is available or specified.

Downgrade

Command:

dnf downgrade -y <package>-<version>

Note: Unlike upgrade, downgrade uses a dedicated DNF command rather than delegating to install.

Uninstall

Command:

dnf remove -y <package>

Version Format

RPM versions follow the EVR (Epoch:Version-Release) format:

[epoch:]version-release
ComponentRequiredDescription
epochNoInteger, default 0. Higher epoch always wins.
versionYesUpstream version number
releaseYesDistribution-specific release/build number

Examples:

  • 5.8-9.el9 β†’ epoch=0, version=5.8, release=9.el9
  • 1:2.0-3.fc39 β†’ epoch=1, version=2.0, release=3.fc39
  • 0:1.0.0-1.el9 β†’ epoch=0 (explicit), version=1.0.0, release=1.el9

Version Comparison

Version comparison uses a generic algorithm ported from Puppet, implemented in internal/util.VersionCmp().

Algorithm

The version string is tokenized into segments by splitting on -, ., digits, and non-digit sequences. Segments are compared left-to-right:

  1. Hyphens (-) - A hyphen loses to any other character
  2. Dots (.) - A dot loses to any non-hyphen character
  3. Digit sequences - Compared numerically, except:
    • Leading zeros trigger lexical comparison (01 vs 1 compared as strings)
  4. Non-digit sequences - Compared lexically (case-insensitive)

If all segments match, falls back to whole-string comparison.

Trailing Zero Normalization

When ignoreTrailingZeroes is enabled, trailing .0 segments before the first - are removed:

  • 1.0.0-rc1 β†’ 1-rc1
  • 2.0.0 β†’ 2

This allows 1.0.0 to equal 1.0 when the flag is set.

Comparison Examples

ABResultReason
1.02.0A < BNumeric: 1 < 2
1.101.9A > BNumeric: 10 > 9
1.01.0.1A < BA exhausted first
1.0-11.0-2A < BRelease comparison
1.0a1.0bA < BLexical: a < b
011A < BLeading zero: lexical comparison
1.0.01.0A > BWithout normalization
1.0.01.0A = BWith ignoreTrailingZeroes=true

Implementation

The version comparison is implemented in internal/util/util.go and provides:

  • VersionCmp(a, b, ignoreTrailingZeroes) - Compare two version strings
  • Returns -1 (a < b), 0 (a == b), or 1 (a > b)

Scaffold Type

This document describes the design of the scaffold resource type for rendering template directories into target directories.

Overview

The scaffold resource renders files from a source template directory to a target directory:

  • Rendering: Process templates using Go text/template or Jet engine
  • Synchronization: Detect changed, stable, and purgeable files
  • Cleanup: Optionally remove files in the target not present in the source

Templates have access to facts and data from Hiera, enabling dynamic configuration generation from directory structures.

Provider Interface

Scaffold providers must implement the ScaffoldProvider interface:

type ScaffoldProvider interface {
    model.Provider

    Remove(ctx context.Context, prop *model.ScaffoldResourceProperties, state *model.ScaffoldState) error
    Scaffold(ctx context.Context, env *templates.Env, prop *model.ScaffoldResourceProperties, noop bool) (*model.ScaffoldState, error)
    Status(ctx context.Context, env *templates.Env, prop *model.ScaffoldResourceProperties) (*model.ScaffoldState, error)
}

Method Responsibilities

MethodResponsibility
StatusRender in noop mode to determine current state of managed files
ScaffoldRender templates to target directory (or noop to preview changes)
RemoveDelete managed files (changed and stable) and clean up directories

Status Response

The Status method returns a ScaffoldState containing:

type ScaffoldState struct {
    CommonResourceState
    Metadata *ScaffoldMetadata
}

type ScaffoldMetadata struct {
    Name         string                 // Target directory
    Provider     string                 // Provider name (e.g., "choria")
    TargetExists bool                   // Whether target directory exists
    Changed      []string               // Files created or modified
    Purged       []string               // Files removed (not in source)
    Stable       []string               // Files unchanged
    Engine       ScaffoldResourceEngine // Template engine used
}

The Ensure field in CommonResourceState is set to:

  • present if the target directory exists
  • absent if the target directory does not exist

Available Providers

ProviderEngine SupportDocumentation
choriaGo, JetChoria

Ensure States

ValueDescription
presentTarget directory must exist with rendered template files
absentManaged files must be removed from the target

Template Engines

Two template engines are supported:

EngineLibraryDefault DelimitersDescription
goGo text/template{{ / }}Standard Go templates
jetJet templating[[ / ]]Jet template language

The engine defaults to jet if not specified. Delimiters can be customized via left_delimiter and right_delimiter properties.

Properties

PropertyTypeRequiredDescription
sourcestringYesSource template directory path or URL
enginestringNoTemplate engine: go or jet (default: jet)
skip_emptyboolNoSkip empty files in rendered output
left_delimiterstringNoCustom left template delimiter
right_delimiterstringNoCustom right template delimiter
purgeboolNoRemove files in target not present in source
datamap[string]anyNoCustom data that replaces Hiera data for template rendering
post[]map[string]stringNoPost-processing: glob pattern to command mapping
# Render configuration templates using Jet engine
- scaffold:
    - /etc/app:
        ensure: present
        source: templates/app
        engine: jet
        purge: true

# Render with Go templates and custom delimiters
- scaffold:
    - /etc/myservice:
        ensure: present
        source: templates/myservice
        engine: go
        left_delimiter: "<<"
        right_delimiter: ">>"

# With post-processing commands
- scaffold:
    - /opt/app:
        ensure: present
        source: templates/app
        post:
          - "*.go": "go fmt {}"

# With custom data replacing Hiera data
- scaffold:
    - /etc/app:
        ensure: present
        source: templates/app
        engine: jet
        data:
          app_name: myapp
          version: "{{ 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:

data:
  "{{ Facts.key_name }}": "{{ Facts.value }}"
  static_key: "{{ Facts.hostname }}"
  port: 8080

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

  1. 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.
  2. 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.

DesiredTarget ExistsChanged FilesPurged FilesPurge EnabledStable?
absentNoN/AN/AN/AYes
absentYesNoneAnyN/AYes (no managed files on disk)
absentYesSomeAnyN/ANo (managed files remain)
presentYesNoneNoneAnyYes
presentYesNoneSomeNoYes (purged files ignored)
presentYesNoneSomeYesNo (purge needed)
presentYesSomeAnyAnyNo (render needed)
presentNoN/AN/AAnyNo (target missing)

Source Resolution

The source property is resolved relative to the manager’s working directory when it is a relative path:

parsed, _ := url.Parse(properties.Source)
if parsed == nil || parsed.Scheme == "" {
    if !filepath.IsAbs(properties.Source) {
        t.prop.Source = filepath.Join(mgr.WorkingDirectory(), properties.Source)
    }
}

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:
  - "*.go": "go fmt {}"
  - "*.sh": "chmod +x {}"

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).

DesiredAffected CountMessage
presentChanged + Purged (if purge enabled)Would have changed N scaffold files
absentChanged + 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:

  1. Check if target directory exists
  2. Configure scaffold with source, target, engine, delimiters, post-processing, and skip_empty settings
  3. Create scaffold instance using the appropriate engine (scaffold.New() for Go, scaffold.NewJet() for Jet)
  4. Call Render() (real mode) or RenderNoop() (noop mode)
  5. Categorize results into changed, stable, and purged file lists

Scaffold Configuration:

Config FieldSource PropertyDescription
TargetDirectoryNameTarget directory for rendered files
SourceDirectorySourceSource template directory
MergeTargetDirectory(always true)Merge into existing target directory
PostPostPost-processing commands
SkipEmptySkipEmptySkip empty rendered files
CustomLeftDelimiterLeftDelimiterCustom template left delimiter
CustomRightDelimiterRightDelimiterCustom template right delimiter

Engine Selection:

EngineConstructorDefault Delimiters
goscaffold.New(){{ / }}
jetscaffold.NewJet()[[ / ]]

Result Categorization:

Scaffold ActionMetadata ListDescription
FileActionEqualStableFile content unchanged
FileActionAddChangedNew file created
FileActionUpdateChangedExisting file modified
FileActionRemovePurgedFile 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:

  1. Perform a dry-run render (noop mode) to determine what the scaffold would do
  2. 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 DirectoryEnsure ValueMetadata
ExistspresentChanged, stable, and purged file lists from render
ExistsabsentChanged and stable filtered to files on disk, purged from render
Does not existAnyEmpty metadata, TargetExists: false

Remove

Process:

  1. Collect managed files from the state’s Changed and Stable lists (purged files are not removed as they don’t belong to the scaffold)
  2. Remove each file individually
  3. Track parent directories of removed files
  4. Iteratively remove empty directories deepest-first
  5. Stop when no more empty directories can be removed
  6. Best-effort removal of the target directory (only succeeds if empty)

File Removal Order:

Files are collected from two metadata lists:

  1. Changed - Files that were added or modified
  2. 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:

ConditionBehavior
Non-absolute file pathReturn error immediately
File removal failsLog error, continue with remaining files
Directory removal failsLog error, continue with remaining directories
File does not existSilently skip (os.IsNotExist check)
Target directory removal failsLog 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:

type logger struct {
    log model.Logger
}

func (l *logger) Debugf(format string, v ...any)
func (l *logger) Infof(format string, v ...any)

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:

type ServiceProvider interface {
    model.Provider

    Enable(ctx context.Context, service string) error
    Disable(ctx context.Context, service string) error
    Start(ctx context.Context, service string) error
    Stop(ctx context.Context, service string) error
    Restart(ctx context.Context, service string) error
    Status(ctx context.Context, service string) (*model.ServiceState, error)
}

Method Responsibilities

MethodResponsibility
StatusQuery current running and enabled state
StartStart the service if not running
StopStop the service if running
RestartStop and start the service (for refresh)
EnableConfigure service to start at boot
DisableConfigure service to not start at boot

Status Response

The Status method returns a ServiceState containing:

type ServiceState struct {
    CommonResourceState
    Metadata *ServiceMetadata
}

type ServiceMetadata struct {
    Name     string  // Service name
    Provider string  // Provider name (e.g., "systemd")
    Enabled  bool    // Whether service starts at boot
    Running  bool    // Whether service is currently running
}

The Ensure field in CommonResourceState is set to:

  • running if the service is active
  • stopped if the service is inactive

Available Providers

ProviderInit SystemDocumentation
systemdsystemdSystemd

Ensure States

ValueDescription
runningService must be running (default)
stoppedService must be stopped

If ensure is not specified, it defaults to running.

Enable Property

The enable property is a boolean pointer (*bool) with three possible states:

ValueBehavior
trueEnable service to start at boot
falseDisable service from starting at boot
nil (not set)Leave boot configuration unchanged

This allows managing running state without affecting boot configuration:

# Start service but don't change boot config
- service:
    - myapp:
        ensure: running

# Start and enable at boot
- service:
    - myapp:
        ensure: running
        enable: true

# Stop and disable at boot
- service:
    - myapp:
        ensure: stopped
        enable: false

Apply Logic

The service type applies changes in two phases:

Phase 1: Running State

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Check for subscribe refresh             β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Subscribed resource       β”‚
    β”‚ changed?                  β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              Yes β”‚         β”‚ No
                  β–Ό         β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
    β”‚ ensure=running?     β”‚ β”‚
    β”‚ already running?    β”‚ β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
          Yes+Yes β”‚         β”‚
                  β–Ό         β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”‚
          β”‚ Restart   β”‚     β”‚
          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β”‚
                            β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ Compare ensure vs state β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                            β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ ensure=stopped        β”‚ ensure=running        β”‚
    β”‚ state=running         β”‚ state=stopped         β”‚
    β–Ό                       β–Ό                       β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”                    β”‚
β”‚ Stop   β”‚            β”‚ Start  β”‚                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜            β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β”‚
                                                    β–Ό
                                          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                                          β”‚ No change     β”‚
                                          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Phase 2: Enabled State

After running state is handled, enabled state is processed:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ enable property set?                    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
          nil     β”‚     true/false
          β–Ό       β”‚
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
  β”‚ No change β”‚   β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
                  β–Ό
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ Compare enable vs enabled   β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  β”‚
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚ enable=true β”‚ enable=falseβ”‚
    β”‚ !enabled    β”‚ enabled     β”‚
    β–Ό             β–Ό             β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”‚
β”‚ Enable β”‚  β”‚ Disable β”‚         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β”‚
                                β–Ό
                      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                      β”‚ No change     β”‚
                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Subscribe Behavior

Services can subscribe to other resources and restart when they change:

- service:
    - httpd:
        ensure: running
        subscribe:
          - file#/etc/httpd/conf/httpd.conf
          - package#httpd

Special Cases:

ConditionBehavior
ensure: stoppedSubscribe ignored (no restart)
Service not running + ensure: runningStart (not restart)
Service running + ensure: runningRestart

This prevents restarting stopped services and ensures a clean start when the service should be running but isn’t.

Idempotency

The service resource is idempotent through state comparison:

DesiredCurrentAction
ensure: runningrunningNone
ensure: runningstoppedStart
ensure: stoppedstoppedNone
ensure: stoppedrunningStop
enable: trueenabledNone
enable: truedisabledEnable
enable: falseenabledDisable
enable: falsedisabledNone
enable: nilanyNone

Desired State Validation

After applying changes, the type verifies the service reached the desired state:

func (t *Type) isDesiredState(properties, state) bool {
    // Check running state
    if properties.Ensure != state.Ensure {
        return false
    }

    // Check enabled state (only if explicitly set)
    if properties.Enable != nil {
        if *properties.Enable != state.Metadata.Enabled {
            return false
        }
    }

    return true
}

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:

  1. Queries current state normally
  2. Logs what actions would be taken
  3. Sets appropriate NoopMessage (e.g., “Would have started”, “Would have enabled”)
  4. Reports Changed: true if changes would occur
  5. 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.

func (p *Provider) execute(ctx context.Context, cmd string, args ...string) (...) {
    model.ServiceGlobalLock.Lock()
    defer model.ServiceGlobalLock.Unlock()
    return p.runner.Execute(ctx, cmd, args...)
}

Daemon Reload

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).

func (p *Provider) maybeReload(ctx context.Context) error {
    p.mu.Lock()
    defer p.mu.Unlock()

    if p.didReload {
        return nil
    }

    _, _, _, err := p.execute(ctx, "systemctl", "daemon-reload")
    return err
}

The reload is performed only once, tracked by the didReload flag.

Operations

Status

Commands:

systemctl is-active --system <service>
systemctl is-enabled --system <service>

Active State Detection:

is-active OutputInterpreted As
activeRunning
inactiveStopped
failedStopped
activatingStopped
OtherError

Enabled State Detection:

is-enabled OutputInterpreted As
enabledEnabled
enabled-runtimeEnabled
aliasEnabled
staticEnabled
indirectEnabled
generatedEnabled
transientEnabled
linkedDisabled
linked-runtimeDisabled
maskedDisabled
masked-runtimeDisabled
disabledDisabled
not-foundError: service not found

Returned State:

FieldValue
Ensurerunning or stopped based on is-active
Metadata.EnabledBoolean from is-enabled
Metadata.RunningBoolean from is-active
Metadata.Provider“systemd”

Start

Command:

systemctl start --system <service>

Called when ensure: running and service is currently stopped.

Stop

Command:

systemctl stop --system <service>

Called when ensure: stopped and service is currently running.

Restart

Command:

systemctl restart --system <service>

Called when a subscribed resource has changed and the service should be refreshed.

Enable

Command:

systemctl enable --system <service>

Called when enable: true and service is currently disabled.

Disable

Command:

systemctl disable --system <service>

Called when enable: false and service is currently enabled.

Command Flags

All commands use the --system flag to explicitly target system-level units (as opposed to user-level units managed with --user).

Decision Flow

Ensure State (Running/Stopped)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Subscribe triggered?                     β”‚
β”‚ (subscribed resource changed)            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚ Yes           β”‚ No
              β”‚               β”‚
              β”‚               β–Ό
              β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚       β”‚ Ensure = stopped? β”‚
              β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚           Yes   β”‚   No
              β”‚           β–Ό     β–Ό
              β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚   β”‚ Running?β”‚ β”‚ Ensure = running? β”‚
              β”‚   β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚    Yes β”‚ No         Yes β”‚   No
              β”‚        β–Ό               β–Ό
              β”‚   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚   β”‚ Stop   β”‚    β”‚ Running?β”‚
              β”‚   β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
              β”‚                  No  β”‚ Yes
              β”‚                      β–Ό
              β”‚                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚                 β”‚ Start  β”‚
              β”‚                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚ Ensure = running?             β”‚
      β”‚ (only restart running services)β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  Yes β”‚   No
                      β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ Restart     β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Subscribe Behavior Notes:

  • Restart only occurs if ensure: running
  • If service is stopped and ensure: running, it starts instead of restarting
  • Subscribe is ignored if ensure: stopped

Enable State

Enable/disable is processed independently after ensure state:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Enable property set?                     β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚ Yes           β”‚ No (nil)
              β”‚               β”‚
              β”‚               β–Ό
              β”‚       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚       β”‚ No change     β”‚
              β”‚       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚ Enable = true?                 β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  Yes β”‚   No
                      β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚ Currently enabled?             β”‚
      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                  No  β”‚ Yes
                      β–Ό
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ Enable      β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

      (Similar flow for disable when enable=false)

Idempotency

The service resource checks current state before making changes:

Desired StateCurrent StateAction
runningrunningNone
runningstoppedStart
stoppedstoppedNone
stoppedrunningStop
enable: trueenabledNone
enable: truedisabledEnable
enable: falseenabledDisable
enable: falsedisabledNone
enable: nilanyNone

Service Name Validation

Service names are validated to prevent shell injection:

Dangerous Characters Check:

if dangerousCharsRegex.MatchString(p.Name) {
    return fmt.Errorf("service name contains dangerous characters: %q", p.Name)
}

Allowed Characters:

  • Alphanumeric (a-z, A-Z, 0-9)
  • Period (.)
  • Underscore (_)
  • Plus (+)
  • Colon (:)
  • Tilde (~)
  • Hyphen (-)

Examples:

NameValid
httpdYes
nginx.serviceYes
my-app_v2Yes
app@instanceNo (@ not allowed)
app; rm -rf /No (shell metacharacters)

Subscribe and Refresh

Services can subscribe to other resources and restart when they change:

- file:
    - /etc/myapp/config.yaml:
        ensure: present
        content: "..."
        owner: root
        group: root
        mode: "0644"

- service:
    - myapp:
        ensure: running
        enable: true
        subscribe:
          - file#/etc/myapp/config.yaml

Behavior:

  • 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

ConditionBehavior
systemctl not in PATHProvider unavailable
Service not foundError from is-enabled: “service not found”
Unknown is-active outputError: “invalid systemctl is-active output”
Unknown is-enabled outputError: “invalid systemctl is-enabled output”
Command execution failureError 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.

Entry pointsCLI Β· ensureManifest Β· applyAgentWire APIManagerfacts Β· data Β· sessionFacts Β· DataHiera Β· TemplatesResource lifecycleResourcedecides desired vs actualProvideracts on the platformSystempackages Β· files Β· servicesregistryselects byfacts
One core loop, many faces. Every entry point drives the same manager, resource, and provider machinery.

Explore

  • Architecture

    Package layering and the three seams that keep the engine composable: the model contracts, the provider registry, and the manager.

  • The Resource-Provider Model

    How a concrete resource type is built, and how one resource is driven to its desired state: decide, act, and verify.

  • The Apply Engine

    How a manifest of many resources is parsed, resolved against data, and executed in order, with require as a gate and events as the shared state.

  • The Agent

    The background loop that applies manifests on a timer, watches remote sources, refreshes facts and data, and remediates failing health checks.

  • Data, Facts, and Templates

    How facts are gathered, how Hiera layers a hierarchy into resolved data, and how templates render that data into resource fields.

  • Registration and Discovery

    How stable resources announce themselves into a JetStream store keyed by subject, and how other nodes look them up or watch them live.

  • Observability

    How each apply becomes a recorded event, a Prometheus counter, and a session summary, and how health checks feed remediation.

  • Reference and Map

    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.

Entry points Β· cmd/ Β· agent/Apply engine Β· resources/applyResource types Β· resources/file Β· package Β· service Β· exec Β· archive Β· scaffoldShared base Β· resources/baseProviders Β· posix Β· apt Β· dnf Β· systemd Β· httpmodel contracts Β· every layer depends on theseinternal/registryprovider directory
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.

  1. 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.
  2. Gate on control checkControl evaluates control.if and control.unless expressions. If they say do not manage, the event is marked Skipped and returned.
  3. 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.
  4. 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.
  5. Read status and decide provider.Status reads the actual state, and isDesiredState compares it against the properties, returning a human-readable mismatch reason.
  6. 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.
  7. 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.
  8. Finalize and record FinalizeState writes the flags onto the state, base maps them onto the event, and the caller logs and records it.
Select provider by factsControl + require gatesprovider.Status β†’ actualmatchesdesired?Stable Β· no changeAct via provider verbnoop: describe, do not actRe-read + verifyelse ErrDesiredStateFailedFinalizeState β†’ eventyesno
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.

TypeStatesNotes
filepresent, absent, directoryCompares ensure, content checksum, owner, group, and mode.
packagepresent, 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.
servicerunning, stoppedIndependent 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.

  1. Resolve the source ResolveManifestUrl 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.
  2. 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.
  3. Resolve Hiera hiera.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.
  4. Publish data mgr.SetData stores the resolved data and the template environment is built from it, so resource fields can reference Data.
  5. 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.
  6. Parse and template each resource NewValidatedResourcePropertiesFromYaml builds typed properties per type, resolves templates, and validates.
  7. 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.
Sourceobj Β· http Β· fileParseHiera+ overridesResource listValidateExecute Β· sessionBuild resourceApply / checkRecord event+ publishnext resource Β· declaration orderfail_on_error: stop
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

Interval tick Β· 5mfloor 30sRefresh facts + dataApply each manifestWorkers watch sourcesfile Β· http Β· obj://Health tickRun checksno fact refreshcritical?remediateno β†’ ok
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.

Next

Continue to Data, Facts, and Templates to see how the inputs the agent refreshes are produced.

Data, Facts, and Templates

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

Factsgopsutil + facts.dHierarchy + overrideslevels templated by factsMergefirst Β· deepResolved data+ validateTemplate envlookup Β· kvGet Β· registrationsResource fields{{ }} Β· ${ } Β· jet
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

Node A Β· applyresource stable +health checks passJetStreamREGISTRATION1 msg / subjectTTL Β· rollup Β· markersNode B Β· lookup()renders configWatcherregister / removepublishchoria.ccm.registration.v1.<cluster>.<protocol>.<service>.<address>.<instance>
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

Apply resourceTransactionEventKSUID id Β· flagsSession storememory (default)directory: JSON / eventMetricschoria_ccm_*Summaryon StopSessionrecord
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.

CommandPurposeDrives
ccm ensure <type>Manage one resource imperatively: archive, exec, file, package, scaffold, serviceResource-Provider Model
ccm ensure api pipedApply a resource sent as JSON or YAML on stdinResource-Provider Model
ccm apply <manifest>Apply a manifest from a file, obj://, or https:// tarballApply Engine
ccm agent --config <file>Run the continuous manifest daemonThe Agent
ccm facts [query]Show system facts, with an optional gjson queryData, Facts, and Templates
ccm hiera parse <input>Resolve a Hiera input against factsData, Facts, and Templates
ccm registration create / query / watch / rm / initPublish, read, watch, remove entries, or provision the streamRegistration and Discovery
ccm session new / reportCreate a session store or summarize oneObservability
ccm status <type> <name>Read the current state of a resourceResource-Provider Model

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

PackageContents
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.

Key types

TypeRoleExplained in
model.Manager (CCM)The orchestrator every layer programs againstArchitecture
model.Provider / ProviderFactoryThe provider seam and its registration unitArchitecture
model.Resource / ResourcePropertiesA managed thing and its declarative desired stateResource-Provider Model
base.Base / EmbeddedResourceThe shared apply flow and its callback interfaceResource-Provider Model
model.Apply / TransactionEventA parsed manifest and the per-resource result recordApply Engine
hiera.Resolve / templates.EnvData resolution and the render environmentData, Facts, and Templates
model.RegistrationEntryA published service-discovery entryRegistration and Discovery
model.SessionStore / SessionSummaryEvent storage and run aggregationObservability
agent.AgentThe continuous loop and its workersThe Agent

Glossary

Resource
A system component to manage: a package, file, service, exec, archive, or scaffold. Declares a desired state.
Provider
The platform-specific implementation for a resource type, such as apt, dnf, systemd, or posix. Selected at run time by facts.
Ensure
The desired state of a resource, such as present, absent, running, or a package version.
Manifest
A YAML document of data, a hierarchy, and a list of resources, applied as a unit.
Facts
Gathered truths about the host, used to select providers and drive the data hierarchy.
Hiera
The hierarchical data resolver that merges layered data blocks, chosen by facts, into one resolved map.
Session
The record of a run: a sequence of events, stored in memory or on disk, that drives require and subscribe.
Noop
Dry-run mode. No change is made, but the event still reports what would have changed.
Require
A fail-gate: a resource is skipped if a named prior resource failed. Not a scheduler.
Subscribe / refresh
A resource, in practice a service, restarts when a named prior resource changed.
register_when_stable
A resource property that publishes a discovery entry once the resource is stable and healthy.
Back to the start

Return to the Code Map overview for the mental model and the full page list.

Adding a Type

This guide documents the process for adding a new resource type to CCM. It uses the archive type as a reference implementation.

Overview

Adding a new resource type requires changes across several packages:

  1. Model definitions - Properties, state, and metadata structs
  2. Resource type implementation - Core logic and provider interface
  3. Provider implementation - Platform-specific operations
  4. Integration points - Factory functions and registry
  5. CLI commands - User-facing command line interface
  6. JSON schemas - Validation for manifests and API requests
  7. Documentation - User and design documentation
  8. CCM Studio - Web-based manifest designer

File Checklist

FileActionPurpose
model/resource_<type>.goCreateProperties, state, metadata structs
model/resource_<type>_test.goCreateProperty validation tests
model/resource.goModifyAdd case to factory function
resources/<type>/<type>.goCreateProvider interface definition
resources/<type>/type.goCreateResource type implementation
resources/<type>/type_test.goCreateResource type tests
resources/<type>/provider_mock_test.goGenerateMock provider for tests
resources/<type>/<provider>/factory.goCreateProvider factory
resources/<type>/<provider>/<provider>.goCreateProvider implementation
resources/<type>/<provider>/<provider>_test.goCreateProvider tests
resources/resources.goModifyAdd case to NewResourceFromProperties
cmd/ensure_<type>.goCreateCLI command handler
cmd/ensure.goModifyRegister CLI command
internal/fs/schemas/manifest.jsonModifyAdd resource schema definitions
internal/fs/schemas/resource_ensure_request.jsonModifyAdd API request schema
docs/content/resources/<type>.mdCreateUser documentation
docs/content/design/<type>/_index.mdCreateDesign documentation
docs/content/design/<type>/<provider>.mdCreateProvider 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 state
    ResourceStatus<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 ResourceProperties interface {
    CommonProperties() *CommonResourceProperties
    Validate() error
    ResolveTemplates(*templates.Env) error
    ResolveDeferredTemplates(*templates.Env) error
    ToYamlManifest() (yaml.RawMessage, error)
}

Structure:

type <Type>ResourceProperties struct {
    CommonResourceProperties `yaml:",inline"`

    // All string fields are automatically template-resolved by default.
    // Use struct tags to control resolution behavior.
    Url      string `json:"url" yaml:"url"`
    Checksum string `json:"checksum,omitempty" yaml:"checksum,omitempty"`

    // Fields that must not be template-resolved
    Delimiter string `json:"delimiter,omitempty" yaml:"delimiter,omitempty" template:"-"`

    // Fields deferred until after control evaluation
    Content string `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 ResourceState interface {
    CommonState() *CommonResourceState
}

Structure:

type <Type>Metadata struct {
    Name     string `json:"name" yaml:"name"`
    Provider string `json:"provider,omitempty" yaml:"provider,omitempty"`
    // Add fields describing current system state
}

type <Type>State struct {
    CommonResourceState
    Metadata *<Type>Metadata `json:"metadata,omitempty"`
}

Factory Function

Provide a factory function for YAML parsing:

func New<Type>ResourcePropertiesFromYaml(raw yaml.RawMessage) ([]ResourceProperties, error) {
    return parseProperties(raw, <Type>TypeName, func() ResourceProperties {
        return &<Type>ResourceProperties{}
    })
}

Step 2: Resource Type Implementation

Provider Interface (resources/<type>/<type>.go)

Define a type-specific provider interface that embeds model.Provider and adds type-specific methods:

package <type>resource

import (
    "context"

    "github.com/choria-io/ccm/model"
    "github.com/choria-io/ccm/resources/<type>/<provider>"
)

type <Type>Factory interface {
    model.ProviderFactory
}

func init() {
    <provider>.Register()
}

type <Type>Provider interface {
    model.Provider

    Status(ctx context.Context, properties *model.<Type>ResourceProperties) (*model.<Type>State, error)
    // Add provider-specific methods (e.g., Download, Extract for archive)
}

Type Implementation (resources/<type>/type.go)

The Type struct must satisfy both model.Resource and base.EmbeddedResource:

// model.Resource interface
type Resource interface {
    Type() string
    Name() string
    Provider() string
    Properties() ResourceProperties
    Apply(context.Context) (*TransactionEvent, error)
    Info(context.Context) (any, error)
    Healthcheck(ctx context.Context) (*TransactionEvent, error)
}

// base.EmbeddedResource interface
type EmbeddedResource interface {
    NewTransactionEvent() *model.TransactionEvent
    ApplyResource(ctx context.Context) (model.ResourceState, error)
    SelectProvider() (string, error)
    Type() string
}

Embedding *base.Base provides implementations for Apply(), Healthcheck(), Type(), Name(), Properties(), and NewTransactionEvent(). The type must implement:

  • ApplyResource() - core resource application logic
  • SelectProvider() - provider selection
  • Provider() - return current provider name
  • Info() - return resource information

Structure:

type Type struct {
    *base.Base

    prop     *model.<Type>ResourceProperties
    mgr      model.Manager
    log      model.Logger
    provider model.Provider
    facts    map[string]any
    data     map[string]any

    mu sync.Mutex
}

var _ model.Resource = (*Type)(nil)

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:

  1. Get initial state via provider.Status()
  2. Check if already in desired state (implement isDesiredState() helper)
  3. If stable, call t.FinalizeState() and return early
  4. Apply changes, respecting t.mgr.NoopMode()
  5. Get final state and verify desired state was achieved
  6. 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.

Step 3: Provider Implementation

Factory (resources/<type>/<provider>/factory.go)

The factory must satisfy model.ProviderFactory:

type ProviderFactory interface {
    TypeName() string
    Name() string
    New(log Logger, runner CommandRunner) (Provider, error)
    IsManageable(facts map[string]any, properties ResourceProperties) (bool, int, error)
}

The IsManageable method returns:

  • bool - whether this provider can manage the resource
  • int - priority (higher wins when multiple providers match)
  • error - any error encountered

Structure:

package <provider>

import (
    "github.com/choria-io/ccm/internal/registry"
    "github.com/choria-io/ccm/model"
)

const ProviderName = "<provider>"

func Register() {
    registry.MustRegister(&factory{})
}

type factory struct{}

func (p *factory) TypeName() string { return model.<Type>TypeName }
func (p *factory) Name() string     { return ProviderName }
func (p *factory) New(log model.Logger, runner model.CommandRunner) (model.Provider, error) {
    return New<Provider>Provider(log, runner)
}
func (p *factory) IsManageable(facts map[string]any, prop model.ResourceProperties) (bool, int, error) {
    // Type assert and check if this provider can handle the resource
    return true, 1, nil
}

See resources/archive/http/factory.go for a complete example.

Provider Implementation (resources/<type>/<provider>/<provider>.go)

The provider must satisfy the type-specific provider interface defined in Step 2 (which embeds model.Provider):

type Provider interface {
    Name() string
}

Structure:

package <provider>

import (
    "context"

    "github.com/choria-io/ccm/model"
)

type <Provider>Provider struct {
    log    model.Logger
    runner model.CommandRunner
}

func New<Provider>Provider(log model.Logger, runner model.CommandRunner) (*<Provider>Provider, error) {
    return &<Provider>Provider{log: log, runner: runner}, nil
}

func (p *<Provider>Provider) Name() string {
    return ProviderName
}

func (p *<Provider>Provider) Status(ctx context.Context, properties *model.<Type>ResourceProperties) (*model.<Type>State, error) {
    state := &model.<Type>State{
        CommonResourceState: model.NewCommonResourceState(
            model.ResourceStatus<Type>Protocol,
            model.<Type>TypeName,
            properties.Name,
            model.EnsureAbsent,
        ),
        Metadata: &model.<Type>Metadata{
            Name:     properties.Name,
            Provider: ProviderName,
        },
    }

    // Query system state and populate metadata

    return state, nil
}

// Implement other type-specific provider methods...

See resources/archive/http/http.go for a complete example.

Step 4: Integration Points

Update resources/resources.go

Add the import and case statement:

import (
    // ...
    <type>resource "github.com/choria-io/ccm/resources/<type>"
)

func NewResourceFromProperties(ctx context.Context, mgr model.Manager, props model.ResourceProperties) (model.Resource, error) {
    switch rprop := props.(type) {
    // ... existing cases ...
    case *model.<Type>ResourceProperties:
        return <type>resource.New(ctx, mgr, *rprop)
    default:
        return nil, fmt.Errorf("unsupported resource property type %T", rprop)
    }
}

Update model/resource.go

Add the case to NewResourcePropertiesFromYaml:

func NewResourcePropertiesFromYaml(typeName string, rawProperties yaml.RawMessage, env *templates.Env) ([]ResourceProperties, error) {
    switch typeName {
    // ... existing cases ...
    case <Type>TypeName:
        props, err = New<Type>ResourcePropertiesFromYaml(rawProperties)
    default:
        return nil, fmt.Errorf("%w: %s %s", ErrResourceInvalid, ErrUnknownType, typeName)
    }
    // ...
}

Step 5: CLI Command

Create cmd/ensure_<type>.go:

package main

import (
    "github.com/choria-io/ccm/model"
    "github.com/choria-io/fisk"
)

type ensure<Type>Command struct {
    name string
    // Add command-specific fields for flags

    parent *ensureCommand
}

func registerEnsure<Type>Command(ccm *fisk.CmdClause, parent *ensureCommand) {
    cmd := &ensure<Type>Command{parent: parent}

    <type> := ccm.Command("<type>", "<Type> management").Action(cmd.<type>Action)
    <type>.Arg("name", "Resource name").Required().StringVar(&cmd.name)
    // Add type-specific flags

    parent.addCommonFlags(<type>)
}

func (c *ensure<Type>Command) <type>Action(_ *fisk.ParseContext) error {
    properties := model.<Type>ResourceProperties{
        CommonResourceProperties: model.CommonResourceProperties{
            Name:     c.name,
            Ensure:   model.EnsurePresent,
            Provider: c.parent.provider,
        },
        // Set type-specific properties from flags
    }

    return c.parent.commonEnsureResource(&properties)
}

Update cmd/ensure.go:

func registerEnsureCommand(ccm *fisk.Application) {
    // ... existing code ...
    registerEnsure<Type>Command(ens, cmd)
}

Step 6: JSON Schemas

Update internal/fs/schemas/manifest.json

Add to the $defs/resource properties:

"<type>": {
  "oneOf": [
    { "$ref": "#/$defs/<type>ResourceList" },
    { "$ref": "#/$defs/<type>ResourcePropertiesWithName" }
  ]
}

Add resource list definition:

"<type>ResourceList": {
  "type": "array",
  "description": "List of <type> resources to manage (named format)",
  "items": {
    "type": "object",
    "additionalProperties": {
      "$ref": "#/$defs/<type>ResourceProperties"
    },
    "minProperties": 1,
    "maxProperties": 1
  }
}

Add properties definitions:

"<type>ResourcePropertiesWithName": {
  "allOf": [
    { "$ref": "#/$defs/<type>ResourceProperties" },
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "description": "Resource name"
        }
      },
      "required": ["name"]
    }
  ]
},
"<type>ResourceProperties": {
  "type": "object",
  "properties": {
    "ensure": {
      "type": "string",
      "enum": ["present", "absent"]
    }
    // Add type-specific properties
  }
}

Update internal/fs/schemas/resource_ensure_request.json

Add to the type enum:

"enum": ["package", "service", "file", "exec", "archive", "<type>"]

Add to properties.oneOf:

{ "$ref": "#/$defs/<type>Properties" }

Add properties definition under $defs:

"<type>Properties": {
  "allOf": [
    { "$ref": "#/$defs/commonProperties" },
    {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "description": "Resource name"
        }
        // Add type-specific properties
      },
      "required": ["name"]
    }
  ]
}

Copy Schemas to Documentation Site

After updating the schema files in internal/fs/schemas/, copy them to docs/static/schemas/v1/ so they are available on the documentation website:

cp internal/fs/schemas/manifest.json docs/static/schemas/v1/manifest.json
cp internal/fs/schemas/resource_ensure_request.json docs/static/schemas/v1/resource_ensure_request.json

Step 7: Generate Mocks

Generate the provider mock for tests:

mockgen -write_generate_directive \
  -source resources/<type>/<type>.go \
  -destination resources/<type>/provider_mock_test.go \
  -package <type>resource

Or use the project command:

abt gen mocks

Step 8: Testing

Model Tests (model/resource_<type>_test.go)

Test property validation:

var _ = Describe("<Type>ResourceProperties", func() {
    Describe("Validate", func() {
        It("should require name", func() {
            p := &model.<Type>ResourceProperties{}
            p.Ensure = model.EnsurePresent
            Expect(p.Validate()).To(MatchError(model.ErrResourceNameRequired))
        })

        It("should validate ensure values", func() {
            p := &model.<Type>ResourceProperties{}
            p.Name = "test"
            p.Ensure = "invalid"
            Expect(p.Validate()).To(HaveOccurred())
        })
    })
})

Type Tests (resources/<type>/type_test.go)

Use the mock manager helper:

var _ = Describe("<Type> Type", func() {
    var mockctl *gomock.Controller

    BeforeEach(func() {
        mockctl = gomock.NewController(GinkgoT())
        registry.Clear()
        // Register mock factory
    })

    AfterEach(func() {
        mockctl.Finish()
    })

    Describe("Apply", func() {
        It("should handle present ensure state", func() {
            mgr, _ := modelmocks.NewManager(facts, data, false, mockctl)
            // Test implementation
        })
    })
})

Key Patterns

State Checking

Always check current state before making changes:

initialStatus, err := p.Status(ctx, t.prop)
if err != nil {
    return nil, err
}

isStable := t.isDesiredState(properties, initialStatus)
if isStable {
    // No changes needed
    t.FinalizeState(initialStatus, noop, "", false, true, false)
    return initialStatus, nil
}

Noop Mode

All resources must respect noop mode:

if !noop {
    // Make actual changes
    t.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)
if err != nil {
    return fmt.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:

err = os.Rename(tf.Name(), file)
if err != nil {
    return err
}

// chown before chmod: chown(2) clears setuid/setgid bits.
err = os.Chown(file, uid, gid)
if err != nil {
    return err
}

return os.Chmod(file, parsedMode)

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:

TagBehavior
(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:

func (p *<Type>ResourceProperties) ResolveTemplates(env *templates.Env) error {
    if err := templates.ResolveStructTemplates(p, env, false); err != nil {
        return err
    }

    return p.resolveRegistrations(env)
}

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():

func (p *<Type>ResourceProperties) ResolveDeferredTemplates(env *templates.Env) error {
    if err := templates.ResolveStructTemplates(p, env, true); err != nil {
        return err
    }

    // Optional post-processing, e.g.:
    if p.Source != "" {
        p.Source = filepath.Clean(p.Source)
    }

    return nil
}

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.

Environment []string `json:"environment,omitempty" yaml:"environment,omitempty" template:"deferred" schema_placeholder:"PLACEHOLDER=x"`

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:

  1. Front matter: TOML (+++) with title, description, toc = true, and weight.
  2. Opening paragraph: One or two sentences stating what the resource does.
  3. Callout: A warning or note about common pitfalls, using > [!info] syntax.
  4. Primary example: A tabbed block (Manifest / CLI / API Request) showing typical usage.
  5. Brief explanation: One or two sentences describing what the example does.
  6. Ensure values: Table of valid ensure states.
  7. Properties: Table of all properties with short descriptions.
  8. Additional sections: Provider notes, idempotency, authentication, behavioral details as needed.

Front matter

Use TOML delimiters (+++). Include at minimum:

+++
title = "Resource Name"
description = "Short verb-phrase summary"
toc = true
weight = 30
+++

The description field should read as a phrase, not a sentence. No trailing period. Start with a verb: “Manage files content, ownership and more.”

Headings

  • Use ## for top-level sections within a page. Do not use # (the page title comes from front matter).
  • Use ### for subsections.
  • Use sentence case: “Guard commands,” not “Guard Commands.”
  • Keep headings short and descriptive.

Tables

Use Markdown tables for structured reference content: ensure values, properties, provider lists.

  • The first column is the property or value name in backticks.
  • The second column is a brief description, written as a sentence fragment with no trailing period.
  • Align columns with pipes for readability.
| Property | Description                          |
|----------|--------------------------------------|
| `name`   | Absolute path to the file            |
| `ensure` | Desired state (`present`, `absent`)  |

Code examples

Tabbed blocks

Show every example in three tabs using Hugo shortcodes:

  1. Manifest: YAML manifest syntax.
  2. CLI: ccm ensure command using nohighlight fence.
  3. API Request: JSON request body.
{{< tabs >}}
{{% tab title="Manifest" %}}
...
{{% /tab %}}
{{% tab title="CLI" %}}
...
{{% /tab %}}
{{% tab title="API Request" %}}
...
{{% /tab %}}
{{< /tabs >}}

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.