yamine 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/CHANGELOG.md +243 -0
- data/LICENSE +21 -0
- data/README.md +294 -0
- data/bin/yamine +6 -0
- data/lib/ask/skills/yamine/SKILL.md +113 -0
- data/lib/yamine/certs.rb +177 -0
- data/lib/yamine/cli/boot.rb +247 -0
- data/lib/yamine/cli/context.rb +104 -0
- data/lib/yamine/cli/routes.rb +255 -0
- data/lib/yamine/cli/system.rb +768 -0
- data/lib/yamine/cli.rb +108 -0
- data/lib/yamine/command.rb +28 -0
- data/lib/yamine/config.rb +394 -0
- data/lib/yamine/doctor.rb +133 -0
- data/lib/yamine/errors.rb +18 -0
- data/lib/yamine/framework.rb +75 -0
- data/lib/yamine/hostname.rb +44 -0
- data/lib/yamine/hosts.rb +86 -0
- data/lib/yamine/inference.rb +142 -0
- data/lib/yamine/log.rb +59 -0
- data/lib/yamine/ownership.rb +53 -0
- data/lib/yamine/ports.rb +45 -0
- data/lib/yamine/procfile.rb +137 -0
- data/lib/yamine/proxy.rb +489 -0
- data/lib/yamine/proxy_control.rb +222 -0
- data/lib/yamine/resolver.rb +92 -0
- data/lib/yamine/route_store.rb +148 -0
- data/lib/yamine/runner.rb +243 -0
- data/lib/yamine/sanitize.rb +41 -0
- data/lib/yamine/supervisor.rb +242 -0
- data/lib/yamine/trust.rb +131 -0
- data/lib/yamine/variant.rb +134 -0
- data/lib/yamine/version.rb +5 -0
- data/lib/yamine.rb +37 -0
- metadata +137 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Yamine
|
|
6
|
+
# Detect what kind of Ruby app lives in a directory so the runner
|
|
7
|
+
# knows how to boot it. Pure filesystem convention, no shell-outs.
|
|
8
|
+
module Framework
|
|
9
|
+
RAILS = :rails
|
|
10
|
+
RACK = :rack
|
|
11
|
+
JEKYLL = :jekyll
|
|
12
|
+
BRIDGETOWN = :bridgetown
|
|
13
|
+
MIDDLEMAN = :middleman
|
|
14
|
+
PROCFILE = :procfile
|
|
15
|
+
UNKNOWN = :unknown
|
|
16
|
+
|
|
17
|
+
STATIC_GENERATORS = [JEKYLL, BRIDGETOWN, MIDDLEMAN].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def detect(dir = Dir.pwd)
|
|
22
|
+
path = Pathname.new(File.expand_path(dir))
|
|
23
|
+
return RAILS if rails?(path)
|
|
24
|
+
return JEKYLL if jekyll?(path)
|
|
25
|
+
return BRIDGETOWN if bridgetown?(path)
|
|
26
|
+
return MIDDLEMAN if middleman?(path)
|
|
27
|
+
return RACK if rack?(path)
|
|
28
|
+
return PROCFILE if procfile?(path)
|
|
29
|
+
|
|
30
|
+
UNKNOWN
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Managed mode (unix socket via puma) applies to live Rack apps.
|
|
34
|
+
def managed?(framework)
|
|
35
|
+
framework == RAILS || framework == RACK
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def rails?(path)
|
|
39
|
+
path.join("config", "application.rb").file? &&
|
|
40
|
+
path.join("config", "application.rb").read.match?(/<\s*Rails::Application/)
|
|
41
|
+
rescue SystemCallError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def rack?(path)
|
|
46
|
+
path.join("config.ru").file?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def jekyll?(path)
|
|
50
|
+
path.join("_config.yml").file? && gemfile_includes?(path, "jekyll")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def bridgetown?(path)
|
|
54
|
+
path.join("bridgetown.config.yml").file? ||
|
|
55
|
+
(path.join("config", "initializers").directory? && gemfile_includes?(path, "bridgetown"))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def middleman?(path)
|
|
59
|
+
path.join("config.rb").file? && gemfile_includes?(path, "middleman")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def procfile?(path)
|
|
63
|
+
path.join("Procfile.dev").file? || path.join("Procfile").file?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def gemfile_includes?(path, gem_name)
|
|
67
|
+
gemfile = path.join("Gemfile")
|
|
68
|
+
return false unless gemfile.file?
|
|
69
|
+
|
|
70
|
+
gemfile.read.match?(/gem\s+["']#{Regexp.escape(gem_name)}["']/)
|
|
71
|
+
rescue SystemCallError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
# Hostname composition: {variant}.{service}.{app}.{tld}
|
|
5
|
+
#
|
|
6
|
+
# Each axis is independent. The web service is bare (myapp.localhost);
|
|
7
|
+
# any other service prefixes (api.myapp.localhost). A variant prefixes
|
|
8
|
+
# everything (fix-ui.myapp.localhost, fix-ui.api.myapp.localhost).
|
|
9
|
+
# An explicit full hostname bypasses composition entirely.
|
|
10
|
+
module Hostname
|
|
11
|
+
DEFAULT_TLD = "localhost"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Build hostnames for every configured TLD.
|
|
16
|
+
def build(app:, tlds: [DEFAULT_TLD], service: nil, variant: nil)
|
|
17
|
+
tld_list = Array(tlds).flatten.compact
|
|
18
|
+
tld_list = [DEFAULT_TLD] if tld_list.empty?
|
|
19
|
+
tld_list.map { |tld| compose(app: app, tld: tld, service: service, variant: variant) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def compose(app:, tld:, service: nil, variant: nil)
|
|
23
|
+
parts = [app.to_s]
|
|
24
|
+
# The web service is bare (myapp.localhost); every other service
|
|
25
|
+
# prefixes (api.myapp.localhost). "web" is the default service.
|
|
26
|
+
svc = service.to_s
|
|
27
|
+
parts.unshift(svc) if !svc.empty? && svc != "web"
|
|
28
|
+
parts.unshift(variant.to_s) if variant && !variant.to_s.empty?
|
|
29
|
+
"#{parts.join(".")}.#{tld}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def url(hostname, port:, tls:)
|
|
33
|
+
scheme = tls ? "https" : "http"
|
|
34
|
+
default_port = tls ? 443 : 80
|
|
35
|
+
suffix = (port == default_port) ? "" : ":#{port}"
|
|
36
|
+
"#{scheme}://#{hostname}#{suffix}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Strip an explicit :port from a Host header for routing.
|
|
40
|
+
def strip_port(authority)
|
|
41
|
+
authority.to_s.split(":").first.to_s.downcase
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/yamine/hosts.rb
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "resolv"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Yamine
|
|
7
|
+
# /etc/hosts sync for Safari + custom TLDs (.localhost resolves natively
|
|
8
|
+
# in Chrome/Firefox/Edge; Safari uses the system resolver).
|
|
9
|
+
# Same managed-block approach as portless hosts.ts.
|
|
10
|
+
module Hosts
|
|
11
|
+
BEGIN_MARKER = "# --- yamine begin ---"
|
|
12
|
+
END_MARKER = "# --- yamine end ---"
|
|
13
|
+
PATH = "/etc/hosts"
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def managed_block(hostnames)
|
|
18
|
+
lines = [BEGIN_MARKER]
|
|
19
|
+
hostnames.uniq.sort.each { |h| lines << "127.0.0.1 #{h}" }
|
|
20
|
+
lines << END_MARKER
|
|
21
|
+
"#{lines.join("\n")}\n"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def read(path = PATH)
|
|
25
|
+
File.read(path)
|
|
26
|
+
rescue SystemCallError
|
|
27
|
+
""
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def managed_hostnames(content = read)
|
|
31
|
+
inside = false
|
|
32
|
+
names = []
|
|
33
|
+
content.each_line do |line|
|
|
34
|
+
inside = true if line.strip == BEGIN_MARKER
|
|
35
|
+
next unless inside
|
|
36
|
+
break if line.strip == END_MARKER
|
|
37
|
+
|
|
38
|
+
parts = line.split
|
|
39
|
+
names.concat(parts[1..]) if parts.first == "127.0.0.1"
|
|
40
|
+
end
|
|
41
|
+
names
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sync(hostnames, path = PATH)
|
|
45
|
+
content = read(path)
|
|
46
|
+
block = managed_block(hostnames)
|
|
47
|
+
if content.include?(BEGIN_MARKER)
|
|
48
|
+
updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, block)
|
|
49
|
+
else
|
|
50
|
+
updated = "#{content.rstrip}\n#{block}"
|
|
51
|
+
end
|
|
52
|
+
File.write(path, updated)
|
|
53
|
+
true
|
|
54
|
+
rescue SystemCallError
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# True when /etc/hosts already carries exactly the managed block for
|
|
59
|
+
# these hostnames. The root service install syncs under elevation;
|
|
60
|
+
# plain re-runs of setup must not fail trying to rewrite it
|
|
61
|
+
# unprivileged when nothing changed.
|
|
62
|
+
def synced?(hostnames, path = PATH)
|
|
63
|
+
read(path).include?(managed_block(hostnames))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def clean(path = PATH)
|
|
67
|
+
content = read(path)
|
|
68
|
+
updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, "")
|
|
69
|
+
File.write(path, updated)
|
|
70
|
+
true
|
|
71
|
+
rescue SystemCallError
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolves?(hostname)
|
|
76
|
+
Timeout.timeout(2) { Resolv.getaddress(hostname) }
|
|
77
|
+
true
|
|
78
|
+
rescue Resolv::ResolvError, SystemCallError, Timeout::Error
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def unresolved(hostnames)
|
|
83
|
+
hostnames.reject { |h| resolves?(h) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Yamine
|
|
8
|
+
# Zero-flag app name inference.
|
|
9
|
+
#
|
|
10
|
+
# Order: yamine.json "name" -> Rails module in config/application.rb ->
|
|
11
|
+
# gemspec name -> package.json name (for hybrid apps) -> git root basename
|
|
12
|
+
# -> directory basename. First non-empty sanitized name wins.
|
|
13
|
+
module Inference
|
|
14
|
+
CONFIG_FILENAME = "yamine.json"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Returns [name, source].
|
|
19
|
+
def infer(cwd = Dir.pwd)
|
|
20
|
+
from_config(cwd) ||
|
|
21
|
+
from_rails_module(cwd) ||
|
|
22
|
+
from_gemspec(cwd) ||
|
|
23
|
+
from_package_json(cwd) ||
|
|
24
|
+
from_git_root(cwd) ||
|
|
25
|
+
from_directory(cwd)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def from_config(cwd)
|
|
29
|
+
path = File.join(cwd, CONFIG_FILENAME)
|
|
30
|
+
return nil unless File.file?(path)
|
|
31
|
+
|
|
32
|
+
parsed = JSON.parse(File.read(path))
|
|
33
|
+
name = parsed["name"] || parsed.dig("apps", ".", "name")
|
|
34
|
+
return nil if name.nil? || name.strip.empty?
|
|
35
|
+
|
|
36
|
+
[Sanitize.hostname_label(name), "yamine.json"]
|
|
37
|
+
rescue JSON::ParserError, SystemCallError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Myapp::Application -> myapp (walks up for config/application.rb).
|
|
42
|
+
def from_rails_module(cwd)
|
|
43
|
+
dir = Pathname.new(cwd)
|
|
44
|
+
until dir.root?
|
|
45
|
+
candidate = dir.join("config", "application.rb")
|
|
46
|
+
if candidate.file?
|
|
47
|
+
mod = parse_rails_module(candidate.read)
|
|
48
|
+
return [Sanitize.hostname_label(mod), "config/application.rb"] if mod
|
|
49
|
+
end
|
|
50
|
+
dir = dir.parent
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def parse_rails_module(source)
|
|
56
|
+
match = source.match(/module\s+([A-Z][A-Za-z0-9_]*)/)
|
|
57
|
+
return nil unless match
|
|
58
|
+
|
|
59
|
+
# CamelCase with digit runs -> kebab: Rails8Min => rails-8-min,
|
|
60
|
+
# MyApp => my-app. Underscores become hyphens as well.
|
|
61
|
+
match[1].gsub(/([a-z0-9])([A-Z])/, '\1-\2')
|
|
62
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
63
|
+
.gsub(/([a-zA-Z])(\d)/, '\1-\2')
|
|
64
|
+
.gsub(/(\d)([a-zA-Z])/, '\1-\2')
|
|
65
|
+
.tr("_", "-").downcase
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# First *.gemspec with a name in cwd (non-recursive, top level only).
|
|
69
|
+
def from_gemspec(cwd)
|
|
70
|
+
Dir.glob(File.join(cwd, "*.gemspec")).sort.each do |path|
|
|
71
|
+
name = parse_gemspec_name(File.read(path))
|
|
72
|
+
next if name.nil? || name.empty?
|
|
73
|
+
|
|
74
|
+
base = name.split("/").last
|
|
75
|
+
labeled = Sanitize.hostname_label(base)
|
|
76
|
+
return [labeled, File.basename(path)] unless labeled.empty?
|
|
77
|
+
end
|
|
78
|
+
nil
|
|
79
|
+
rescue SystemCallError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_gemspec_name(source)
|
|
84
|
+
match = source.match(/\.name\s*=\s*["']([^"']+)["']/)
|
|
85
|
+
match && match[1]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def from_package_json(cwd)
|
|
89
|
+
dir = Pathname.new(cwd)
|
|
90
|
+
until dir.root?
|
|
91
|
+
pkg = dir.join("package.json")
|
|
92
|
+
if pkg.file?
|
|
93
|
+
parsed = JSON.parse(pkg.read)
|
|
94
|
+
raw = parsed["name"]
|
|
95
|
+
if raw.is_a?(String) && !raw.empty?
|
|
96
|
+
base = raw.sub(%r{\A@[^/]+/}, "")
|
|
97
|
+
labeled = Sanitize.hostname_label(base)
|
|
98
|
+
return [labeled, "package.json"] unless labeled.empty?
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
dir = dir.parent
|
|
102
|
+
end
|
|
103
|
+
nil
|
|
104
|
+
rescue JSON::ParserError, SystemCallError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def from_git_root(cwd)
|
|
109
|
+
root = git_root(cwd)
|
|
110
|
+
return nil unless root
|
|
111
|
+
|
|
112
|
+
labeled = Sanitize.hostname_label(File.basename(root))
|
|
113
|
+
labeled.empty? ? nil : [labeled, "git root"]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def from_directory(cwd)
|
|
117
|
+
labeled = Sanitize.hostname_label(File.basename(File.expand_path(cwd)))
|
|
118
|
+
raise Error, "Could not infer a project name from #{cwd}" if labeled.empty?
|
|
119
|
+
|
|
120
|
+
[labeled, "directory name"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def git_root(cwd)
|
|
124
|
+
out, status = Open3.capture2("git", "rev-parse", "--show-toplevel",
|
|
125
|
+
chdir: cwd, err: File::NULL)
|
|
126
|
+
return out.strip if status.success? && !out.strip.empty?
|
|
127
|
+
|
|
128
|
+
walk_up_for_git(cwd)
|
|
129
|
+
rescue SystemCallError, ArgumentError
|
|
130
|
+
walk_up_for_git(cwd)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def walk_up_for_git(cwd)
|
|
134
|
+
dir = Pathname.new(File.expand_path(cwd))
|
|
135
|
+
until dir.root?
|
|
136
|
+
return dir.to_s if dir.join(".git").exist?
|
|
137
|
+
dir = dir.parent
|
|
138
|
+
end
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
data/lib/yamine/log.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Yamine
|
|
6
|
+
# Size-based log rotation. Nothing rotated today: proxy.log grows
|
|
7
|
+
# forever and every managed boot appends to the app log — a Rails app
|
|
8
|
+
# with HMR polling plus a long-lived daemon eventually fills the disk,
|
|
9
|
+
# and ENOSPC on the socket dir looks like our bug. Rotate before write.
|
|
10
|
+
module Log
|
|
11
|
+
# Rotate when the file exceeds this size; keep one generation.
|
|
12
|
+
MAX_BYTES = Integer(ENV.fetch("YAMINE_LOG_MAX_BYTES", 5 * 1024 * 1024))
|
|
13
|
+
KEEP_GENERATIONS = 1
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Open path for appending, rotating first if oversize. Returns the
|
|
18
|
+
# open File so spawn(out:) can take it directly.
|
|
19
|
+
def open_append(path, max_bytes: MAX_BYTES)
|
|
20
|
+
rotate(path, max_bytes: max_bytes)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
22
|
+
File.open(path, "a")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def rotate(path, max_bytes: MAX_BYTES)
|
|
26
|
+
return unless File.file?(path)
|
|
27
|
+
return unless File.size(path) > max_bytes
|
|
28
|
+
|
|
29
|
+
KEEP_GENERATIONS.downto(1) do |gen|
|
|
30
|
+
src = gen == 1 ? path : "#{path}.#{gen - 1}"
|
|
31
|
+
dst = "#{path}.#{gen}"
|
|
32
|
+
FileUtils.mv(src, dst, force: true) if File.file?(src)
|
|
33
|
+
end
|
|
34
|
+
rescue SystemCallError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Bytes under dir (state dir or app log dir), for doctor reporting.
|
|
39
|
+
def disk_usage(dir)
|
|
40
|
+
total = 0
|
|
41
|
+
Dir.glob(File.join(dir, "**", "*")).each do |f|
|
|
42
|
+
total += File.size(f) if File.file?(f)
|
|
43
|
+
rescue SystemCallError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
total
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def human_bytes(bytes)
|
|
50
|
+
if bytes >= 1024 * 1024
|
|
51
|
+
format("%.1f MB", bytes.to_f / (1024 * 1024))
|
|
52
|
+
elsif bytes >= 1024
|
|
53
|
+
format("%.1f KB", bytes.to_f / 1024)
|
|
54
|
+
else
|
|
55
|
+
"#{bytes} B"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Yamine
|
|
7
|
+
# File ownership across the root/user boundary.
|
|
8
|
+
#
|
|
9
|
+
# The sudo-spawned daemon and the root LaunchDaemon both write into
|
|
10
|
+
# the invoking user's state dir. The first root-owned file lands,
|
|
11
|
+
# the unprivileged CLI can no longer register routes — and the
|
|
12
|
+
# failure looks like corruption, not permissions. Every root write
|
|
13
|
+
# path calls fix() so the tree stays user-owned.
|
|
14
|
+
module Ownership
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# The user behind sudo, or nil when not elevated.
|
|
18
|
+
def invoking_user
|
|
19
|
+
sudo_user = ENV["SUDO_USER"]
|
|
20
|
+
return nil if sudo_user.nil? || sudo_user.empty?
|
|
21
|
+
|
|
22
|
+
Etc.getpwnam(sudo_user)
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Chown path (recursively for dirs) to the invoking user. No-op
|
|
28
|
+
# when not running as root or when the user cannot be resolved.
|
|
29
|
+
def fix(*paths)
|
|
30
|
+
user = invoking_user
|
|
31
|
+
return unless user
|
|
32
|
+
return unless Process.uid.zero?
|
|
33
|
+
|
|
34
|
+
paths.each do |path|
|
|
35
|
+
begin
|
|
36
|
+
if File.directory?(path) && !File.symlink?(path)
|
|
37
|
+
FileUtils.chown_R(user.uid, user.gid, path)
|
|
38
|
+
else
|
|
39
|
+
FileUtils.chown(user.uid, user.gid, path)
|
|
40
|
+
end
|
|
41
|
+
rescue SystemCallError, ArgumentError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Fresh state files a root proxy creates: routes, pid/port/tls
|
|
48
|
+
# markers and the log. Called after ensure_dir in daemon boot paths.
|
|
49
|
+
def chown_state_dir(dir)
|
|
50
|
+
fix(dir)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/yamine/ports.rb
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Yamine
|
|
6
|
+
# Ephemeral TCP ports for run-mode backends (4000-4999, portless range).
|
|
7
|
+
# Random-first then sequential; WHATWG blocked ports skipped.
|
|
8
|
+
module Ports
|
|
9
|
+
MIN_PORT = 4000
|
|
10
|
+
MAX_PORT = 4999
|
|
11
|
+
RANDOM_ATTEMPTS = 50
|
|
12
|
+
|
|
13
|
+
# Browsers refuse these (WHATWG fetch "bad port" list); Next.js too.
|
|
14
|
+
BLOCKED = [0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42,
|
|
15
|
+
43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113,
|
|
16
|
+
115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512,
|
|
17
|
+
513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601,
|
|
18
|
+
636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190,
|
|
19
|
+
5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697,
|
|
20
|
+
10080].to_h { |p| [p, true] }.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def free?(port)
|
|
25
|
+
server = TCPServer.new("127.0.0.1", port)
|
|
26
|
+
server.close
|
|
27
|
+
true
|
|
28
|
+
rescue SystemCallError
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find_free(min: MIN_PORT, max: MAX_PORT)
|
|
33
|
+
raise Error, "min (#{min}) must be <= max (#{max})" if min > max
|
|
34
|
+
|
|
35
|
+
RANDOM_ATTEMPTS.times do
|
|
36
|
+
port = min + rand(max - min + 1)
|
|
37
|
+
return port if !BLOCKED[port] && free?(port)
|
|
38
|
+
end
|
|
39
|
+
(min..max).each do |port|
|
|
40
|
+
return port if !BLOCKED[port] && free?(port)
|
|
41
|
+
end
|
|
42
|
+
raise Error, "No free port found in range #{min}-#{max}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yamine
|
|
4
|
+
# Procfile.dev multi-process support: parse every line, classify each
|
|
5
|
+
# process as HTTP (gets a .localhost URL) or background (supervised,
|
|
6
|
+
# no URL), and boot them all with one command.
|
|
7
|
+
#
|
|
8
|
+
# Classification is deliberately permissive (portless lesson: proxy by
|
|
9
|
+
# default): a process is background only when its NAME says so
|
|
10
|
+
# (worker/job/sidekiq/watch/tunnel/build/css) or an yamine.json
|
|
11
|
+
# override says so. Everything else is HTTP. A misclassified worker
|
|
12
|
+
# harmlessly gets an unvisited route; a misclassified server with NO
|
|
13
|
+
# route is a broken dev day — so the bias is toward HTTP, and the boot
|
|
14
|
+
# banner always prints the classification so the fix is obvious.
|
|
15
|
+
#
|
|
16
|
+
# The Procfile stays canonical: Heroku, Docker, and plain
|
|
17
|
+
# `foreman start` keep working. yamine.json only overrides
|
|
18
|
+
# classification, ports, and env — it never replaces the Procfile.
|
|
19
|
+
module Procfile
|
|
20
|
+
HTTP = :http
|
|
21
|
+
BACKGROUND = :background
|
|
22
|
+
|
|
23
|
+
# Name fragments that mark a background process. Matched against
|
|
24
|
+
# the process NAME (left of the colon), not the command.
|
|
25
|
+
BACKGROUND_HINTS = %w[
|
|
26
|
+
worker job sidekiq solid_queue mission_control
|
|
27
|
+
watch tailwind css esbuild vite assets
|
|
28
|
+
tunnel cloudflared ngrok expose
|
|
29
|
+
build compile
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
COMPOUND = /&&|\|\||[|;]/.freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Parsed line: {name, command, compound?}. Compound lines (shell
|
|
37
|
+
# operators) cannot be safely injected with PORT — refused loudly
|
|
38
|
+
# by the caller, never silently rewritten.
|
|
39
|
+
Line = Struct.new(:name, :command, :compound, keyword_init: true)
|
|
40
|
+
|
|
41
|
+
def parse_file(path)
|
|
42
|
+
lines = File.readlines(path, chomp: true)
|
|
43
|
+
entries = []
|
|
44
|
+
lines.each do |line|
|
|
45
|
+
stripped = line.strip
|
|
46
|
+
next if stripped.empty? || stripped.start_with?("#")
|
|
47
|
+
next unless stripped.include?(":")
|
|
48
|
+
|
|
49
|
+
name, cmd = stripped.split(":", 2).map(&:strip)
|
|
50
|
+
next if name.nil? || name.empty? || cmd.nil? || cmd.empty?
|
|
51
|
+
|
|
52
|
+
entries << Line.new(name: name, command: cmd, compound: cmd.match?(COMPOUND))
|
|
53
|
+
end
|
|
54
|
+
entries
|
|
55
|
+
rescue SystemCallError
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find_file(dir = Dir.pwd)
|
|
60
|
+
%w[Procfile.dev Procfile].each do |name|
|
|
61
|
+
path = File.join(dir, name)
|
|
62
|
+
return path if File.file?(path)
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Classify one process. Overrides win: {"processes": {"worker":
|
|
68
|
+
# {"type": "background"}}} in yamine.json. Otherwise background
|
|
69
|
+
# on name hints, HTTP for everything else.
|
|
70
|
+
def classify(name, overrides: {})
|
|
71
|
+
override = overrides[name] || overrides[name.to_s]
|
|
72
|
+
if override.is_a?(Hash) && override["type"]
|
|
73
|
+
return override["type"].to_s == "background" ? BACKGROUND : HTTP
|
|
74
|
+
end
|
|
75
|
+
lowered = name.to_s.downcase
|
|
76
|
+
return BACKGROUND if BACKGROUND_HINTS.any? { |hint| lowered.include?(hint) }
|
|
77
|
+
|
|
78
|
+
HTTP
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Per-process overrides from yamine.json "processes" map:
|
|
82
|
+
# {"web": {"type": "http", "port": 3000, "env": {...}}, ...}.
|
|
83
|
+
# Unknown keys warn; the Procfile stays the source of the command.
|
|
84
|
+
def load_overrides(dir = Dir.pwd)
|
|
85
|
+
config = Config.load(dir)
|
|
86
|
+
return {} unless config
|
|
87
|
+
|
|
88
|
+
procs = config.data["processes"]
|
|
89
|
+
return {} unless procs.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
procs
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Split command string into argv for spawn (no shell). Returns nil
|
|
95
|
+
# for compound lines the caller must refuse.
|
|
96
|
+
def to_argv(command)
|
|
97
|
+
return nil if command.match?(COMPOUND)
|
|
98
|
+
|
|
99
|
+
split_command(command)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Minimal shell-word split (quotes + backslash escapes), matching
|
|
103
|
+
# Config.split_command semantics for Procfile lines.
|
|
104
|
+
def split_command(command)
|
|
105
|
+
args = []
|
|
106
|
+
current = +""
|
|
107
|
+
in_single = false
|
|
108
|
+
in_double = false
|
|
109
|
+
escaped = false
|
|
110
|
+
command.each_char do |ch|
|
|
111
|
+
if escaped
|
|
112
|
+
current << ch
|
|
113
|
+
escaped = false
|
|
114
|
+
next
|
|
115
|
+
end
|
|
116
|
+
if ch == "\\" && !in_single
|
|
117
|
+
escaped = true
|
|
118
|
+
next
|
|
119
|
+
end
|
|
120
|
+
if ch == "'" && !in_double
|
|
121
|
+
in_single = !in_single
|
|
122
|
+
elsif ch == '"' && !in_single
|
|
123
|
+
in_double = !in_double
|
|
124
|
+
elsif ch.match?(/\s/) && !in_single && !in_double
|
|
125
|
+
unless current.empty?
|
|
126
|
+
args << current
|
|
127
|
+
current = +""
|
|
128
|
+
end
|
|
129
|
+
else
|
|
130
|
+
current << ch
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
args << current unless current.empty?
|
|
134
|
+
args
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|