-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstack_reader.rb
More file actions
185 lines (159 loc) · 7.79 KB
/
Copy pathstack_reader.rb
File metadata and controls
185 lines (159 loc) · 7.79 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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
183
184
185
require 'open3'
require 'tmpdir'
require 'fileutils'
require 'yaml'
require 'json'
module Import
class StackReader
# Raised when `docker compose config` rejects the stack (malformed YAML,
# an invalid compose project, etc.).
class Error < StandardError
# The `docker compose config` output that caused the failure, stripped
# of progress/warning noise — safe to show the user verbatim.
attr_reader :detail
def initialize(detail)
@detail = detail
super("docker compose config failed: #{detail}")
end
end
# Canonical HELIOS service name -> image prefix that identifies it.
# Lets us resolve legacy installations where the user chose different
# service names (e.g. SOLECTRUS's historic 'app' + 'db' instead of
# 'dashboard' + 'postgresql').
SERVICE_IMAGE_PREFIXES = {
'dashboard' => %w[ghcr.io/solectrus/solectrus],
'ingest' => %w[ghcr.io/solectrus/ingest],
'senec-collector' => %w[ghcr.io/solectrus/senec-collector],
'mqtt-collector' => %w[ghcr.io/solectrus/mqtt-collector],
'forecast-collector' => %w[ghcr.io/solectrus/forecast-collector],
'tibber-collector' => %w[ghcr.io/solectrus/tibber-collector],
'senec-charger' => %w[ghcr.io/solectrus/senec-charger],
'shelly-collector' => %w[ghcr.io/solectrus/shelly-collector],
'power-splitter' => %w[ghcr.io/solectrus/power-splitter],
'helios' => %w[ghcr.io/solectrus/helios],
'postgresql-backup' => %w[ghcr.io/solectrus/postgres-s3-backup ghcr.io/solectrus/postgresql-backup],
'influxdb-backup' => %w[ghcr.io/solectrus/influxdb2-s3-backup ghcr.io/solectrus/influxdb-backup],
'postgresql' => %w[postgres],
'redis' => %w[redis],
'influxdb' => %w[influxdb],
# containrrr/watchtower is the historical image; nickfedor/watchtower is
# the community-maintained fork that many users (and fixtures) have moved to.
'watchtower' => %w[containrrr/watchtower nickfedor/watchtower],
'traefik' => %w[traefik],
}.freeze
ALL_IMAGE_PREFIXES = SERVICE_IMAGE_PREFIXES.values.flatten.freeze
COLLECTOR_SERVICES = %w[senec-collector mqtt-collector shelly-collector forecast-collector].freeze
# Collectors that need local hardware/network access (vs. the API-based
# forecast-collector). Their absence — alongside an exposed InfluxDB — is the
# signal that the device collectors run remotely (dashboard_only mode).
DEVICE_COLLECTOR_SERVICES = %w[senec-collector mqtt-collector shelly-collector].freeze
def initialize(compose_path:, env_path:)
@compose_path = compose_path
@env_path = env_path
end
# Directory the imported stack lives in. Used to decide whether an
# absolute volume path from .env is equivalent to the default relative
# bind mount (./service) and can therefore be dropped.
def stack_dir
File.dirname(@compose_path)
end
# Full resolved config hash for a single service (image, ports, volumes, healthcheck, restart, etc.).
# Falls back to image-based lookup so legacy service names (e.g. 'app') resolve
# to their canonical counterparts.
def service(name)
services[name]
end
# All resolved services as { name => config_hash }, including canonical aliases
# for services identified by image.
def services
@services ||= resolved_services.merge(aliased_services)
end
# Returns true if the given image (from a compose service) matches any of
# HELIOS's known service images.
def self.managed_image?(image)
return false if image.nil?
ALL_IMAGE_PREFIXES.any? { |prefix| image_matches_prefix?(image, prefix) }
end
# Matches an image against one prefix or an array of prefixes, respecting
# the ':tag' / '@digest' boundary — so 'postgres' matches 'postgres:16-alpine'
# but not 'ghcr.io/.../postgresql-backup'.
def self.image_matches?(image, prefix_or_prefixes)
Array(prefix_or_prefixes).any? { |prefix| image_matches_prefix?(image, prefix) }
end
def self.image_matches_prefix?(image, prefix)
image = image.to_s
image == prefix || image.start_with?("#{prefix}:", "#{prefix}@")
end
private_class_method :image_matches_prefix?
# Raw (unresolved) compose config parsed from YAML, preserving ${VAR} references.
# Aliases are allowed (real-world compose files lean on `<<: *anchor` heavily) and
# the parsed tree is deep-duped so identical sub-hashes from resolved aliases
# don't share object identity — otherwise YAML.dump would re-emit anchors when
# downstream consumers persist this data into config.yaml.
def raw_compose
@raw_compose ||= (YAML.safe_load_file(@compose_path, permitted_classes: [Symbol], aliases: true) || {}).deep_dup
end
# Raw environment variables from .env file (unresolved, preserving original values)
def raw_env
@raw_env ||= Env::File.load(@env_path)
end
private
def resolved_services
resolved_config['services'] || {}
end
def aliased_services
SERVICE_IMAGE_PREFIXES.each_with_object({}) do |(canonical_name, prefix), result|
next if resolved_services.key?(canonical_name)
# ShellyExtractor reads every service with the shelly-collector image
# directly and merges them into `shelly.devices`. A canonical alias
# would only duplicate the first instance under two keys.
next if canonical_name == 'shelly-collector'
# When multiple services share the same image (e.g. parallel
# forecast-collector instances per provider, or a second mqtt-collector
# for ingest), the first one wins the canonical alias — HELIOS re-emits
# it under the canonical name, while the rest stay in
# _unmanaged.services with their original names. Order is taken from
# raw_compose (original YAML insertion order, what the user authored)
# rather than resolved_services (alphabetized by `docker compose
# config`), so the user's first-listed service wins.
first_match = ordered_service_names.find do |name|
self.class.image_matches?(resolved_services.dig(name, 'image'), prefix)
end
result[canonical_name] = resolved_services[first_match] if first_match
end
end
def ordered_service_names
(raw_compose['services'] || {}).keys
end
def resolved_config
@resolved_config ||= run_compose_config
end
# Use JSON format to avoid YAML 1.1 type coercion issues.
def run_compose_config
Dir.mktmpdir do |tmpdir|
FileUtils.cp(@compose_path, File.join(tmpdir, 'compose.yaml'))
# Normalized copy rather than FileUtils.cp: docker replaces invalid
# bytes with U+FFFD, so a Latin-1 umlaut in a *value* (a password!)
# would be imported corrupted here, while raw_env reads it correctly.
# Extractors mix both sources, so the two have to agree.
File.write(File.join(tmpdir, '.env'), TextEncoding.utf8(File.read(@env_path))) if File.exist?(@env_path)
# Without the overrides, HELIOS' own TZ/ADMIN_PASSWORD/SECRET_KEY_BASE
# would outrank the adopted stack's .env and get imported as if they
# were the user's values (see Env.spawn_overrides).
stdout, stderr, status = Open3.capture3(
::Env.spawn_overrides(@env_path),
'docker', 'compose', 'config', '--format', 'json',
chdir: tmpdir
)
raise Error, compose_error(stderr.presence || stdout) unless status.success?
JSON.parse(stdout)
end
end
# docker emits progress/warning lines (`time=... level=warning ...`) to
# stderr alongside the actual error; drop them so only the error remains.
def compose_error(output)
filtered = output.each_line.reject { |line| line.include?('level=warning') }.join.strip
filtered.presence || output.strip
end
end
end