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
  • 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
    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
CreateDirectoryCreate a directory with attributes

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

Required Properties

Unlike some resources, file resources require explicit attributes:

PropertyRequiredDescription
ownerYesUsername that owns the file
groupYesGroup that owns the file
modeYesPermissions in octal notation

This prevents accidental creation of files with default or inherited permissions.

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     β”‚      β”‚ Store         β”‚
β”‚ (os.Remove)β”‚      β”‚               β”‚      β”‚               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

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)
  3. Owner match: Current owner matches desired
  4. Group match: Current group matches desired
  5. Mode match: Current permissions match desired

Decision Table

DesiredCurrent StateAction
absentabsentNone
absentpresent/directoryRemove
directorydirectory + matching attrsNone
directoryabsent/presentCreateDirectory
directorydirectory + wrong attrsCreateDirectory (updates attrs)
presentpresent + matching allNone
presentabsentStore
presentpresent + wrong contentStore
presentpresent + wrong attrsStore

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 directory”
    • “Would have removed the file”
  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.

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 - Username comparison
  4. Group matches - Group name comparison
  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 names are resolved to numeric UID/GID via:

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

This uses the system’s user database (/etc/passwd, /etc/group, or equivalent):

  • user.Lookup(owner) β†’ UID
  • user.LookupGroup(group) β†’ GID

Error Handling:

ConditionBehavior
User not foundReturn error: “could not lookup user”
Group not foundReturn error: “could not lookup group”
Invalid UID/GID formatReturn error from strconv.Atoi

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

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

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.

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.

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.