kamal-dev 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +508 -0
- data/LICENSE.txt +21 -0
- data/README.md +899 -0
- data/Rakefile +10 -0
- data/exe/kamal-dev +10 -0
- data/exe/plugin-kamal-dev +283 -0
- data/lib/kamal/cli/dev.rb +1192 -0
- data/lib/kamal/dev/builder.rb +332 -0
- data/lib/kamal/dev/compose_parser.rb +255 -0
- data/lib/kamal/dev/config.rb +359 -0
- data/lib/kamal/dev/devcontainer.rb +122 -0
- data/lib/kamal/dev/devcontainer_parser.rb +204 -0
- data/lib/kamal/dev/registry.rb +149 -0
- data/lib/kamal/dev/secrets_loader.rb +93 -0
- data/lib/kamal/dev/state_manager.rb +271 -0
- data/lib/kamal/dev/templates/dev-entrypoint.sh +44 -0
- data/lib/kamal/dev/templates/dev.yml +93 -0
- data/lib/kamal/dev/version.rb +7 -0
- data/lib/kamal/dev.rb +33 -0
- data/lib/kamal/providers/base.rb +121 -0
- data/lib/kamal/providers/upcloud.rb +299 -0
- data/lib/kamal-dev.rb +5 -0
- data/sig/kamal/dev.rbs +6 -0
- data/test_installer.sh +73 -0
- metadata +141 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Kamal
|
|
6
|
+
module Dev
|
|
7
|
+
# Registry configuration and authentication for image building and pushing
|
|
8
|
+
#
|
|
9
|
+
# Provides methods for:
|
|
10
|
+
# - Registry server configuration (default: ghcr.io)
|
|
11
|
+
# - Credential loading from environment variables
|
|
12
|
+
# - Image naming conventions ({registry}/{user}/{service}-dev:{tag})
|
|
13
|
+
# - Docker login command generation
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage
|
|
16
|
+
# registry = Kamal::Dev::Registry.new(config)
|
|
17
|
+
# image = registry.image_name("myapp")
|
|
18
|
+
# # => "ghcr.io/ljuti/myapp-dev"
|
|
19
|
+
#
|
|
20
|
+
# @example With tag
|
|
21
|
+
# registry.image_tag("myapp", "abc123")
|
|
22
|
+
# # => "ghcr.io/ljuti/myapp-dev:abc123"
|
|
23
|
+
#
|
|
24
|
+
# @example Docker login
|
|
25
|
+
# registry.login_command
|
|
26
|
+
# # => ["docker", "login", "ghcr.io", "-u", "ljuti", "-p", "token"]
|
|
27
|
+
#
|
|
28
|
+
class Registry
|
|
29
|
+
attr_reader :config
|
|
30
|
+
|
|
31
|
+
# Initialize registry with configuration
|
|
32
|
+
#
|
|
33
|
+
# @param config [Kamal::Dev::Config] Configuration object
|
|
34
|
+
def initialize(config)
|
|
35
|
+
@config = config
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Registry server URL
|
|
39
|
+
#
|
|
40
|
+
# @return [String] Registry server (default: ghcr.io)
|
|
41
|
+
def server
|
|
42
|
+
config.registry_server
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Registry username loaded from environment variable
|
|
46
|
+
#
|
|
47
|
+
# @return [String, nil] Username from ENV
|
|
48
|
+
def username
|
|
49
|
+
config.registry_username
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Registry password/token loaded from environment variable
|
|
53
|
+
#
|
|
54
|
+
# @return [String, nil] Password from ENV
|
|
55
|
+
def password
|
|
56
|
+
config.registry_password
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get full image name with registry server prepended if needed
|
|
60
|
+
#
|
|
61
|
+
# Supports both patterns:
|
|
62
|
+
# - Full path: "ghcr.io/org/app" → "ghcr.io/org/app"
|
|
63
|
+
# - Short path: "org/app" → "ghcr.io/org/app" (registry prepended)
|
|
64
|
+
# - Name only: "app" → "ghcr.io/app"
|
|
65
|
+
#
|
|
66
|
+
# @param image_ref [String] Image reference from config.image
|
|
67
|
+
# @return [String] Full image name with registry
|
|
68
|
+
def full_image_name(image_ref)
|
|
69
|
+
# Check if image already includes a registry
|
|
70
|
+
# Registry indicators: has a . (ghcr.io) or : (localhost:5000) in first component
|
|
71
|
+
first_component = image_ref.split("/").first
|
|
72
|
+
|
|
73
|
+
if first_component.include?(".") || first_component.include?(":")
|
|
74
|
+
# Already has registry: "ghcr.io/org/app", "docker.io/library/ruby", "localhost:5000/app"
|
|
75
|
+
image_ref
|
|
76
|
+
else
|
|
77
|
+
# No registry: "org/app" or "app" - prepend registry server
|
|
78
|
+
"#{server}/#{image_ref}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Generate image name without tag (DEPRECATED - kept for backward compatibility)
|
|
83
|
+
#
|
|
84
|
+
# @deprecated Use full_image_name(config.image) instead
|
|
85
|
+
# @param service [String] Service name (from config.service)
|
|
86
|
+
# @return [String] Full image name without tag
|
|
87
|
+
# @raise [Kamal::Dev::RegistryError] if username not configured
|
|
88
|
+
def image_name(service)
|
|
89
|
+
raise Kamal::Dev::RegistryError, "Registry username not configured" unless username
|
|
90
|
+
|
|
91
|
+
"#{server}/#{username}/#{service}-dev"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Generate full image reference with tag
|
|
95
|
+
#
|
|
96
|
+
# @param image_base [String] Base image name (can be full or short path)
|
|
97
|
+
# @param tag [String] Image tag (timestamp, git SHA, or custom)
|
|
98
|
+
# @return [String] Full image reference with tag
|
|
99
|
+
def image_tag(image_base, tag)
|
|
100
|
+
base = (image_base.is_a?(String) && (image_base.include?("/") || image_base.include?("."))) ?
|
|
101
|
+
full_image_name(image_base) :
|
|
102
|
+
image_name(image_base) # Backward compatibility
|
|
103
|
+
"#{base}:#{tag}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Generate docker login command
|
|
107
|
+
#
|
|
108
|
+
# @return [Array<String>] Docker login command array
|
|
109
|
+
# @raise [Kamal::Dev::RegistryError] if credentials not configured
|
|
110
|
+
# @example
|
|
111
|
+
# registry.login_command
|
|
112
|
+
# # => ["docker", "login", "ghcr.io", "-u", "ljuti", "-p", "token"]
|
|
113
|
+
def login_command
|
|
114
|
+
raise Kamal::Dev::RegistryError, "Registry credentials not configured" unless credentials_present?
|
|
115
|
+
|
|
116
|
+
["docker", "login", server, "-u", username, "-p", password]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Check if credentials are present
|
|
120
|
+
#
|
|
121
|
+
# @return [Boolean] true if username and password are set
|
|
122
|
+
def credentials_present?
|
|
123
|
+
!!(username && password)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Generate timestamp-based tag
|
|
127
|
+
#
|
|
128
|
+
# Format: unix_timestamp (e.g., "1700000000")
|
|
129
|
+
#
|
|
130
|
+
# @return [String] Unix timestamp tag
|
|
131
|
+
def tag_with_timestamp
|
|
132
|
+
Time.now.to_i.to_s
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Generate git SHA-based tag
|
|
136
|
+
#
|
|
137
|
+
# Format: short_sha (e.g., "abc123f")
|
|
138
|
+
#
|
|
139
|
+
# @return [String, nil] Short git commit SHA or nil if git not available
|
|
140
|
+
def tag_with_git_sha
|
|
141
|
+
sha, _status = Open3.capture2("git", "rev-parse", "--short", "HEAD", err: :close)
|
|
142
|
+
sha = sha.strip
|
|
143
|
+
sha.empty? ? nil : sha
|
|
144
|
+
rescue
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Kamal
|
|
6
|
+
module Dev
|
|
7
|
+
# Loads and processes secrets from .kamal/secrets file
|
|
8
|
+
#
|
|
9
|
+
# Parses shell script format (export KEY="value") and provides
|
|
10
|
+
# Base64-encoded values for container injection.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# loader = SecretsLoader.new(".kamal/secrets")
|
|
14
|
+
# secrets = loader.load_secrets
|
|
15
|
+
# #=> {"DATABASE_URL" => "base64_encoded_value", ...}
|
|
16
|
+
class SecretsLoader
|
|
17
|
+
# Custom error for missing secrets file
|
|
18
|
+
class SecretsNotFoundError < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Custom error for secrets parsing failures
|
|
21
|
+
class SecretsParseError < StandardError; end
|
|
22
|
+
|
|
23
|
+
attr_reader :secrets_file
|
|
24
|
+
|
|
25
|
+
# Initialize secrets loader with file path
|
|
26
|
+
#
|
|
27
|
+
# @param secrets_file_path [String] Path to secrets file
|
|
28
|
+
def initialize(secrets_file_path = ".kamal/secrets")
|
|
29
|
+
@secrets_file = secrets_file_path
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Load and parse secrets from file
|
|
33
|
+
#
|
|
34
|
+
# @return [Hash<String, String>] Hash of secret keys to Base64-encoded values
|
|
35
|
+
# @raise [SecretsNotFoundError] if secrets file doesn't exist
|
|
36
|
+
# @raise [SecretsParseError] if file cannot be parsed
|
|
37
|
+
def load_secrets
|
|
38
|
+
unless File.exist?(@secrets_file)
|
|
39
|
+
raise SecretsNotFoundError, "Secrets file not found: #{@secrets_file}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
parse_secrets_file
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Load secrets for specific keys only
|
|
46
|
+
#
|
|
47
|
+
# @param keys [Array<String>] List of secret keys to load
|
|
48
|
+
# @return [Hash<String, String>] Hash of requested keys to Base64-encoded values
|
|
49
|
+
def load_secrets_for(keys)
|
|
50
|
+
all_secrets = load_secrets
|
|
51
|
+
keys.each_with_object({}) do |key, result|
|
|
52
|
+
result[key] = all_secrets[key] if all_secrets.key?(key)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Parse shell script format secrets file
|
|
59
|
+
#
|
|
60
|
+
# Supports formats:
|
|
61
|
+
# export KEY="value"
|
|
62
|
+
# export KEY='value'
|
|
63
|
+
# export KEY=value
|
|
64
|
+
#
|
|
65
|
+
# @return [Hash<String, String>] Parsed and Base64-encoded secrets
|
|
66
|
+
def parse_secrets_file
|
|
67
|
+
content = File.read(@secrets_file)
|
|
68
|
+
secrets = {}
|
|
69
|
+
|
|
70
|
+
content.each_line do |line|
|
|
71
|
+
# Skip comments and empty lines
|
|
72
|
+
next if line.strip.start_with?("#") || line.strip.empty?
|
|
73
|
+
|
|
74
|
+
# Match: export KEY="value" or export KEY='value' or export KEY=value
|
|
75
|
+
if line =~ /^\s*export\s+([A-Z_][A-Z0-9_]*)\s*=\s*(.+)$/
|
|
76
|
+
key = $1
|
|
77
|
+
value = $2.strip
|
|
78
|
+
|
|
79
|
+
# Remove quotes if present
|
|
80
|
+
value = value[1..-2] if value.start_with?('"', "'") && value.end_with?('"', "'")
|
|
81
|
+
|
|
82
|
+
# Base64 encode the value
|
|
83
|
+
secrets[key] = Base64.strict_encode64(value)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
secrets
|
|
88
|
+
rescue => e
|
|
89
|
+
raise SecretsParseError, "Failed to parse secrets file: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "timeout"
|
|
6
|
+
|
|
7
|
+
module Kamal
|
|
8
|
+
module Dev
|
|
9
|
+
# Manages deployment state file with file locking for concurrency safety
|
|
10
|
+
#
|
|
11
|
+
# Provides thread-safe read/write operations to `.kamal/dev_state.yml` using
|
|
12
|
+
# File.flock with exclusive locks for writes and shared locks for reads.
|
|
13
|
+
#
|
|
14
|
+
# State file format:
|
|
15
|
+
# ```yaml
|
|
16
|
+
# deployments:
|
|
17
|
+
# container-name-1:
|
|
18
|
+
# vm_id: "vm-123"
|
|
19
|
+
# vm_ip: "1.2.3.4"
|
|
20
|
+
# container_name: "container-name-1"
|
|
21
|
+
# status: "running"
|
|
22
|
+
# deployed_at: "2025-11-16T10:00:00Z"
|
|
23
|
+
# ```
|
|
24
|
+
#
|
|
25
|
+
# @example Basic usage
|
|
26
|
+
# manager = StateManager.new(".kamal/dev_state.yml")
|
|
27
|
+
# state = manager.read_state
|
|
28
|
+
# manager.add_deployment({name: "myapp-dev-1", vm_id: "vm-123", ...})
|
|
29
|
+
class StateManager
|
|
30
|
+
# Lock timeout in seconds
|
|
31
|
+
LOCK_TIMEOUT = 10
|
|
32
|
+
|
|
33
|
+
attr_reader :state_file
|
|
34
|
+
|
|
35
|
+
# Custom error for lock timeouts
|
|
36
|
+
class LockTimeoutError < StandardError; end
|
|
37
|
+
|
|
38
|
+
# Initialize state manager with state file path
|
|
39
|
+
#
|
|
40
|
+
# @param state_file_path [String] Path to state YAML file
|
|
41
|
+
def initialize(state_file_path)
|
|
42
|
+
@state_file = state_file_path
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Read state with shared lock (multiple readers allowed)
|
|
46
|
+
#
|
|
47
|
+
# @return [Hash] State hash with deployment data
|
|
48
|
+
def read_state
|
|
49
|
+
with_lock(:shared) do |file|
|
|
50
|
+
content = file.read
|
|
51
|
+
return {} if content.empty?
|
|
52
|
+
YAML.safe_load(content, permitted_classes: [Symbol, Time], aliases: true, symbolize_names: false) || {}
|
|
53
|
+
end
|
|
54
|
+
rescue Errno::ENOENT
|
|
55
|
+
{} # File doesn't exist yet
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Write state with exclusive lock (single writer)
|
|
59
|
+
#
|
|
60
|
+
# @param state [Hash] State data to write
|
|
61
|
+
def write_state(state)
|
|
62
|
+
atomic_write(state)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Update state (read-modify-write pattern with exclusive lock)
|
|
66
|
+
#
|
|
67
|
+
# @yield [state] Yields current state for modification
|
|
68
|
+
# @yieldparam state [Hash] Current state hash
|
|
69
|
+
# @yieldreturn [Hash] Modified state hash
|
|
70
|
+
def update_state
|
|
71
|
+
with_lock(:exclusive) do |file|
|
|
72
|
+
file.rewind
|
|
73
|
+
content = file.read
|
|
74
|
+
current_state = if content && !content.empty?
|
|
75
|
+
YAML.safe_load(content, permitted_classes: [Symbol, Time], aliases: true, symbolize_names: false) || {}
|
|
76
|
+
else
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
new_state = yield(current_state)
|
|
81
|
+
|
|
82
|
+
atomic_write(new_state)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Add a new deployment to state (single container)
|
|
87
|
+
#
|
|
88
|
+
# @param deployment [Hash] Deployment data with keys:
|
|
89
|
+
# - :name [String] Container name (key in deployments hash)
|
|
90
|
+
# - :vm_id [String] VM identifier
|
|
91
|
+
# - :vm_ip [String] VM IP address
|
|
92
|
+
# - :container_name [String] Docker container name
|
|
93
|
+
# - :status [String] Deployment status
|
|
94
|
+
# - :deployed_at [String] ISO 8601 timestamp
|
|
95
|
+
def add_deployment(deployment)
|
|
96
|
+
update_state do |state|
|
|
97
|
+
state["deployments"] ||= {}
|
|
98
|
+
state["deployments"][deployment[:name]] = {
|
|
99
|
+
"vm_id" => deployment[:vm_id],
|
|
100
|
+
"vm_ip" => deployment[:vm_ip],
|
|
101
|
+
"container_name" => deployment[:container_name],
|
|
102
|
+
"status" => deployment[:status],
|
|
103
|
+
"deployed_at" => deployment[:deployed_at]
|
|
104
|
+
}
|
|
105
|
+
state
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add a compose stack deployment to state (multiple containers per VM)
|
|
110
|
+
#
|
|
111
|
+
# @param vm_name [String] VM identifier (e.g., "myapp-1")
|
|
112
|
+
# @param vm_id [String] Cloud provider VM ID
|
|
113
|
+
# @param vm_ip [String] VM IP address
|
|
114
|
+
# @param containers [Array<Hash>] Array of container hashes with keys:
|
|
115
|
+
# - :name [String] Container name
|
|
116
|
+
# - :service [String] Service name from compose file
|
|
117
|
+
# - :image [String] Docker image reference
|
|
118
|
+
# - :status [String] Container status
|
|
119
|
+
def add_compose_deployment(vm_name, vm_id, vm_ip, containers)
|
|
120
|
+
update_state do |state|
|
|
121
|
+
state["deployments"] ||= {}
|
|
122
|
+
state["deployments"][vm_name] = {
|
|
123
|
+
"vm_id" => vm_id,
|
|
124
|
+
"vm_ip" => vm_ip,
|
|
125
|
+
"deployed_at" => Time.now.utc.iso8601,
|
|
126
|
+
"type" => "compose",
|
|
127
|
+
"containers" => containers.map do |container|
|
|
128
|
+
{
|
|
129
|
+
"name" => container[:name],
|
|
130
|
+
"service" => container[:service],
|
|
131
|
+
"image" => container[:image],
|
|
132
|
+
"status" => container[:status]
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
}
|
|
136
|
+
state
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Update deployment status
|
|
141
|
+
#
|
|
142
|
+
# @param name [String] Container name
|
|
143
|
+
# @param new_status [String] New status value
|
|
144
|
+
def update_deployment_status(name, new_status)
|
|
145
|
+
update_state do |state|
|
|
146
|
+
if state.dig("deployments", name)
|
|
147
|
+
state["deployments"][name]["status"] = new_status
|
|
148
|
+
end
|
|
149
|
+
state
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Remove deployment from state
|
|
154
|
+
#
|
|
155
|
+
# @param name [String] Container name
|
|
156
|
+
def remove_deployment(name)
|
|
157
|
+
should_delete_file = false
|
|
158
|
+
|
|
159
|
+
update_state do |state|
|
|
160
|
+
state["deployments"]&.delete(name)
|
|
161
|
+
|
|
162
|
+
# Mark for deletion if no deployments remain
|
|
163
|
+
if state["deployments"].nil? || state["deployments"].empty?
|
|
164
|
+
should_delete_file = true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
state
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Delete state file outside of lock
|
|
171
|
+
File.delete(@state_file) if should_delete_file && File.exist?(@state_file)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# List all deployments
|
|
175
|
+
#
|
|
176
|
+
# Returns deployments in a normalized format, handling both
|
|
177
|
+
# single container and compose (multi-container) deployments.
|
|
178
|
+
#
|
|
179
|
+
# @return [Hash] Hash of deployments keyed by container/VM name
|
|
180
|
+
def list_deployments
|
|
181
|
+
state = read_state
|
|
182
|
+
state["deployments"] || {}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Check if deployment is a compose stack
|
|
186
|
+
#
|
|
187
|
+
# @param deployment_name [String] Deployment key name
|
|
188
|
+
# @return [Boolean] true if deployment is a compose stack
|
|
189
|
+
def compose_deployment?(deployment_name)
|
|
190
|
+
state = read_state
|
|
191
|
+
deployment = state.dig("deployments", deployment_name)
|
|
192
|
+
return false unless deployment
|
|
193
|
+
|
|
194
|
+
deployment["type"] == "compose" || deployment.key?("containers")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Get containers for a deployment
|
|
198
|
+
#
|
|
199
|
+
# For single container deployments, returns array with one item.
|
|
200
|
+
# For compose deployments, returns all containers in the stack.
|
|
201
|
+
#
|
|
202
|
+
# @param deployment_name [String] Deployment key name
|
|
203
|
+
# @return [Array<Hash>] Array of container hashes
|
|
204
|
+
def get_containers(deployment_name)
|
|
205
|
+
state = read_state
|
|
206
|
+
deployment = state.dig("deployments", deployment_name)
|
|
207
|
+
return [] unless deployment
|
|
208
|
+
|
|
209
|
+
if deployment.key?("containers")
|
|
210
|
+
# Compose deployment - return container array
|
|
211
|
+
deployment["containers"]
|
|
212
|
+
else
|
|
213
|
+
# Single container deployment - wrap in array
|
|
214
|
+
[{
|
|
215
|
+
"name" => deployment["container_name"],
|
|
216
|
+
"service" => "app",
|
|
217
|
+
"image" => "unknown",
|
|
218
|
+
"status" => deployment["status"]
|
|
219
|
+
}]
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private
|
|
224
|
+
|
|
225
|
+
# Acquire lock and execute block
|
|
226
|
+
#
|
|
227
|
+
# @param mode [Symbol] :shared or :exclusive
|
|
228
|
+
# @yield [file] File handle with lock acquired
|
|
229
|
+
def with_lock(mode)
|
|
230
|
+
lock_mode = (mode == :exclusive) ? File::LOCK_EX : File::LOCK_SH
|
|
231
|
+
|
|
232
|
+
FileUtils.mkdir_p(File.dirname(@state_file))
|
|
233
|
+
|
|
234
|
+
File.open(@state_file, File::RDWR | File::CREAT, 0o644) do |file|
|
|
235
|
+
acquire_lock(file, lock_mode)
|
|
236
|
+
yield(file)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Acquire file lock with timeout
|
|
241
|
+
#
|
|
242
|
+
# @param file [File] File handle
|
|
243
|
+
# @param lock_mode [Integer] File::LOCK_SH or File::LOCK_EX
|
|
244
|
+
# @raise [LockTimeoutError] if lock cannot be acquired within timeout
|
|
245
|
+
def acquire_lock(file, lock_mode)
|
|
246
|
+
Timeout.timeout(LOCK_TIMEOUT) do
|
|
247
|
+
file.flock(lock_mode)
|
|
248
|
+
end
|
|
249
|
+
rescue Timeout::Error
|
|
250
|
+
mode_name = (lock_mode == File::LOCK_EX) ? "exclusive" : "shared"
|
|
251
|
+
raise LockTimeoutError, "Could not acquire #{mode_name} lock on state file after #{LOCK_TIMEOUT}s"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Atomic write using temp file + rename
|
|
255
|
+
#
|
|
256
|
+
# @param state [Hash] State data to write
|
|
257
|
+
def atomic_write(state)
|
|
258
|
+
FileUtils.mkdir_p(File.dirname(@state_file))
|
|
259
|
+
|
|
260
|
+
tmp_file = "#{@state_file}.tmp.#{Process.pid}"
|
|
261
|
+
|
|
262
|
+
begin
|
|
263
|
+
File.write(tmp_file, YAML.dump(state))
|
|
264
|
+
File.rename(tmp_file, @state_file) # Atomic on POSIX
|
|
265
|
+
ensure
|
|
266
|
+
File.delete(tmp_file) if File.exist?(tmp_file)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Kamal Dev entrypoint script
|
|
3
|
+
# Handles git cloning for remote deployments while preserving local devcontainer workflow
|
|
4
|
+
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
# Check if this is a kamal-dev remote deployment (env vars will be set)
|
|
8
|
+
if [ -n "$KAMAL_DEV_GIT_REPO" ]; then
|
|
9
|
+
echo "[kamal-dev] Remote deployment detected"
|
|
10
|
+
|
|
11
|
+
# Clone repository if not already present
|
|
12
|
+
if [ ! -d "$KAMAL_DEV_WORKSPACE_FOLDER/.git" ]; then
|
|
13
|
+
echo "[kamal-dev] Cloning $KAMAL_DEV_GIT_REPO (branch: $KAMAL_DEV_GIT_BRANCH)"
|
|
14
|
+
mkdir -p "$KAMAL_DEV_WORKSPACE_FOLDER"
|
|
15
|
+
|
|
16
|
+
# Use token authentication if provided (for private repositories)
|
|
17
|
+
if [ -n "$KAMAL_DEV_GIT_TOKEN" ]; then
|
|
18
|
+
# Extract repo URL components for token injection
|
|
19
|
+
# Supports: https://github.com/user/repo.git
|
|
20
|
+
REPO_URL_WITH_TOKEN=$(echo "$KAMAL_DEV_GIT_REPO" | sed "s|https://|https://${KAMAL_DEV_GIT_TOKEN}@|")
|
|
21
|
+
git clone --depth 1 --branch "$KAMAL_DEV_GIT_BRANCH" "$REPO_URL_WITH_TOKEN" "$KAMAL_DEV_WORKSPACE_FOLDER"
|
|
22
|
+
|
|
23
|
+
# Configure git to cache credentials for future operations (pull/push)
|
|
24
|
+
cd "$KAMAL_DEV_WORKSPACE_FOLDER"
|
|
25
|
+
git config credential.helper store
|
|
26
|
+
echo "https://${KAMAL_DEV_GIT_TOKEN}@github.com" > ~/.git-credentials
|
|
27
|
+
chmod 600 ~/.git-credentials
|
|
28
|
+
else
|
|
29
|
+
# Public repository or SSH (if configured separately)
|
|
30
|
+
git clone --depth 1 --branch "$KAMAL_DEV_GIT_BRANCH" "$KAMAL_DEV_GIT_REPO" "$KAMAL_DEV_WORKSPACE_FOLDER"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
echo "[kamal-dev] Clone complete: $KAMAL_DEV_WORKSPACE_FOLDER"
|
|
34
|
+
else
|
|
35
|
+
echo "[kamal-dev] Repository already cloned at $KAMAL_DEV_WORKSPACE_FOLDER"
|
|
36
|
+
# Optionally pull latest changes
|
|
37
|
+
# cd "$KAMAL_DEV_WORKSPACE_FOLDER" && git pull
|
|
38
|
+
fi
|
|
39
|
+
else
|
|
40
|
+
echo "[kamal-dev] Local development mode (using mounted code)"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# Execute the original command (CMD from Dockerfile or docker-compose)
|
|
44
|
+
exec "$@"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Kamal Dev Configuration
|
|
2
|
+
# Deploy and manage development container workspaces to cloud infrastructure
|
|
3
|
+
|
|
4
|
+
# Service name (used as prefix for container naming)
|
|
5
|
+
service: myapp-dev
|
|
6
|
+
|
|
7
|
+
# Destination image (where to push/deploy) - matches Kamal's usage
|
|
8
|
+
# Registry server will be prepended if not included
|
|
9
|
+
# Examples:
|
|
10
|
+
# myorg/myapp → ghcr.io/myorg/myapp (registry prepended)
|
|
11
|
+
# ghcr.io/myorg/myapp → ghcr.io/myorg/myapp (explicit registry)
|
|
12
|
+
image: myorg/myapp
|
|
13
|
+
|
|
14
|
+
# Build configuration (optional - omit to deploy existing image)
|
|
15
|
+
# Option 1: Build from devcontainer.json
|
|
16
|
+
build:
|
|
17
|
+
devcontainer: .devcontainer/devcontainer.json
|
|
18
|
+
|
|
19
|
+
# Option 2: Build from Dockerfile
|
|
20
|
+
# build:
|
|
21
|
+
# dockerfile: .devcontainer/Dockerfile
|
|
22
|
+
# context: .devcontainer
|
|
23
|
+
|
|
24
|
+
# Option 3: Deploy existing image (no build needed)
|
|
25
|
+
# Remove the build: section entirely and set image to existing image:
|
|
26
|
+
# image: ruby:3.2
|
|
27
|
+
|
|
28
|
+
# Container registry configuration (for building and pushing custom images)
|
|
29
|
+
# Required when using build: section
|
|
30
|
+
registry:
|
|
31
|
+
server: ghcr.io # Registry server (default: ghcr.io - GitHub Container Registry)
|
|
32
|
+
# Alternatives: docker.io, your-custom-registry.io
|
|
33
|
+
username: GITHUB_USER # Environment variable name for registry username
|
|
34
|
+
password: GITHUB_TOKEN # Environment variable name for registry password/token
|
|
35
|
+
# For GHCR: create a Personal Access Token with write:packages scope
|
|
36
|
+
|
|
37
|
+
# Cloud provider configuration
|
|
38
|
+
provider:
|
|
39
|
+
type: upcloud # Cloud provider (currently: upcloud)
|
|
40
|
+
zone: us-nyc1 # Data center location (upcloud zones: us-nyc1, de-fra1, uk-lon1, etc.)
|
|
41
|
+
plan: 1xCPU-2GB # VM plan/size (upcloud plans: 1xCPU-1GB, 1xCPU-2GB, 2xCPU-4GB, etc.)
|
|
42
|
+
# storage_template: "01000000-0000-4000-8000-000030220200" # OS template UUID (optional - defaults to Ubuntu 24.04 LTS)
|
|
43
|
+
# Ubuntu 24.04 LTS: 01000000-0000-4000-8000-000030240200 (default)
|
|
44
|
+
# Ubuntu 22.04 LTS: 01000000-0000-4000-8000-000030220200
|
|
45
|
+
# Find UUIDs in UpCloud control panel under Templates
|
|
46
|
+
|
|
47
|
+
# Secrets to inject from .kamal/secrets file
|
|
48
|
+
# These will be loaded and injected as Base64-encoded environment variables
|
|
49
|
+
secrets:
|
|
50
|
+
- UPCLOUD_USERNAME # Required: UpCloud API username
|
|
51
|
+
- UPCLOUD_PASSWORD # Required: UpCloud API password
|
|
52
|
+
# Registry credentials (required if using registry.username/password above):
|
|
53
|
+
# - GITHUB_USER # For GitHub Container Registry (ghcr.io)
|
|
54
|
+
# - GITHUB_TOKEN # Personal Access Token with write:packages scope
|
|
55
|
+
# - DOCKER_HUB_USERNAME # For Docker Hub (hub.docker.com)
|
|
56
|
+
# - DOCKER_HUB_TOKEN # Docker Hub access token
|
|
57
|
+
# Add your application secrets here:
|
|
58
|
+
# - DATABASE_URL
|
|
59
|
+
# - API_KEY
|
|
60
|
+
|
|
61
|
+
# Path to secrets file (default: .kamal/secrets)
|
|
62
|
+
# secrets_file: .kamal/secrets
|
|
63
|
+
|
|
64
|
+
# SSH configuration
|
|
65
|
+
ssh:
|
|
66
|
+
key_path: ~/.ssh/id_rsa.pub # SSH public key for VM access (default: ~/.ssh/id_rsa.pub)
|
|
67
|
+
|
|
68
|
+
# Git clone configuration (for DevPod-style remote development)
|
|
69
|
+
# When configured, code is cloned from repository on deployment
|
|
70
|
+
# Local devcontainer usage (VS Code) is unaffected - uses mounted code
|
|
71
|
+
git:
|
|
72
|
+
repository: https://github.com/yourorg/yourrepo.git # Git repository URL (HTTPS format)
|
|
73
|
+
branch: main # Branch to checkout (default: main)
|
|
74
|
+
workspace_folder: /workspaces/myapp # Where to clone code (should match devcontainer workspaceFolder)
|
|
75
|
+
token: GITHUB_TOKEN # Environment variable name containing Personal Access Token (for private repos)
|
|
76
|
+
# Generate token at: https://github.com/settings/tokens
|
|
77
|
+
# Scopes needed: repo (for private repos)
|
|
78
|
+
# Add to .kamal/secrets: export GITHUB_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx"
|
|
79
|
+
|
|
80
|
+
# Default resource limits for containers
|
|
81
|
+
defaults:
|
|
82
|
+
cpus: 2 # CPU limit (cores)
|
|
83
|
+
memory: 4g # Memory limit
|
|
84
|
+
memory_swap: 8g # Swap limit (optional)
|
|
85
|
+
|
|
86
|
+
# VM and container deployment configuration
|
|
87
|
+
vms:
|
|
88
|
+
count: 3 # Number of workspaces to deploy
|
|
89
|
+
spread: false # false = colocate containers, true = one container per VM
|
|
90
|
+
|
|
91
|
+
# Container naming pattern
|
|
92
|
+
naming:
|
|
93
|
+
pattern: "{service}-{index}" # Results in: myapp-dev-1, myapp-dev-2, etc.
|
data/lib/kamal/dev.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Load base Kamal first to ensure CLI classes are available
|
|
4
|
+
require "kamal"
|
|
5
|
+
|
|
6
|
+
require_relative "dev/version"
|
|
7
|
+
require_relative "dev/config"
|
|
8
|
+
require_relative "dev/devcontainer_parser"
|
|
9
|
+
require_relative "dev/devcontainer"
|
|
10
|
+
require_relative "dev/state_manager"
|
|
11
|
+
require_relative "dev/secrets_loader"
|
|
12
|
+
require_relative "dev/registry"
|
|
13
|
+
require_relative "dev/builder"
|
|
14
|
+
require_relative "providers/base"
|
|
15
|
+
require_relative "providers/upcloud"
|
|
16
|
+
require_relative "cli/dev"
|
|
17
|
+
|
|
18
|
+
module Kamal
|
|
19
|
+
module Dev
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
class ConfigurationError < Error; end
|
|
22
|
+
class RegistryError < Error; end
|
|
23
|
+
class BuildError < Error; end
|
|
24
|
+
class DeploymentError < Error; end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Hook into Kamal's CLI to register the 'dev' subcommand
|
|
29
|
+
# This allows users to run: kamal dev deploy, kamal dev list, etc.
|
|
30
|
+
Kamal::Cli::Main.class_eval do
|
|
31
|
+
desc "dev", "Manage development containers"
|
|
32
|
+
subcommand "dev", Kamal::Cli::Dev
|
|
33
|
+
end
|