boringbuilder 0.1.0.alpha.1

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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +38 -0
  3. data/CHANGELOG.md +13 -0
  4. data/LICENSE +21 -0
  5. data/README.md +131 -0
  6. data/SECURITY.md +25 -0
  7. data/SOUL.md +40 -0
  8. data/STYLE.md +33 -0
  9. data/docs/artifacts.md +106 -0
  10. data/docs/building.md +175 -0
  11. data/docs/caching.md +53 -0
  12. data/exe/boringbuilder +6 -0
  13. data/lib/boringbuilder/artifact.rb +87 -0
  14. data/lib/boringbuilder/boring_cache.rb +126 -0
  15. data/lib/boringbuilder/build_progress.rb +90 -0
  16. data/lib/boringbuilder/builder.rb +33 -0
  17. data/lib/boringbuilder/cli.rb +234 -0
  18. data/lib/boringbuilder/config_file.rb +40 -0
  19. data/lib/boringbuilder/configuration.rb +165 -0
  20. data/lib/boringbuilder/errors.rb +9 -0
  21. data/lib/boringbuilder/exporter.rb +144 -0
  22. data/lib/boringbuilder/exporters/asset.rb +7 -0
  23. data/lib/boringbuilder/exporters/boring_cache.rb +83 -0
  24. data/lib/boringbuilder/exporters/local.rb +27 -0
  25. data/lib/boringbuilder/exporters/receipt.rb +11 -0
  26. data/lib/boringbuilder/exporters/resolver.rb +57 -0
  27. data/lib/boringbuilder/exporters.rb +7 -0
  28. data/lib/boringbuilder/initializer.rb +180 -0
  29. data/lib/boringbuilder/mise.rb +88 -0
  30. data/lib/boringbuilder/pipeline.rb +156 -0
  31. data/lib/boringbuilder/project.rb +264 -0
  32. data/lib/boringbuilder/project_plan.rb +78 -0
  33. data/lib/boringbuilder/rails_build.rb +6 -0
  34. data/lib/boringbuilder/railtie.rb +9 -0
  35. data/lib/boringbuilder/result.rb +33 -0
  36. data/lib/boringbuilder/ruby_application.rb +142 -0
  37. data/lib/boringbuilder/ruby_build.rb +212 -0
  38. data/lib/boringbuilder/runtime.rb +89 -0
  39. data/lib/boringbuilder/tasks/boringbuilder.rake +40 -0
  40. data/lib/boringbuilder/version.rb +5 -0
  41. data/lib/boringbuilder.rb +45 -0
  42. metadata +108 -0
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BoringBuilder
6
+ class Mise
7
+ DEFAULT_IMAGE = BoringCache::BUILD_IMAGE
8
+ CACHE_PATH = "/mise/cache"
9
+ INSTALL_COMMAND = %w[mise install].freeze
10
+ METADATA_FILES = %w[mise.toml .mise.toml .tool-versions mise.lock].freeze
11
+ ENVIRONMENT = {
12
+ "MISE_CACHE_DIR" => CACHE_PATH,
13
+ "MISE_DATA_DIR" => "/mise",
14
+ "MISE_INSTALLS_DIR" => "/mise/installs",
15
+ "MISE_SHIMS_DIR" => "/mise/shims",
16
+ "PATH" => "/mise/shims:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
17
+ }.freeze
18
+
19
+ attr_reader :project, :pipeline
20
+
21
+ def initialize(project, pipeline)
22
+ @project = project
23
+ @pipeline = pipeline
24
+ end
25
+
26
+ def container(tools: {}, image: DEFAULT_IMAGE, workdir: project.application_path)
27
+ base = with_project_metadata(pipeline.container(image), at: workdir)
28
+ install(base, tools: tools, workdir: workdir).with_directory(workdir, pipeline.source)
29
+ end
30
+
31
+ def install(container, tools:, workdir:, only: nil)
32
+ container = with_environment(container)
33
+ container = container.with_workdir(workdir)
34
+ .with_new_file("/etc/mise/config.toml", system_config(tools))
35
+ command = [*INSTALL_COMMAND, *Array(only).map(&:to_s)]
36
+ pipeline.run(
37
+ container,
38
+ command,
39
+ cache: "mise",
40
+ at: CACHE_PATH,
41
+ entry: "mise",
42
+ workdir: workdir,
43
+ name: "Prepare Mise toolchain"
44
+ )
45
+ end
46
+
47
+ def with_project_metadata(container, at: project.application_path)
48
+ METADATA_FILES.reduce(container) do |result, name|
49
+ path = project.root.join(name)
50
+ path.file? ? result.with_file("#{at}/#{name}", project.source_file(pipeline.client, name)) : result
51
+ end
52
+ end
53
+
54
+ def with_environment(container)
55
+ ENVIRONMENT.reduce(container) do |result, (name, value)|
56
+ result.with_env_variable(name, value)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def system_config(tools)
63
+ declarations = normalize_tools(tools).sort.map do |name, version|
64
+ "#{JSON.generate(name)} = #{JSON.generate(version)}"
65
+ end
66
+
67
+ <<~TOML
68
+ [tools]
69
+ #{declarations.join("\n")}
70
+
71
+ [settings]
72
+ paranoid = true
73
+ TOML
74
+ end
75
+
76
+ def normalize_tools(tools)
77
+ tools.to_h.each_with_object({}) do |(name, version), normalized|
78
+ name = name.to_s
79
+ version = version.to_s
80
+ raise ConfigurationError, "Mise tool names and versions cannot be empty" if name.empty? || version.empty?
81
+
82
+ normalized[name] = version
83
+ end
84
+ rescue NoMethodError, TypeError
85
+ raise ConfigurationError, "Mise tools must be a name-to-version map"
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class Pipeline
5
+ attr_reader :project, :client, :progress
6
+
7
+ def initialize(project, client, environment: ENV, progress: BuildProgress.silent)
8
+ @project = project
9
+ @client = client
10
+ @environment = environment
11
+ @progress = progress
12
+ end
13
+
14
+ def build
15
+ container = yield self
16
+ raise ConfigurationError, "The custom pipeline must return a Dagger container" unless container
17
+
18
+ sync_step(container, "Build application")
19
+ end
20
+
21
+ def container(image = nil)
22
+ result = client.container(container_options)
23
+ image ? result.from(image) : result
24
+ end
25
+
26
+ def source
27
+ project.source(client)
28
+ end
29
+
30
+ def mise(tools: {}, image: Mise::DEFAULT_IMAGE, workdir: project.application_path)
31
+ Mise.new(project, self).container(tools: tools, image: image, workdir: workdir)
32
+ end
33
+
34
+ def run(container, command, cache:, at: nil, entry: nil, workdir: project.application_path, sharing: :LOCKED,
35
+ name: "Run cached step", **options)
36
+ arguments = Array(command).map(&:to_s)
37
+ mounts = normalize_mounts(cache, at, entry)
38
+ validate_step!(arguments, mounts)
39
+
40
+ remote_cache = BoringCache.new(project, client, environment: @environment)
41
+ container = container.with_workdir(workdir.to_s)
42
+
43
+ result, executed = if remote_cache.enabled?
44
+ container = remote_cache.prepare(container, workdir: workdir)
45
+ command = if entry
46
+ remote_cache.wrap(arguments, entry: entry.to_s)
47
+ else
48
+ remote_cache.wrap_mounts(arguments, mounts)
49
+ end
50
+ command_result = execute(container, command, options)
51
+ final_result = entry ? command_result : remove_directories(command_result, mounts.values)
52
+ [final_result, command_result]
53
+ else
54
+ run_with_local_cache(container, arguments, mounts, sharing, options)
55
+ end
56
+
57
+ sync_step(result, name, output: -> { command_output(executed) })
58
+ end
59
+
60
+ def exec(container, command, name:, workdir: project.application_path, **options)
61
+ arguments = Array(command).map(&:to_s)
62
+ raise ConfigurationError, "step command cannot be empty" if arguments.empty?
63
+
64
+ result = execute(container.with_workdir(workdir.to_s), arguments, options)
65
+ sync_step(result, name, output: -> { command_output(result) })
66
+ end
67
+
68
+ def step(name, container)
69
+ result = yield container
70
+ raise ConfigurationError, "The pipeline step must return a Dagger container" unless result
71
+
72
+ sync_step(result, name)
73
+ end
74
+
75
+ private
76
+
77
+ def container_options
78
+ project.configuration.platform ? { platform: project.configuration.platform } : {}
79
+ end
80
+
81
+ def normalize_mounts(cache, at, entry)
82
+ return { cache.to_s => at.to_s } unless cache.is_a?(Hash)
83
+
84
+ raise ConfigurationError, "at and entry apply only to a single cache" if at || entry
85
+
86
+ cache.to_h { |name, path| [name.to_s, path.to_s] }
87
+ end
88
+
89
+ def mount_directly(container, mounts, sharing)
90
+ mounts.reduce(container) do |step, (name, path)|
91
+ step.with_mounted_cache(path, cache_volume(name), sharing: sharing)
92
+ end
93
+ end
94
+
95
+ def run_with_local_cache(container, arguments, mounts, sharing, options)
96
+ executed = execute(mount_directly(container, mounts, sharing), arguments, options)
97
+ [remove_local_mounts(executed, mounts), executed]
98
+ end
99
+
100
+ def execute(container, command, options)
101
+ options.empty? ? container.with_exec(command) : container.with_exec(command, options)
102
+ end
103
+
104
+ def command_output(container)
105
+ [container.stdout, container.stderr].map(&:strip).reject(&:empty?).join("\n")
106
+ end
107
+
108
+ def remove_local_mounts(container, mounts)
109
+ mounts.reduce(container) do |step, (_name, path)|
110
+ step.chain_operation("withoutMount", { "path" => path })
111
+ .with_exec(["rm", "-rf", path])
112
+ end
113
+ end
114
+
115
+ def remove_directories(container, paths)
116
+ container.with_exec(["rm", "-rf", *paths])
117
+ end
118
+
119
+ def cache_volume(name)
120
+ client.cache_volume("boringbuilder-#{project.app_name}-#{name}")
121
+ end
122
+
123
+ def sync_step(container, name, output: nil)
124
+ return container if container.equal?(@last_synced_container)
125
+
126
+ @last_synced_container = progress.step(name) do
127
+ progress.write { output.call } if output
128
+ container.sync
129
+ end
130
+ end
131
+
132
+ def validate_step!(arguments, mounts)
133
+ raise ConfigurationError, "cached command cannot be empty" if arguments.empty?
134
+ raise ConfigurationError, "at is required for a single cache" if mounts.values == [""]
135
+ raise ConfigurationError, "at least one cache is required" if mounts.empty?
136
+
137
+ mounts.each do |name, path|
138
+ validate_cache_name!(name)
139
+ validate_cache_path!(path)
140
+ end
141
+ end
142
+
143
+ def validate_cache_name!(name)
144
+ return if name.match?(/\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/)
145
+
146
+ raise ConfigurationError, "cache name may contain only letters, numbers, '.', '_', and '-'"
147
+ end
148
+
149
+ def validate_cache_path!(path)
150
+ clean = Pathname.new(path).cleanpath.to_s
151
+ return if path.start_with?("/") && path != "/" && clean == path
152
+
153
+ raise ConfigurationError, "cache path must be a clean absolute path other than '/'"
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class Project
5
+ DEFAULT_EXCLUDES = %w[
6
+ .env
7
+ .env.*
8
+ .git
9
+ coverage
10
+ dist
11
+ log
12
+ node_modules
13
+ tmp
14
+ vendor/bundle
15
+ ].freeze
16
+
17
+ attr_reader :configuration
18
+
19
+ def initialize(configuration)
20
+ @configuration = configuration
21
+ end
22
+
23
+ def validate!
24
+ configuration.validate!
25
+ validate_lockfile!
26
+ validate_application!
27
+ validate_native_javascript!
28
+ Exporters::Resolver.new(self, nil).validate!
29
+ self
30
+ end
31
+
32
+ def container(client, progress: BuildProgress.silent)
33
+ return Pipeline.new(self, client, progress: progress).build(&configuration.pipeline) if custom_pipeline?
34
+
35
+ builder = rails? ? RailsBuild : RubyBuild
36
+ builder.new(self, client, progress: progress).container
37
+ end
38
+
39
+ def plan
40
+ ProjectPlan.new(self).to_h
41
+ end
42
+
43
+ def root
44
+ configuration.root
45
+ end
46
+
47
+ def app_name
48
+ @app_name ||= root.basename.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
49
+ end
50
+
51
+ def rails?
52
+ application.rails?
53
+ end
54
+
55
+ def ruby?
56
+ application.ruby?
57
+ end
58
+
59
+ def hanami?
60
+ application.hanami?
61
+ end
62
+
63
+ def framework
64
+ application.framework
65
+ end
66
+
67
+ def locked?
68
+ root.join("Gemfile.lock").file?
69
+ end
70
+
71
+ def ruby_version
72
+ @ruby_version ||= mise_ruby_version || tool_versions_ruby_version || ruby_version_file ||
73
+ gemfile_ruby_version || locked_ruby_version || RUBY_VERSION
74
+ end
75
+
76
+ def assets?
77
+ return configuration.assets unless configuration.assets.nil?
78
+
79
+ root.join("app/assets").directory? || root.join("config/manifest.js").file?
80
+ end
81
+
82
+ def bootsnap?
83
+ return configuration.bootsnap unless configuration.bootsnap.nil?
84
+
85
+ root.join("Gemfile.lock").read.match?(/^ bootsnap \(/)
86
+ end
87
+
88
+ def artifact_paths
89
+ artifact.entries.filter_map { |entry| entry.source if entry.origin == :container }.map(&:to_s)
90
+ end
91
+
92
+ def application_path
93
+ application.path
94
+ end
95
+
96
+ def application_user
97
+ application.user
98
+ end
99
+
100
+ def runtime_environment
101
+ application.runtime_environment
102
+ end
103
+
104
+ def runtime_command
105
+ application.runtime_command
106
+ end
107
+
108
+ def runtime_entrypoint
109
+ application.runtime_entrypoint
110
+ end
111
+
112
+ def runtime_port
113
+ application.runtime_port
114
+ end
115
+
116
+ def web?
117
+ application.web?
118
+ end
119
+
120
+ def artifact
121
+ @artifact ||= begin
122
+ configured = configuration.artifact
123
+ add_configured_entries(configured)
124
+ add_default_entries(configured) if configured.empty?
125
+ configured
126
+ end
127
+ end
128
+
129
+ def output_path
130
+ return configuration.output if configuration.output
131
+
132
+ suffix = {
133
+ directory: "rootfs",
134
+ tar: "tar",
135
+ tar_zst: "tar.zst",
136
+ oci: "oci.tar",
137
+ docker: "docker.tar"
138
+ }.fetch(configuration.format)
139
+ platform = configuration.platform&.tr("/", "-") || "native"
140
+ root.join("dist", "#{app_name}-#{platform}.#{suffix}")
141
+ end
142
+
143
+ def source(client)
144
+ client.host.directory(root.to_s, exclude: DEFAULT_EXCLUDES, gitignore: true)
145
+ end
146
+
147
+ def source_file(client, path)
148
+ client.host.file(root.join(path).to_s)
149
+ end
150
+
151
+ def custom_pipeline?
152
+ !configuration.pipeline.nil?
153
+ end
154
+
155
+ private
156
+
157
+ def validate_lockfile!
158
+ return if custom_pipeline? || !ruby? || locked?
159
+
160
+ raise ConfigurationError, "Gemfile.lock is required for a reproducible Ruby build"
161
+ end
162
+
163
+ def validate_application!
164
+ return if custom_pipeline? || ruby?
165
+
166
+ raise ConfigurationError, "No conventional Ruby application or custom pipeline found in #{root}"
167
+ end
168
+
169
+ def add_configured_entries(artifact)
170
+ configuration.paths.each do |mapping|
171
+ source, destination = parse_mapping(mapping)
172
+ artifact.directory(source, at: destination)
173
+ end
174
+ configuration.files.each do |mapping|
175
+ source, destination = parse_mapping(mapping)
176
+ artifact.file(source, at: destination)
177
+ end
178
+ configuration.host_paths.each do |mapping|
179
+ source, destination = parse_mapping(mapping, destination_required: true)
180
+ artifact.host_path(source, at: destination)
181
+ end
182
+ end
183
+
184
+ def add_default_entries(artifact)
185
+ paths = custom_pipeline? ? ["/"] : [application_path, "/usr/local", "/mise"]
186
+ paths.each { |path| artifact.directory(path) }
187
+ end
188
+
189
+ def parse_mapping(mapping, destination_required: false)
190
+ source, destination = mapping.to_s.split("=", 2)
191
+ if source.to_s.empty? || (destination_required && destination.to_s.empty?)
192
+ raise ConfigurationError, "Artifact mapping must be SOURCE=DESTINATION: #{mapping.inspect}"
193
+ end
194
+
195
+ [source, destination || source]
196
+ end
197
+
198
+ def validate_native_javascript!
199
+ return if custom_pipeline? || !root.join("package.json").file?
200
+
201
+ raise ConfigurationError, <<~MESSAGE.strip
202
+ package.json was found. Define config/boringbuilder.rb with a custom Dagger pipeline that installs the
203
+ application's JavaScript runtime.
204
+ MESSAGE
205
+ end
206
+
207
+ def application
208
+ @application ||= RubyApplication.new(root, configuration)
209
+ end
210
+
211
+ def ruby_version_file
212
+ path = root.join(".ruby-version")
213
+ return unless path.file?
214
+
215
+ normalize_ruby_version(path.read.strip)
216
+ end
217
+
218
+ def mise_ruby_version
219
+ path = %w[mise.toml .mise.toml].map { |name| root.join(name) }.find(&:file?)
220
+ return unless path
221
+
222
+ tools_section = false
223
+ path.each_line do |line|
224
+ value = line.strip.sub(/\s+#.*\z/, "")
225
+ if value.start_with?("[")
226
+ tools_section = value == "[tools]"
227
+ next
228
+ end
229
+ next unless tools_section
230
+
231
+ version = value[/\Aruby\s*=\s*["']([^"']+)["']\z/, 1]
232
+ return normalize_ruby_version(version) if version
233
+ end
234
+
235
+ nil
236
+ end
237
+
238
+ def tool_versions_ruby_version
239
+ path = root.join(".tool-versions")
240
+ return unless path.file?
241
+
242
+ version = path.each_line.filter_map { |line| line[/\Aruby\s+(\S+)/, 1] }.first
243
+ normalize_ruby_version(version) if version
244
+ end
245
+
246
+ def gemfile_ruby_version
247
+ path = root.join("Gemfile")
248
+ return unless path.file?
249
+
250
+ version = path.read[/^\s*ruby\s+["']([^"']+)["']/, 1]
251
+ normalize_ruby_version(version) if version
252
+ end
253
+
254
+ def normalize_ruby_version(version)
255
+ version.to_s.delete_prefix("ruby-")
256
+ end
257
+
258
+ def locked_ruby_version
259
+ return unless locked?
260
+
261
+ root.join("Gemfile.lock").read[/^RUBY VERSION\n\s+ruby ([^\s]+)/, 1]&.sub(/p\d+\z/, "")
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class ProjectPlan
5
+ attr_reader :project
6
+
7
+ def initialize(project)
8
+ @project = project
9
+ end
10
+
11
+ def to_h
12
+ identity.merge(build, artifact, delivery)
13
+ end
14
+
15
+ private
16
+
17
+ def identity
18
+ {
19
+ app: project.app_name,
20
+ root: project.root.to_s,
21
+ strategy: strategy,
22
+ framework: project.framework,
23
+ ruby_version: ruby_version
24
+ }
25
+ end
26
+
27
+ def build
28
+ {
29
+ platform: configuration.platform,
30
+ runtime: configuration.runtime,
31
+ format: configuration.format,
32
+ command: command,
33
+ port: port,
34
+ cache: BoringCache.new(project, nil).mode
35
+ }
36
+ end
37
+
38
+ def artifact
39
+ {
40
+ output: project.output_path.to_s,
41
+ artifact: project.artifact.to_a,
42
+ artifact_paths: project.artifact_paths,
43
+ exporters: Exporters::Resolver.new(project, nil).plan
44
+ }
45
+ end
46
+
47
+ def delivery
48
+ {
49
+ publish: configuration.publish,
50
+ load: configuration.load
51
+ }
52
+ end
53
+
54
+ def configuration
55
+ project.configuration
56
+ end
57
+
58
+ def strategy
59
+ return "custom" if project.custom_pipeline?
60
+
61
+ project.framework.to_s
62
+ end
63
+
64
+ def ruby_version
65
+ project.ruby? ? project.ruby_version : nil
66
+ end
67
+
68
+ def command
69
+ project.custom_pipeline? ? nil : project.runtime_command
70
+ end
71
+
72
+ def port
73
+ return if project.custom_pipeline? || !project.web?
74
+
75
+ project.runtime_port
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class RailsBuild < RubyBuild
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ load File.expand_path("tasks/boringbuilder.rake", __dir__)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BoringBuilder
4
+ Result = Data.define(:format, :path, :reference, :runtime, :platform, :artifact_id, :artifact_name) do
5
+ def initialize(format:, path:, reference:, runtime:, platform:, artifact_id: nil, artifact_name: nil)
6
+ super
7
+ end
8
+
9
+ def exported?
10
+ !path.nil?
11
+ end
12
+
13
+ def published?
14
+ !reference.nil?
15
+ end
16
+
17
+ def artifact_published?
18
+ !artifact_id.nil?
19
+ end
20
+
21
+ def to_h
22
+ {
23
+ format: format,
24
+ path: path,
25
+ reference: reference,
26
+ runtime: runtime,
27
+ platform: platform,
28
+ artifact_id: artifact_id,
29
+ artifact_name: artifact_name
30
+ }
31
+ end
32
+ end
33
+ end