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.
@@ -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.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kamal
4
+ module Dev
5
+ VERSION = "0.3.0"
6
+ end
7
+ end
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