Class: Kamal::Dev::StateManager

Inherits:
Object
  • Object
show all
Defined in:
lib/kamal/dev/state_manager.rb

Overview

Manages deployment state file with file locking for concurrency safety

Provides thread-safe read/write operations to .kamal/dev_state.yml using File.flock with exclusive locks for writes and shared locks for reads.

State file format:

deployments:
  container-name-1:
    vm_id: "vm-123"
    vm_ip: "1.2.3.4"
    container_name: "container-name-1"
    status: "running"
    deployed_at: "2025-11-16T10:00:00Z"

Examples:

Basic usage

manager = StateManager.new(".kamal/dev_state.yml")
state = manager.read_state
manager.add_deployment({name: "myapp-dev-1", vm_id: "vm-123", ...})

Defined Under Namespace

Classes: LockTimeoutError

Constant Summary collapse

LOCK_TIMEOUT =

Lock timeout in seconds

10

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(state_file_path) ⇒ StateManager

Initialize state manager with state file path

Parameters:

  • Path to state YAML file



41
42
43
# File 'lib/kamal/dev/state_manager.rb', line 41

def initialize(state_file_path)
  @state_file = state_file_path
end

Instance Attribute Details

#state_fileObject (readonly)

Returns the value of attribute state_file.



33
34
35
# File 'lib/kamal/dev/state_manager.rb', line 33

def state_file
  @state_file
end

Instance Method Details

#add_compose_deployment(vm_name, vm_id, vm_ip, containers) ⇒ Object

Add a compose stack deployment to state (multiple containers per VM)

Parameters:

  • VM identifier (e.g., "myapp-1")

  • Cloud provider VM ID

  • VM IP address

  • Array of container hashes with keys:

    • :name [String] Container name
    • :service [String] Service name from compose file
    • :image [String] Docker image reference
    • :status [String] Container status


119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/kamal/dev/state_manager.rb', line 119

def add_compose_deployment(vm_name, vm_id, vm_ip, containers)
  update_state do |state|
    state["deployments"] ||= {}
    state["deployments"][vm_name] = {
      "vm_id" => vm_id,
      "vm_ip" => vm_ip,
      "deployed_at" => Time.now.utc.iso8601,
      "type" => "compose",
      "containers" => containers.map do |container|
        {
          "name" => container[:name],
          "service" => container[:service],
          "image" => container[:image],
          "status" => container[:status]
        }
      end
    }
    state
  end
end

#add_deployment(deployment) ⇒ Object

Add a new deployment to state (single container)

Parameters:

  • Deployment data with keys:

    • :name [String] Container name (key in deployments hash)
    • :vm_id [String] VM identifier
    • :vm_ip [String] VM IP address
    • :container_name [String] Docker container name
    • :status [String] Deployment status
    • :deployed_at [String] ISO 8601 timestamp


95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/kamal/dev/state_manager.rb', line 95

def add_deployment(deployment)
  update_state do |state|
    state["deployments"] ||= {}
    state["deployments"][deployment[:name]] = {
      "vm_id" => deployment[:vm_id],
      "vm_ip" => deployment[:vm_ip],
      "container_name" => deployment[:container_name],
      "status" => deployment[:status],
      "deployed_at" => deployment[:deployed_at]
    }
    state
  end
end

#compose_deployment?(deployment_name) ⇒ Boolean

Check if deployment is a compose stack

Parameters:

  • Deployment key name

Returns:

  • true if deployment is a compose stack



189
190
191
192
193
194
195
# File 'lib/kamal/dev/state_manager.rb', line 189

def compose_deployment?(deployment_name)
  state = read_state
  deployment = state.dig("deployments", deployment_name)
  return false unless deployment

  deployment["type"] == "compose" || deployment.key?("containers")
end

#get_containers(deployment_name) ⇒ Array<Hash>

Get containers for a deployment

For single container deployments, returns array with one item. For compose deployments, returns all containers in the stack.

Parameters:

  • Deployment key name

Returns:

  • Array of container hashes



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
# File 'lib/kamal/dev/state_manager.rb', line 204

def get_containers(deployment_name)
  state = read_state
  deployment = state.dig("deployments", deployment_name)
  return [] unless deployment

  if deployment.key?("containers")
    # Compose deployment - return container array
    deployment["containers"]
  else
    # Single container deployment - wrap in array
    [{
      "name" => deployment["container_name"],
      "service" => "app",
      "image" => "unknown",
      "status" => deployment["status"]
    }]
  end
end

#list_deploymentsHash

List all deployments

Returns deployments in a normalized format, handling both single container and compose (multi-container) deployments.

Returns:

  • Hash of deployments keyed by container/VM name



180
181
182
183
# File 'lib/kamal/dev/state_manager.rb', line 180

def list_deployments
  state = read_state
  state["deployments"] || {}
end

#read_stateHash

Read state with shared lock (multiple readers allowed)

Returns:

  • State hash with deployment data



48
49
50
51
52
53
54
55
56
# File 'lib/kamal/dev/state_manager.rb', line 48

def read_state
  with_lock(:shared) do |file|
    content = file.read
    return {} if content.empty?
    YAML.safe_load(content, permitted_classes: [Symbol, Time], aliases: true, symbolize_names: false) || {}
  end
rescue Errno::ENOENT
  {} # File doesn't exist yet
end

#remove_deployment(name) ⇒ Object

Remove deployment from state

Parameters:

  • Container name



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/kamal/dev/state_manager.rb', line 156

def remove_deployment(name)
  should_delete_file = false

  update_state do |state|
    state["deployments"]&.delete(name)

    # Mark for deletion if no deployments remain
    if state["deployments"].nil? || state["deployments"].empty?
      should_delete_file = true
    end

    state
  end

  # Delete state file outside of lock
  File.delete(@state_file) if should_delete_file && File.exist?(@state_file)
end

#update_deployment_status(name, new_status) ⇒ Object

Update deployment status

Parameters:

  • Container name

  • New status value



144
145
146
147
148
149
150
151
# File 'lib/kamal/dev/state_manager.rb', line 144

def update_deployment_status(name, new_status)
  update_state do |state|
    if state.dig("deployments", name)
      state["deployments"][name]["status"] = new_status
    end
    state
  end
end

#update_state {|state| ... } ⇒ Object

Update state (read-modify-write pattern with exclusive lock)

Yields:

  • (state)

    Yields current state for modification

Yield Parameters:

  • state (Hash)

    Current state hash

Yield Returns:

  • (Hash)

    Modified state hash



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/kamal/dev/state_manager.rb', line 70

def update_state
  with_lock(:exclusive) do |file|
    file.rewind
    content = file.read
    current_state = if content && !content.empty?
      YAML.safe_load(content, permitted_classes: [Symbol, Time], aliases: true, symbolize_names: false) || {}
    else
      {}
    end

    new_state = yield(current_state)

    atomic_write(new_state)
  end
end

#write_state(state) ⇒ Object

Write state with exclusive lock (single writer)

Parameters:

  • State data to write



61
62
63
# File 'lib/kamal/dev/state_manager.rb', line 61

def write_state(state)
  atomic_write(state)
end