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