Architecture
CCM is built around a strict dependency inversion. The model package defines contracts
and almost no behavior. Concrete resource types and providers depend on those contracts and
on a global registry, never on the orchestrator. The manager package is the orchestrator
that satisfies the central contract and wires in facts, data, sessions, NATS, and logging.
That inversion is why a provider can self-register without the manager knowing it exists,
and why the same resource logic runs from the CLI, a manifest, or the agent.
Where it lives
model: the contracts and shared structs. internal/registry: the global provider
directory. manager: the concrete orchestrator, type CCM. Key files: model/resource.go,
model/provider.go, model/manager.go, model/transaction.go, internal/registry/registry.go,
manager/manager.go, manager/opts.go.
The layers
Read the stack from the outside in. Entry points build a manager and hand it work. The apply
engine turns a manifest into resources. Each resource embeds a shared base that owns the
apply flow. The base calls into the concrete type, which decides what to change and calls a
provider. Providers do the platform work. Every layer below the manager programs against
model interfaces only.
The contracts
The model package is deliberately thin. It holds interfaces and shared value structs, and
defers behavior to the packages that implement them.
- Resource
- The runtime behavior of a managed thing:
Type,Name,ResourceId,Provider,Properties, plusApply,Healthcheck, andInfo. Implemented by the per-typeTypestructs underresources/*/type.go. Defined atmodel/resource.go:26. - ResourceProperties
- The declarative desired state:
CommonProperties(),Validate(),ResolveTemplates(),ResolveDeferredTemplates(),ToYamlManifest(). Every per-type properties struct embedsCommonResourceProperties. Defined atmodel/resource.go:43. - Provider
- Intentionally tiny: only
Name() string(model/provider.go:8). Each resource package widens it into a capability interface, so the registry can stay type-agnostic while types get a rich contract. - ProviderFactory
- The registration unit:
TypeName(),Name(),IsManageable(facts, props), andNew(Logger, CommandRunner). Defined atmodel/provider.go:12. - Manager
- The large seam the whole engine programs against: facts, data, logging, execution, sessions and events, registration, templating, and NATS. Defined at
model/manager.go:25.
The registry as a plugin bus
internal/registry is a global directory keyed by resource type then provider name. Each
provider package exposes a Register() that calls registry.MustRegister(&factory{}) from
an init(). The manager triggers all of these through a single blank import,
_ "github.com/choria-io/ccm/resources" at manager/manager.go:27, so it never names a
concrete provider. Duplicate provider names within a type are rejected with
ErrDuplicateProvider.
Resolution happens when a resource applies. FindSuitableProvider (internal/registry/registry.go:168)
probes every factory for the type with IsManageable(facts, props), keeps the ones that
report they can manage the resource, sorts them ascending by the returned priority integer,
and takes the first.
Load-bearing decision
Lower priority value wins. selectProviders sorts ascending and FindSuitableProvider takes
provs[0] (internal/registry/registry.go:113). A test comment calls this the “highest
priority” even though the numeric value is the lowest, so read the number, not the word.
The manager
The CCM struct (manager/manager.go:36) is the concrete model.Manager. It is built with
NewManager(log, userLogger, opts...) and functional options from manager/opts.go
(WithNatsContext, WithSessionDirectory, WithRegistrationDestination, WithNoop,
WithEnvironmentData, and others). It defaults to an in-memory session store and a no-op
registration publisher, so a bare manager is safe to run offline.
- Facts
Facts()caches gathered facts;SystemFacts()always re-gathers with a 2s default deadline;SetFactsandMergeFactsoverride or overlay the cache.- Data
SetDatadeep-merges resolved data with an external overlay that always wins;Data()returns a copy.- Sessions
StartSession,RecordEvent,SessionSummary, plusShouldRefreshandIsResourceFailed, which read the last recorded event for a resource to drive subscribe and require.- Templating
TemplateEnvironment(ctx)assembles the render environment and injects the registration lookup and KV-get closures.- Noop
NoopMode()andSetNoopModegate every mutating branch in the resource types.
Cross-manager safety
Some platform tooling is not safe to run in parallel. CCM guards it with process-wide
mutexes, PackageGlobalLock and ServiceGlobalLock (model/global_locks.go:14), that
serialize all package and service operations even across concurrent managers and manifests.
The stated reason is that concurrent dpkg/apt or systemd operations would corrupt their
databases.
Load-bearing decision
TransactionEvent carries the outcome flags (Changed, Refreshed, Failed, Skipped,
Noop) that are mirrored in metrics, the session summary, the CLI output, and
CommonResourceState. A comment at model/transaction.go:45 warns that a change to this
struct must be propagated to all four. Treat it as a shared schema, not a local struct.
Two type-dispatch switches are hand-maintained and carry a matching TODO:
NewResourcePropertiesFromYaml (model/resource.go:158) and ResourceInfo
(manager/manager.go:397) both map type names to constructors by hand, and the intent is to
make the registry carry that mapping so new types register once. The SelectProvider
boilerplate is likewise duplicated across every resources/*/type.go with a
// TODO: move to base.
Next
Continue to The Resource-Provider Model to see how a concrete type and its providers are built on these contracts.