The Resource-Provider Model
Every resource type in CCM is built from three parts. A Type decides whether the system
already matches the desired state. A Provider makes the platform-specific change. A shared
base.Base owns the flow that connects them: gates, requirements, health checks, and turning
the outcome into an event. The split is deliberate. The type stays portable and unit-testable
against a mock provider, and the flow is written once for all seven resource types.
Where it lives
resources/base: the shared apply flow. resources/file, resources/package,
resources/service and their posix, apt, dnf, systemd provider subpackages: the
concrete types and their platform work. model/resource_*.go: the properties and state
structs. Key files: resources/base/base.go, resources/file/type.go,
resources/file/posix/posix.go.
The inversion
base.Base owns the apply flow but does not know any resource type. It calls back into the
concrete type through a small interface, base.EmbeddedResource (resources/base/base.go:27):
ApplyResource, SelectProvider, NewTransactionEvent, and Type. Each type embeds
*base.Base and, in its New constructor, sets Base.Resource = t so the base can call back
into it. That back-pointer is what lets one copy of the control-gating, require-checking, and
event-mapping code serve every type.
- Type
- Embeds
*base.Base; holds the typed properties, the manager, and the resolved provider. ItsApplyResourcereads status, compares against the desired state, calls a provider verb, and re-verifies. - Provider interface
- Widens
model.Providerper type.FileProvideraddsCreateDirectory,Store,SetAttributes,Remove,Status(resources/file/file.go:18).PackageProviderandServiceProvideradd their own verbs. - Concrete provider
- The only place OS mutation lives. The posix file provider is the sole caller of
os.MkdirAll,os.Rename,os.Chown, andos.RemoveAll(resources/file/posix/posix.go).
Load-bearing decision
Real work must live in the provider, never in the type. The Type.ApplyResource only decides
and verifies; all platform mutation sits behind the capability interface. This keeps a type
portable across providers and keeps its decision logic OS-agnostic and testable with a mocked
provider. A type that stubbed work inline would break both properties.
Applying one resource
The flow below is base.applyOrHealthCheck (resources/base/base.go:74). The type-specific
decisions happen inside the type’s ApplyResource.
- Select the provider The type resolves through
registry.FindSuitableProvider, which filters providers byIsManageable(facts, props)and picks the lowest priority number. The result is cached. - Gate on control
checkControlevaluatescontrol.ifandcontrol.unlessexpressions. If they say do not manage, the event is markedSkippedand returned. - Resolve deferred templates File overrides
ResolveDeferredTemplatessocontentandsourcerender only after the control gate, lettingunlessprotect against template errors on a resource that will be skipped. - Check requirements For each
requireentry,Manager.IsResourceFailedreads the last recorded event. Any failed or unmet requirement marks this eventSkippedwithUnmetRequirementsand returns. - Read status and decide
provider.Statusreads the actual state, andisDesiredStatecompares it against the properties, returning a human-readable mismatch reason. - Act, or describe A switch calls the provider verb. Every mutating branch is guarded by
if !noop. In noop mode the verb is skipped and anoopMessagedescribes what would have happened. - Verify convergence After a real change, the type re-reads status and re-runs
isDesiredState. If the system did not converge it returnsErrDesiredStateFailedwith the reason. - Finalize and record
FinalizeStatewrites the flags onto the state, base maps them onto the event, and the caller logs and records it.
Ensure states
Ensure semantics are per type, and deliberately loose where the platform is unreliable.
| Type | States | Notes |
|---|---|---|
file | present, absent, directory | Compares ensure, content checksum, owner, group, and mode. |
package | present, absent, latest, <version> | present means “anything but absent”; latest is treated as “not absent” because the platform can lie about latest; an exact version uses VersionCmp. |
service | running, stopped | Independent enable axis handled in a second switch; defaults to running. |
Two invariants worth internalizing
Load-bearing decision
In noop mode, Changed stays true. The provider verbs are skipped, but the flow still records
that a change would have happened: package and service force changed = true when
noop && refreshState, and set a NoopMessage. A noop event therefore reports both
Noop=true and Changed=true. The convergence re-check is skipped under noop, since nothing
actually changed.
Convergence is verified, not assumed. After a non-noop change the type re-reads status and
re-checks the desired state, failing with the specific mismatch reason if the system did not
converge. This is why isDesiredState returns a reason string, not just a bool.
Subscribe and refresh
Only the service type uses refresh. Inside its ApplyResource it calls
ShouldRefresh(properties.Subscribe), which asks the manager whether the last recorded event
for each subscribed resource had Changed == true. A refresh is suppressed unless the service
is ensured running, and skipped when the service is currently stopped, since starting it
already covers the change. When it fires, the provider restarts the service and the event is
marked Refreshed.
Next
Continue to The Apply Engine to see how a manifest of many resources is parsed, ordered, and executed.