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.
- checksums.yaml +7 -0
- data/AGENTS.md +38 -0
- data/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +131 -0
- data/SECURITY.md +25 -0
- data/SOUL.md +40 -0
- data/STYLE.md +33 -0
- data/docs/artifacts.md +106 -0
- data/docs/building.md +175 -0
- data/docs/caching.md +53 -0
- data/exe/boringbuilder +6 -0
- data/lib/boringbuilder/artifact.rb +87 -0
- data/lib/boringbuilder/boring_cache.rb +126 -0
- data/lib/boringbuilder/build_progress.rb +90 -0
- data/lib/boringbuilder/builder.rb +33 -0
- data/lib/boringbuilder/cli.rb +234 -0
- data/lib/boringbuilder/config_file.rb +40 -0
- data/lib/boringbuilder/configuration.rb +165 -0
- data/lib/boringbuilder/errors.rb +9 -0
- data/lib/boringbuilder/exporter.rb +144 -0
- data/lib/boringbuilder/exporters/asset.rb +7 -0
- data/lib/boringbuilder/exporters/boring_cache.rb +83 -0
- data/lib/boringbuilder/exporters/local.rb +27 -0
- data/lib/boringbuilder/exporters/receipt.rb +11 -0
- data/lib/boringbuilder/exporters/resolver.rb +57 -0
- data/lib/boringbuilder/exporters.rb +7 -0
- data/lib/boringbuilder/initializer.rb +180 -0
- data/lib/boringbuilder/mise.rb +88 -0
- data/lib/boringbuilder/pipeline.rb +156 -0
- data/lib/boringbuilder/project.rb +264 -0
- data/lib/boringbuilder/project_plan.rb +78 -0
- data/lib/boringbuilder/rails_build.rb +6 -0
- data/lib/boringbuilder/railtie.rb +9 -0
- data/lib/boringbuilder/result.rb +33 -0
- data/lib/boringbuilder/ruby_application.rb +142 -0
- data/lib/boringbuilder/ruby_build.rb +212 -0
- data/lib/boringbuilder/runtime.rb +89 -0
- data/lib/boringbuilder/tasks/boringbuilder.rake +40 -0
- data/lib/boringbuilder/version.rb +5 -0
- data/lib/boringbuilder.rb +45 -0
- metadata +108 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module BoringBuilder
|
|
6
|
+
class Configuration
|
|
7
|
+
FORMATS = %i[directory tar tar_zst oci docker].freeze
|
|
8
|
+
RUNTIMES = %i[auto apple docker].freeze
|
|
9
|
+
FORMAT_ALIASES = {
|
|
10
|
+
"tar.zst" => :tar_zst,
|
|
11
|
+
"tar-zst" => :tar_zst,
|
|
12
|
+
"tar_zst" => :tar_zst
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_accessor :root, :runtime, :platform, :format, :output,
|
|
16
|
+
:base_image, :paths, :files, :host_paths, :publish, :load,
|
|
17
|
+
:progress, :assets, :bootsnap, :artifact,
|
|
18
|
+
:command, :port, :exporter, :artifact_name,
|
|
19
|
+
:build_packages, :runtime_packages, :build_environment
|
|
20
|
+
attr_writer :pipeline
|
|
21
|
+
|
|
22
|
+
def initialize(root: Dir.pwd, runtime: :auto, platform: nil, format: :tar_zst,
|
|
23
|
+
output: nil, base_image: nil, paths: nil,
|
|
24
|
+
files: nil, host_paths: nil, publish: nil, load: nil,
|
|
25
|
+
progress: nil, assets: nil, bootsnap: nil,
|
|
26
|
+
artifact: nil, command: nil, port: nil,
|
|
27
|
+
exporter: :auto, artifact_name: nil, pipeline: nil,
|
|
28
|
+
build_packages: nil, runtime_packages: nil, build_environment: nil)
|
|
29
|
+
@root = Pathname.new(root).expand_path
|
|
30
|
+
@runtime = normalize_runtime(runtime)
|
|
31
|
+
@platform = platform
|
|
32
|
+
@format = normalize_format(format)
|
|
33
|
+
@output = output && Pathname.new(output).expand_path(@root)
|
|
34
|
+
@base_image = base_image
|
|
35
|
+
@paths = Array(paths).compact
|
|
36
|
+
@files = Array(files).compact
|
|
37
|
+
@host_paths = Array(host_paths).compact
|
|
38
|
+
@publish = publish
|
|
39
|
+
@load = load
|
|
40
|
+
@progress = progress
|
|
41
|
+
@assets = assets
|
|
42
|
+
@bootsnap = bootsnap
|
|
43
|
+
@artifact = artifact || Artifact.new(root: @root)
|
|
44
|
+
@command = command
|
|
45
|
+
@port = port
|
|
46
|
+
@exporter = normalize_exporter(exporter)
|
|
47
|
+
@artifact_name = artifact_name
|
|
48
|
+
@pipeline = pipeline
|
|
49
|
+
@build_packages = normalize_packages(build_packages)
|
|
50
|
+
@runtime_packages = normalize_packages(runtime_packages)
|
|
51
|
+
@build_environment = normalize_environment(build_environment)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def validate!
|
|
55
|
+
normalize!
|
|
56
|
+
validate_project_directory!
|
|
57
|
+
validate_option_combinations!
|
|
58
|
+
validate_port!
|
|
59
|
+
validate_artifact!
|
|
60
|
+
validate_output_path!
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def apply(options)
|
|
65
|
+
options.each do |name, value|
|
|
66
|
+
writer = "#{name}="
|
|
67
|
+
raise ConfigurationError, "Unknown configuration option: #{name}" unless respond_to?(writer)
|
|
68
|
+
|
|
69
|
+
public_send(writer, value)
|
|
70
|
+
end
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pipeline(&block)
|
|
75
|
+
@pipeline = block if block
|
|
76
|
+
@pipeline
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def validate_project_directory!
|
|
82
|
+
raise ConfigurationError, "Project directory does not exist: #{root}" unless root.directory?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_option_combinations!
|
|
86
|
+
raise ConfigurationError, "--push and --load cannot be used together" if publish && load
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_port!
|
|
90
|
+
raise ConfigurationError, "port must be a positive integer" if port && port <= 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_artifact!
|
|
94
|
+
raise ConfigurationError, "artifact must be a BoringBuilder::Artifact" unless artifact.is_a?(Artifact)
|
|
95
|
+
raise ConfigurationError, "pipeline must be callable" if pipeline && !pipeline.respond_to?(:call)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize!
|
|
99
|
+
@root = Pathname.new(root).expand_path
|
|
100
|
+
@runtime = normalize_runtime(runtime)
|
|
101
|
+
@format = normalize_format(format)
|
|
102
|
+
@output = output && Pathname.new(output).expand_path(root)
|
|
103
|
+
normalize_collection_options!
|
|
104
|
+
normalize_build_options!
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def normalize_collection_options!
|
|
108
|
+
@paths = Array(paths).compact
|
|
109
|
+
@files = Array(files).compact
|
|
110
|
+
@host_paths = Array(host_paths).compact
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def normalize_build_options!
|
|
114
|
+
@port = normalize_port(port) if port
|
|
115
|
+
@exporter = normalize_exporter(exporter)
|
|
116
|
+
@artifact_name = artifact_name&.to_s
|
|
117
|
+
@build_packages = normalize_packages(build_packages)
|
|
118
|
+
@runtime_packages = normalize_packages(runtime_packages)
|
|
119
|
+
@build_environment = normalize_environment(build_environment)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def normalize_runtime(value)
|
|
123
|
+
runtime = value.to_s.downcase.to_sym
|
|
124
|
+
return runtime if RUNTIMES.include?(runtime)
|
|
125
|
+
|
|
126
|
+
raise ConfigurationError, "Unknown runtime '#{value}'. Use auto, apple, or docker."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def normalize_format(value)
|
|
130
|
+
format = FORMAT_ALIASES.fetch(value.to_s, value.to_s.downcase.to_sym)
|
|
131
|
+
return format if FORMATS.include?(format)
|
|
132
|
+
|
|
133
|
+
raise ConfigurationError, "Unknown format '#{value}'. Use directory, tar, tar.zst, oci, or docker."
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def normalize_port(value)
|
|
137
|
+
Integer(value)
|
|
138
|
+
rescue ArgumentError, TypeError
|
|
139
|
+
raise ConfigurationError, "port must be a positive integer"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def normalize_exporter(value)
|
|
143
|
+
exporter = (value || :auto).to_sym
|
|
144
|
+
return exporter if %i[auto local boringcache].include?(exporter)
|
|
145
|
+
|
|
146
|
+
raise ConfigurationError, "exporter must be auto, local, or boringcache"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def normalize_packages(packages)
|
|
150
|
+
Array(packages).compact.map(&:to_s)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def normalize_environment(environment)
|
|
154
|
+
(environment || {}).to_h { |name, value| [name.to_s, value.to_s] }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_output_path!
|
|
158
|
+
return unless output
|
|
159
|
+
|
|
160
|
+
unsafe = output == Pathname.new("/") || output == root || output == Pathname.new(Dir.home).expand_path
|
|
161
|
+
unsafe ||= format == :directory && root.to_s.start_with?("#{output}/")
|
|
162
|
+
raise ConfigurationError, "Refusing unsafe output path: #{output}" if unsafe
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module BoringBuilder
|
|
7
|
+
class Exporter
|
|
8
|
+
PACKER_IMAGE = "alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"
|
|
9
|
+
|
|
10
|
+
attr_reader :project, :client, :container, :runtime, :exporters, :progress
|
|
11
|
+
|
|
12
|
+
def initialize(project, client, container, runtime:, command_runner: nil, exporters: nil,
|
|
13
|
+
progress: BuildProgress.silent)
|
|
14
|
+
@project = project
|
|
15
|
+
@client = client
|
|
16
|
+
@container = container
|
|
17
|
+
@runtime = runtime
|
|
18
|
+
@command_runner = command_runner || ->(*command) { Open3.capture3(*command) }
|
|
19
|
+
@exporters = exporters || Exporters::Resolver.new(project, client).destinations
|
|
20
|
+
@progress = progress
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call
|
|
24
|
+
exporters.each(&:validate!)
|
|
25
|
+
asset = build_asset unless exporters.empty?
|
|
26
|
+
receipt = exporters.reduce(Exporters::Receipt.new) do |result, adapter|
|
|
27
|
+
received = progress.step(export_step_name(adapter)) { adapter.export(asset) }
|
|
28
|
+
merge_receipts(result, received)
|
|
29
|
+
end
|
|
30
|
+
reference = export_reference
|
|
31
|
+
Result.new(
|
|
32
|
+
format: configuration.format,
|
|
33
|
+
path: receipt.path,
|
|
34
|
+
reference: reference,
|
|
35
|
+
runtime: runtime,
|
|
36
|
+
platform: configuration.platform,
|
|
37
|
+
artifact_id: receipt.artifact_id,
|
|
38
|
+
artifact_name: receipt.artifact_name
|
|
39
|
+
)
|
|
40
|
+
rescue DaggerRuby::DaggerError => e
|
|
41
|
+
raise ExportError, "Dagger could not export the build: #{e.message}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def configuration
|
|
47
|
+
project.configuration
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def merge_receipts(current, received)
|
|
51
|
+
Exporters::Receipt.new(
|
|
52
|
+
path: received.path || current.path,
|
|
53
|
+
artifact_id: received.artifact_id || current.artifact_id,
|
|
54
|
+
artifact_name: received.artifact_name || current.artifact_name
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def export_step_name(adapter)
|
|
59
|
+
return "Publish artifact to BoringCache" if adapter.name == :boringcache
|
|
60
|
+
|
|
61
|
+
"Export #{configuration.format.to_s.tr('_', '.')} artifact"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_asset
|
|
65
|
+
filename = project.output_path.basename.to_s
|
|
66
|
+
case configuration.format
|
|
67
|
+
when :directory
|
|
68
|
+
Exporters::Asset.new(kind: :directory, source: artifact_directory, filename: filename)
|
|
69
|
+
when :tar
|
|
70
|
+
Exporters::Asset.new(kind: :file, source: archive_file("artifact.tar", compression: nil), filename: filename)
|
|
71
|
+
when :tar_zst
|
|
72
|
+
Exporters::Asset.new(kind: :file, source: archive_file("artifact.tar.zst", compression: :zstd),
|
|
73
|
+
filename: filename)
|
|
74
|
+
when :oci, :docker
|
|
75
|
+
Exporters::Asset.new(kind: :file, source: container.as_tarball(media_types: media_types), filename: filename)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def export_reference
|
|
80
|
+
if configuration.publish
|
|
81
|
+
progress.step("Publish container image") do
|
|
82
|
+
container.publish(configuration.publish, media_types: media_types)
|
|
83
|
+
end
|
|
84
|
+
elsif configuration.load
|
|
85
|
+
progress.step("Load container image") { load_image }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load_image
|
|
90
|
+
return load_apple_image if runtime == :apple
|
|
91
|
+
|
|
92
|
+
container.export_image(configuration.load, media_types: media_types)
|
|
93
|
+
configuration.load
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def load_apple_image
|
|
97
|
+
Dir.mktmpdir("boringbuilder-load") do |directory|
|
|
98
|
+
archive = File.join(directory, "image.oci.tar")
|
|
99
|
+
container.export(archive, media_types: :OCIMediaTypes)
|
|
100
|
+
source = load_apple_archive(archive)
|
|
101
|
+
run_apple_command("container", "image", "tag", source, configuration.load)
|
|
102
|
+
end
|
|
103
|
+
configuration.load
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def load_apple_archive(archive)
|
|
107
|
+
output = run_apple_command("container", "image", "load", "--input", archive)
|
|
108
|
+
source = output[/\b(?:untagged@)?sha256:[0-9a-f]{64}\b/]
|
|
109
|
+
return source if source
|
|
110
|
+
|
|
111
|
+
raise ExportError, "Apple Container loaded the image without returning a taggable digest"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def run_apple_command(*command)
|
|
115
|
+
stdout, stderr, status = @command_runner.call(*command)
|
|
116
|
+
output = [stdout, stderr].map(&:strip).reject(&:empty?).join("\n")
|
|
117
|
+
return output if status.success?
|
|
118
|
+
|
|
119
|
+
raise ExportError, "#{command.first(3).join(' ')} failed: #{output}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def artifact_directory
|
|
123
|
+
@artifact_directory ||= project.artifact.export_directory(client, container)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def archive_file(filename, compression:)
|
|
127
|
+
packer = client.container.from(PACKER_IMAGE).with_exec(%w[apk add --no-cache tar zstd])
|
|
128
|
+
packer = packer.with_mounted_directory("/artifact", artifact_directory)
|
|
129
|
+
tar_path = compression == :zstd ? "/artifact.tar" : "/#{filename}"
|
|
130
|
+
packer = packer.with_exec(
|
|
131
|
+
[
|
|
132
|
+
"tar", "--sort=name", "--mtime=@0", "--owner=0", "--group=0", "--numeric-owner",
|
|
133
|
+
"--pax-option=delete=atime,delete=ctime", "-C", "/artifact", "-cf", tar_path, "."
|
|
134
|
+
]
|
|
135
|
+
)
|
|
136
|
+
packer = packer.with_exec(["zstd", "-q", "-T1", tar_path, "-o", "/#{filename}"]) if compression == :zstd
|
|
137
|
+
packer.file("/#{filename}")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def media_types
|
|
141
|
+
configuration.format == :docker ? :DockerMediaTypes : :OCIMediaTypes
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BoringBuilder
|
|
6
|
+
module Exporters
|
|
7
|
+
class BoringCache
|
|
8
|
+
attr_reader :project, :client, :cache
|
|
9
|
+
|
|
10
|
+
def initialize(project, client, environment: ENV)
|
|
11
|
+
@project = project
|
|
12
|
+
@client = client
|
|
13
|
+
@cache = ::BoringBuilder::BoringCache.new(project, client, environment: environment)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def name = :boringcache
|
|
17
|
+
|
|
18
|
+
def validate!
|
|
19
|
+
if configuration.artifact_name&.strip == ""
|
|
20
|
+
raise ConfigurationError, "BoringCache Artifact name cannot be empty"
|
|
21
|
+
end
|
|
22
|
+
raise ConfigurationError, "The boringcache exporter requires BORINGCACHE_SAVE_TOKEN" unless cache.save_token?
|
|
23
|
+
return self if cache.workspace_configured?
|
|
24
|
+
|
|
25
|
+
raise ConfigurationError,
|
|
26
|
+
"The boringcache exporter requires a workspace environment variable or .boringcache.toml"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def export(asset)
|
|
30
|
+
validate!
|
|
31
|
+
publisher = client.container.from(cache.cli_image)
|
|
32
|
+
publisher = cache.prepare_artifact_publisher(publisher)
|
|
33
|
+
path, publisher = mount_asset(publisher, asset)
|
|
34
|
+
output = publisher.with_exec(publish_command(path, asset.kind)).stdout
|
|
35
|
+
artifact = JSON.parse(output).fetch("artifact")
|
|
36
|
+
validate_artifact!(artifact)
|
|
37
|
+
Receipt.new(artifact_id: artifact.fetch("id"), artifact_name: artifact.fetch("name"))
|
|
38
|
+
rescue JSON::ParserError, KeyError => e
|
|
39
|
+
raise ExportError, "BoringCache Artifact returned an invalid receipt: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def configuration
|
|
45
|
+
project.configuration
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def artifact_name
|
|
49
|
+
configuration.artifact_name || [project.app_name, platform_name, format_name].join("-")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def platform_name
|
|
53
|
+
(configuration.platform || "native").tr("/", "-")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_name
|
|
57
|
+
configuration.format.to_s.tr("_", "-")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mount_asset(publisher, asset)
|
|
61
|
+
if asset.kind == :directory
|
|
62
|
+
["/artifact", publisher.with_mounted_directory("/artifact", asset.source)]
|
|
63
|
+
else
|
|
64
|
+
path = "/artifact/#{asset.filename}"
|
|
65
|
+
[path, publisher.with_file(path, asset.source)]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def publish_command(path, kind)
|
|
70
|
+
command = ["boringcache", "artifact", "push", path, "--name", artifact_name,
|
|
71
|
+
"--include-hidden", "--json"]
|
|
72
|
+
command.push("--compression", "none") unless kind == :directory
|
|
73
|
+
command
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def validate_artifact!(artifact)
|
|
77
|
+
return if artifact.fetch("id", "").start_with?("art_") && artifact.fetch("status", nil) == "ready"
|
|
78
|
+
|
|
79
|
+
raise ExportError, "BoringCache Artifact did not return a ready artifact"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module BoringBuilder
|
|
6
|
+
module Exporters
|
|
7
|
+
class Local
|
|
8
|
+
attr_reader :project
|
|
9
|
+
|
|
10
|
+
def initialize(project)
|
|
11
|
+
@project = project
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def name = :local
|
|
15
|
+
|
|
16
|
+
def validate! = self
|
|
17
|
+
|
|
18
|
+
def export(asset)
|
|
19
|
+
output = project.output_path
|
|
20
|
+
FileUtils.mkdir_p(output.dirname)
|
|
21
|
+
options = asset.kind == :directory ? { wipe: true } : {}
|
|
22
|
+
asset.source.export(output.to_s, **options)
|
|
23
|
+
Receipt.new(path: output.to_s)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BoringBuilder
|
|
4
|
+
module Exporters
|
|
5
|
+
class Resolver
|
|
6
|
+
ADAPTERS = {
|
|
7
|
+
local: Local,
|
|
8
|
+
boringcache: BoringCache
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :project, :client, :cache
|
|
12
|
+
|
|
13
|
+
def initialize(project, client, environment: ENV)
|
|
14
|
+
@project = project
|
|
15
|
+
@client = client
|
|
16
|
+
@environment = environment
|
|
17
|
+
@cache = ::BoringBuilder::BoringCache.new(project, client, environment: environment)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def destinations
|
|
21
|
+
names = [primary_name].compact
|
|
22
|
+
names << :local if configuration.output && !names.include?(:local)
|
|
23
|
+
names.map { |name| adapter(name) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def plan
|
|
27
|
+
destinations.map(&:name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def validate!
|
|
31
|
+
destinations.each(&:validate!)
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def configuration
|
|
38
|
+
project.configuration
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def primary_name
|
|
42
|
+
return configuration.exporter unless configuration.exporter == :auto
|
|
43
|
+
return :boringcache if cache.artifact_publishable?
|
|
44
|
+
return if configuration.publish || configuration.load
|
|
45
|
+
|
|
46
|
+
:local
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def adapter(name)
|
|
50
|
+
adapter = ADAPTERS.fetch(name)
|
|
51
|
+
return adapter.new(project) if name == :local
|
|
52
|
+
|
|
53
|
+
adapter.new(project, client, environment: @environment)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module BoringBuilder
|
|
7
|
+
class Initializer
|
|
8
|
+
TEMPLATES = %i[auto ruby node rust go generic].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :root, :requested_template
|
|
11
|
+
|
|
12
|
+
def initialize(root, template: :auto)
|
|
13
|
+
@root = Pathname.new(root).expand_path
|
|
14
|
+
@requested_template = template.to_s.downcase.to_sym
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(force: false)
|
|
18
|
+
validate!
|
|
19
|
+
raise ConfigurationError, "#{path} already exists; pass --force to replace it" if path.exist? && !force
|
|
20
|
+
|
|
21
|
+
FileUtils.mkdir_p(path.dirname)
|
|
22
|
+
path.write(template)
|
|
23
|
+
path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def template_name
|
|
27
|
+
return requested_template unless requested_template == :auto
|
|
28
|
+
return :ruby if root.join("Gemfile").file?
|
|
29
|
+
return :node if root.join("package.json").file?
|
|
30
|
+
return :rust if root.join("Cargo.toml").file?
|
|
31
|
+
return :go if root.join("go.mod").file?
|
|
32
|
+
|
|
33
|
+
:generic
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def path
|
|
39
|
+
root.join(ConfigFile::DEFAULT_PATH)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def validate!
|
|
43
|
+
raise ConfigurationError, "Project directory does not exist: #{root}" unless root.directory?
|
|
44
|
+
return if TEMPLATES.include?(requested_template)
|
|
45
|
+
|
|
46
|
+
raise ConfigurationError, "Unknown template '#{requested_template}'. Use auto, ruby, node, rust, go, or generic."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def template
|
|
50
|
+
send("#{template_name}_template")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ruby_template
|
|
54
|
+
<<~RUBY
|
|
55
|
+
# frozen_string_literal: true
|
|
56
|
+
|
|
57
|
+
# Rails, Hanami, Rack, and other Bundler applications use BoringBuilder's
|
|
58
|
+
# built-in Mise-powered pipeline. Add only the application-specific choices.
|
|
59
|
+
BoringBuilder.configure do |config|
|
|
60
|
+
# config.build_packages += %w[libexample-dev]
|
|
61
|
+
# config.runtime_packages += %w[libexample1]
|
|
62
|
+
end
|
|
63
|
+
RUBY
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def node_template
|
|
67
|
+
install = root.join("package-lock.json").file? ? "%w[npm ci]" : "%w[npm install]"
|
|
68
|
+
build = node_build?
|
|
69
|
+
artifact = if build
|
|
70
|
+
'config.artifact.directory("/app/dist", at: "/app")'
|
|
71
|
+
else
|
|
72
|
+
'config.artifact.directory("/app")'
|
|
73
|
+
end
|
|
74
|
+
build_step = if build
|
|
75
|
+
"\n pipeline.exec(app, %w[npm run build], name: \"Build application\")"
|
|
76
|
+
else
|
|
77
|
+
"\n app"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
<<~RUBY
|
|
81
|
+
# frozen_string_literal: true
|
|
82
|
+
|
|
83
|
+
BoringBuilder.configure do |config|
|
|
84
|
+
#{artifact}
|
|
85
|
+
|
|
86
|
+
config.pipeline do |pipeline|
|
|
87
|
+
app = pipeline.mise(tools: { node: "24" }, workdir: "/app")
|
|
88
|
+
|
|
89
|
+
app = pipeline.run(
|
|
90
|
+
app,
|
|
91
|
+
#{install},
|
|
92
|
+
cache: "npm-downloads",
|
|
93
|
+
at: "/root/.npm/_cacache",
|
|
94
|
+
workdir: "/app",
|
|
95
|
+
name: "Install dependencies"
|
|
96
|
+
)
|
|
97
|
+
#{build_step}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
RUBY
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def rust_template
|
|
104
|
+
<<~RUBY
|
|
105
|
+
# frozen_string_literal: true
|
|
106
|
+
|
|
107
|
+
BoringBuilder.configure do |config|
|
|
108
|
+
config.artifact.directory("/app/target/release", at: "/app")
|
|
109
|
+
|
|
110
|
+
config.pipeline do |pipeline|
|
|
111
|
+
app = pipeline.mise(tools: { rust: "stable" }, workdir: "/app")
|
|
112
|
+
|
|
113
|
+
pipeline.run(
|
|
114
|
+
app,
|
|
115
|
+
%w[cargo build --release],
|
|
116
|
+
cache: {
|
|
117
|
+
"cargo-registry" => "/root/.cargo/registry",
|
|
118
|
+
"cargo-git" => "/root/.cargo/git"
|
|
119
|
+
},
|
|
120
|
+
workdir: "/app",
|
|
121
|
+
name: "Build application"
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
RUBY
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def go_template
|
|
129
|
+
<<~RUBY
|
|
130
|
+
# frozen_string_literal: true
|
|
131
|
+
|
|
132
|
+
BoringBuilder.configure do |config|
|
|
133
|
+
config.artifact.directory("/app/dist", at: "/app")
|
|
134
|
+
|
|
135
|
+
config.pipeline do |pipeline|
|
|
136
|
+
app = pipeline.mise(tools: { go: "1" }, workdir: "/app")
|
|
137
|
+
|
|
138
|
+
pipeline.run(
|
|
139
|
+
app,
|
|
140
|
+
%w[go build -o /app/dist/app .],
|
|
141
|
+
cache: {
|
|
142
|
+
"go-modules" => "/root/go/pkg/mod",
|
|
143
|
+
"go-build" => "/root/.cache/go-build"
|
|
144
|
+
},
|
|
145
|
+
workdir: "/app",
|
|
146
|
+
name: "Build application"
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
RUBY
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def generic_template
|
|
154
|
+
<<~RUBY
|
|
155
|
+
# frozen_string_literal: true
|
|
156
|
+
|
|
157
|
+
BoringBuilder.configure do |config|
|
|
158
|
+
config.artifact.directory("/app")
|
|
159
|
+
|
|
160
|
+
config.pipeline do |pipeline|
|
|
161
|
+
# Add only the tools the application needs, then its build steps.
|
|
162
|
+
pipeline.mise(
|
|
163
|
+
tools: {
|
|
164
|
+
# "python" => "3"
|
|
165
|
+
},
|
|
166
|
+
workdir: "/app"
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
RUBY
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def node_build?
|
|
174
|
+
scripts = JSON.parse(root.join("package.json").read)["scripts"]
|
|
175
|
+
scripts.is_a?(Hash) && scripts.key?("build")
|
|
176
|
+
rescue JSON::ParserError, TypeError
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|