Class: Kamal::Dev::ComposeParser

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

Overview

Parser for Docker Compose files

Parses compose.yaml files to extract service definitions, build contexts, and Dockerfiles. Identifies main application service vs dependent services (databases, caches, etc.) for deployment orchestration.

Examples:

Basic usage

parser = Kamal::Dev::ComposeParser.new(".devcontainer/compose.yaml")
parser.main_service
# => "app"

Get build context

parser.service_build_context("app")
# => "."

Check if service has build section

parser.has_build_section?("postgres")
# => false

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(compose_file_path) ⇒ ComposeParser

Initialize parser with compose file path

Parameters:

  • Path to compose.yaml file

Raises:

  • if file not found or invalid YAML



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

def initialize(compose_file_path)
  @compose_file_path = compose_file_path
  @compose_data = load_and_parse
end

Instance Attribute Details

#compose_dataObject (readonly)

Returns the value of attribute compose_data.



27
28
29
# File 'lib/kamal/dev/compose_parser.rb', line 27

def compose_data
  @compose_data
end

#compose_file_pathObject (readonly)

Returns the value of attribute compose_file_path.



27
28
29
# File 'lib/kamal/dev/compose_parser.rb', line 27

def compose_file_path
  @compose_file_path
end

Instance Method Details

#dependent_servicesArray<String>

Get dependent services (services without build sections)

These are typically databases, caches, message queues, etc. that use pre-built images from registries

Returns:

  • Service names without build sections



127
128
129
# File 'lib/kamal/dev/compose_parser.rb', line 127

def dependent_services
  services.select { |_, config| !config.key?("build") }.keys
end

#has_build_section?(service_name) ⇒ Boolean

Check if service has a build section

Parameters:

  • Service name

Returns:

  • true if service uses build:, false if image:



114
115
116
117
118
119
# File 'lib/kamal/dev/compose_parser.rb', line 114

def has_build_section?(service_name)
  service = services[service_name]
  return false unless service

  service.key?("build")
end

#main_serviceString?

Identify the main application service

Uses heuristic: first service with a build: section, or first service if none have build sections

Returns:

  • Main service name



51
52
53
54
55
56
57
58
# File 'lib/kamal/dev/compose_parser.rb', line 51

def main_service
  # Find first service with build section
  service_with_build = services.find { |_, config| config.key?("build") }
  return service_with_build[0] if service_with_build

  # Fallback to first service
  services.keys.first
end

#service_build_context(service_name) ⇒ String

Get build context for a service

Resolves context path relative to the compose file's directory, since Docker Compose interprets paths relative to the compose file location.

Parameters:

  • Service name

Returns:

  • Build context path resolved relative to compose file (default: ".")



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/kamal/dev/compose_parser.rb', line 67

def service_build_context(service_name)
  service = services[service_name]
  return "." unless service

  build_config = service["build"]
  return "." unless build_config

  # Get context from build config
  context = if build_config.is_a?(String)
    # Handle string build path (shorthand) - this is the context
    build_config
  else
    # Handle object build config
    build_config["context"] || "."
  end

  # Resolve context relative to compose file's directory
  # Docker Compose does this automatically, but we're extracting values
  compose_dir = File.dirname(compose_file_path)
  File.expand_path(context, compose_dir)
end

#service_dockerfile(service_name) ⇒ String

Get Dockerfile path for a service

Returns path relative to the build context (as Docker expects), NOT resolved to absolute path.

Parameters:

  • Service name

Returns:

  • Dockerfile path relative to build context (default: "Dockerfile")



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

def service_dockerfile(service_name)
  service = services[service_name]
  return "Dockerfile" unless service

  build_config = service["build"]
  return "Dockerfile" unless build_config

  # Handle object build config
  return "Dockerfile" if build_config.is_a?(String)

  # Return dockerfile path as-is (relative to build context)
  build_config["dockerfile"] || "Dockerfile"
end

#servicesHash

Get all services from compose file

Returns:

  • Service definitions keyed by service name



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

def services
  compose_data.fetch("services", {})
end

#transform_for_deployment(image_ref, config: nil) ⇒ String

Transform compose file for deployment

Replaces build: sections with image: references pointing to the pushed registry image. Removes local bind mounts (DevPod-style). Optionally injects git clone functionality for remote deployments. Preserves named volumes and other service properties.

Parameters:

  • Full image reference (e.g., "ghcr.io/user/app:tag")

  • (defaults to: nil)

    Optional config for git clone setup

Returns:

  • Transformed YAML content

Raises:

  • if transformation fails



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/kamal/dev/compose_parser.rb', line 142

def transform_for_deployment(image_ref, config: nil)
  transformed = deep_copy(compose_data)
  main = main_service

  if main && transformed["services"][main]
    # Remove build section
    transformed["services"][main].delete("build")

    # Add image reference
    transformed["services"][main]["image"] = image_ref

    # Remove local bind mounts (DevPod-style: code will be cloned, not mounted)
    # Keep named volumes (databases, caches, etc.)
    if transformed["services"][main]["volumes"]
      transformed["services"][main]["volumes"] = transformed["services"][main]["volumes"].reject do |volume|
        # Reject if it's a bind mount (contains ":" and first part is a path)
        if volume.is_a?(String) && volume.include?(":")
          source, _target = volume.split(":", 2)
          # Named volumes don't start with . or / or ~
          source.start_with?(".", "/", "~")
        else
          false
        end
      end

      # Remove volumes array if empty
      transformed["services"][main].delete("volumes") if transformed["services"][main]["volumes"].empty?
    end

    # Inject git clone environment variables if configured
    # The actual cloning is handled by the entrypoint script in the image
    if config&.git_clone_enabled?
      inject_git_env_vars!(transformed["services"][main], config)
    end
  end

  # Convert back to YAML
  YAML.dump(transformed)
rescue => e
  raise Kamal::Dev::ConfigurationError, "Failed to transform compose file: #{e.message}"
end