The Resource-Provider Model

Every resource type in CCM is built from three parts. A Type decides whether the system already matches the desired state. A Provider makes the platform-specific change. A shared base.Base owns the flow that connects them: gates, requirements, health checks, and turning the outcome into an event. The split is deliberate. The type stays portable and unit-testable against a mock provider, and the flow is written once for all seven resource types.

Where it lives

resources/base: the shared apply flow. resources/file, resources/package, resources/service and their posix, apt, dnf, systemd provider subpackages: the concrete types and their platform work. model/resource_*.go: the properties and state structs. Key files: resources/base/base.go, resources/file/type.go, resources/file/posix/posix.go.

The inversion

base.Base owns the apply flow but does not know any resource type. It calls back into the concrete type through a small interface, base.EmbeddedResource (resources/base/base.go:27): ApplyResource, SelectProvider, NewTransactionEvent, and Type. Each type embeds *base.Base and, in its New constructor, sets Base.Resource = t so the base can call back into it. That back-pointer is what lets one copy of the control-gating, require-checking, and event-mapping code serve every type.

Type
Embeds *base.Base; holds the typed properties, the manager, and the resolved provider. Its ApplyResource reads status, compares against the desired state, calls a provider verb, and re-verifies.
Provider interface
Widens model.Provider per type. FileProvider adds CreateDirectory, Store, SetAttributes, Remove, Status (resources/file/file.go:18). PackageProvider and ServiceProvider add their own verbs.
Concrete provider
The only place OS mutation lives. The posix file provider is the sole caller of os.MkdirAll, os.Rename, os.Chown, and os.RemoveAll (resources/file/posix/posix.go).
Load-bearing decision

Real work must live in the provider, never in the type. The Type.ApplyResource only decides and verifies; all platform mutation sits behind the capability interface. This keeps a type portable across providers and keeps its decision logic OS-agnostic and testable with a mocked provider. A type that stubbed work inline would break both properties.

Applying one resource

The flow below is base.applyOrHealthCheck (resources/base/base.go:74). The type-specific decisions happen inside the type’s ApplyResource.

  1. Select the provider The type resolves through registry.FindSuitableProvider, which filters providers by IsManageable(facts, props) and picks the lowest priority number. The result is cached.
  2. Gate on control checkControl evaluates control.if and control.unless expressions. If they say do not manage, the event is marked Skipped and returned.
  3. Resolve deferred templates File overrides ResolveDeferredTemplates so content and source render only after the control gate, letting unless protect against template errors on a resource that will be skipped.
  4. Check requirements For each require entry, Manager.IsResourceFailed reads the last recorded event. Any failed or unmet requirement marks this event Skipped with UnmetRequirements and returns.
  5. Read status and decide provider.Status reads the actual state, and isDesiredState compares it against the properties, returning a human-readable mismatch reason.
  6. Act, or describe A switch calls the provider verb. Every mutating branch is guarded by if !noop. In noop mode the verb is skipped and a noopMessage describes what would have happened.
  7. Verify convergence After a real change, the type re-reads status and re-runs isDesiredState. If the system did not converge it returns ErrDesiredStateFailed with the reason.
  8. Finalize and record FinalizeState writes the flags onto the state, base maps them onto the event, and the caller logs and records it.
Select provider by factsControl + require gatesprovider.Status โ†’ actualmatchesdesired?Stable ยท no changeAct via provider verbnoop: describe, do not actRe-read + verifyelse ErrDesiredStateFailedFinalizeState โ†’ eventyesno
Decide, act, verify. The provider does the work; the type only decides and confirms.

Ensure states

Ensure semantics are per type, and deliberately loose where the platform is unreliable.

TypeStatesNotes
filepresent, absent, directoryCompares ensure, content checksum, owner, group, and mode.
packagepresent, absent, latest, <version>present means “anything but absent”; latest is treated as “not absent” because the platform can lie about latest; an exact version uses VersionCmp.
servicerunning, stoppedIndependent enable axis handled in a second switch; defaults to running.

Two invariants worth internalizing

Load-bearing decision

In noop mode, Changed stays true. The provider verbs are skipped, but the flow still records that a change would have happened: package and service force changed = true when noop && refreshState, and set a NoopMessage. A noop event therefore reports both Noop=true and Changed=true. The convergence re-check is skipped under noop, since nothing actually changed.

Convergence is verified, not assumed. After a non-noop change the type re-reads status and re-checks the desired state, failing with the specific mismatch reason if the system did not converge. This is why isDesiredState returns a reason string, not just a bool.

Subscribe and refresh

Only the service type uses refresh. Inside its ApplyResource it calls ShouldRefresh(properties.Subscribe), which asks the manager whether the last recorded event for each subscribed resource had Changed == true. A refresh is suppressed unless the service is ensured running, and skipped when the service is currently stopped, since starting it already covers the change. When it fires, the provider restarts the service and the event is marked Refreshed.

Next

Continue to The Apply Engine to see how a manifest of many resources is parsed, ordered, and executed.