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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module BoringBuilder
|
|
6
|
+
class Artifact
|
|
7
|
+
Entry = Data.define(:origin, :kind, :source, :destination) do
|
|
8
|
+
def to_h
|
|
9
|
+
{ origin: origin, kind: kind, source: source.to_s, destination: destination }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :root, :entries
|
|
14
|
+
|
|
15
|
+
def initialize(root: Dir.pwd)
|
|
16
|
+
@root = Pathname.new(root).expand_path
|
|
17
|
+
@entries = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def directory(source, at: source)
|
|
21
|
+
add(:container, :directory, source, at)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def file(source, at: source)
|
|
25
|
+
add(:container, :file, source, at)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def host_path(source, at:)
|
|
29
|
+
path = Pathname.new(source).expand_path(root)
|
|
30
|
+
kind = path.file? ? :file : :directory
|
|
31
|
+
raise ConfigurationError, "Host artifact path does not exist: #{path}" unless path.exist?
|
|
32
|
+
|
|
33
|
+
add(:host, kind, path, at)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def empty?
|
|
37
|
+
entries.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def full_container_root?
|
|
41
|
+
entries.one? && entries.first.origin == :container && entries.first.kind == :directory &&
|
|
42
|
+
entries.first.source == "/" && entries.first.destination == "/"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def export_directory(client, container)
|
|
46
|
+
return container.directory("/") if full_container_root?
|
|
47
|
+
|
|
48
|
+
entries.reduce(client.directory) do |directory, entry|
|
|
49
|
+
destination = entry.destination.delete_prefix("/")
|
|
50
|
+
source = source_for(entry, client, container)
|
|
51
|
+
|
|
52
|
+
if entry.kind == :file
|
|
53
|
+
directory.with_file(destination, source)
|
|
54
|
+
else
|
|
55
|
+
directory.with_directory(destination, source)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_a
|
|
61
|
+
entries.map(&:to_h)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def add(origin, kind, source, destination)
|
|
67
|
+
source = normalize_container_path(source, "source") if origin == :container
|
|
68
|
+
destination = normalize_container_path(destination, "destination")
|
|
69
|
+
entries << Entry.new(origin: origin, kind: kind, source: source, destination: destination)
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_container_path(path, label)
|
|
74
|
+
value = path.to_s
|
|
75
|
+
clean = Pathname.new(value).cleanpath.to_s
|
|
76
|
+
return clean if value.start_with?("/") && clean == value
|
|
77
|
+
|
|
78
|
+
raise ConfigurationError, "Artifact #{label} must be a clean absolute path: #{path.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def source_for(entry, client, container)
|
|
82
|
+
source = entry.source.to_s
|
|
83
|
+
owner = entry.origin == :container ? container : client.host
|
|
84
|
+
entry.kind == :file ? owner.file(source) : owner.directory(source)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BoringBuilder
|
|
4
|
+
class BoringCache
|
|
5
|
+
CLI_IMAGE = "ghcr.io/boringcache/base:bookworm-v1.19.4@" \
|
|
6
|
+
"sha256:84d1e168a50fd086ecd4fdb2fd96f689982a6f3c61454acd8c7189e336f8f3ae"
|
|
7
|
+
BUILD_IMAGE = "ghcr.io/boringcache/base:bookworm-build-v1.19.4@" \
|
|
8
|
+
"sha256:df4461581214c0052157041238a67b8da420e1399e4024bc857c68c74a9721cb"
|
|
9
|
+
TOKEN_NAMES = %w[BORINGCACHE_RESTORE_TOKEN BORINGCACHE_SAVE_TOKEN].freeze
|
|
10
|
+
ENVIRONMENT_NAMES = %w[BORINGCACHE_API_URL BORINGCACHE_DEFAULT_WORKSPACE BORINGCACHE_WORKSPACE].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :project, :client, :environment
|
|
13
|
+
|
|
14
|
+
def initialize(project, client, environment: ENV)
|
|
15
|
+
@project = project
|
|
16
|
+
@client = client
|
|
17
|
+
@environment = environment
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enabled?
|
|
21
|
+
token? && (workspace? || project_config?)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def prepare(container, workdir: project.application_path)
|
|
25
|
+
return container unless enabled?
|
|
26
|
+
|
|
27
|
+
container = container.with_file("/usr/local/bin/boringcache", cli_file, permissions: 0o755)
|
|
28
|
+
container = container.with_file("#{workdir}/.boringcache.toml", project_config_file) if project_config?
|
|
29
|
+
container = with_environment(container)
|
|
30
|
+
with_secrets(container)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def wrap(command, entry: "bundler")
|
|
34
|
+
return command unless enabled?
|
|
35
|
+
|
|
36
|
+
wrapped(command, "--entry", entry)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def wrap_mount(command, tag:, path:)
|
|
40
|
+
wrap_mounts(command, tag => path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wrap_mounts(command, mounts)
|
|
44
|
+
return command unless enabled?
|
|
45
|
+
|
|
46
|
+
cache_arguments = mounts.flat_map { |tag, path| ["--manual-entry", "#{tag}:#{path}"] }
|
|
47
|
+
wrapped(command, *cache_arguments)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def mode
|
|
51
|
+
enabled? ? :boringcache : :local
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def artifact_publishable?
|
|
55
|
+
save_token? && (workspace? || project_config?)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prepare_artifact_publisher(container, workdir: "/workspace")
|
|
59
|
+
container = container.with_workdir(workdir)
|
|
60
|
+
container = container.with_file("#{workdir}/.boringcache.toml", project_config_file) if project_config?
|
|
61
|
+
container = with_environment(container)
|
|
62
|
+
with_secrets(container)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def cli_image
|
|
66
|
+
environment.fetch("BORINGBUILDER_BORINGCACHE_IMAGE", CLI_IMAGE)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def save_token?
|
|
70
|
+
!value("BORINGCACHE_SAVE_TOKEN").nil?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def workspace_configured?
|
|
74
|
+
workspace? || project_config?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def wrapped(command, *cache_arguments)
|
|
80
|
+
arguments = ["boringcache", "run", *cache_arguments, "--no-git"]
|
|
81
|
+
arguments << "--read-only" unless save_token?
|
|
82
|
+
[*arguments, "--", *command]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def token?
|
|
86
|
+
TOKEN_NAMES.any? { |name| value(name) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def workspace?
|
|
90
|
+
%w[BORINGCACHE_DEFAULT_WORKSPACE BORINGCACHE_WORKSPACE].any? { |name| value(name) }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def project_config?
|
|
94
|
+
project.root.join(".boringcache.toml").file?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def project_config_file
|
|
98
|
+
project.source_file(client, ".boringcache.toml")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def cli_file
|
|
102
|
+
options = project.configuration.platform ? { platform: project.configuration.platform } : {}
|
|
103
|
+
client.container(options).from(cli_image).file("/usr/local/bin/boringcache")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def with_environment(container)
|
|
107
|
+
ENVIRONMENT_NAMES.reduce(container) do |result, name|
|
|
108
|
+
value(name) ? result.with_env_variable(name, value(name)) : result
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def with_secrets(container)
|
|
113
|
+
TOKEN_NAMES.reduce(container) do |result, name|
|
|
114
|
+
value(name) ? result.with_secret_variable(name, secret(name)) : result
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def secret(name)
|
|
119
|
+
client.set_secret(name, value(name))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def value(name)
|
|
123
|
+
environment[name].to_s.empty? ? nil : environment[name]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BoringBuilder
|
|
4
|
+
class BuildProgress
|
|
5
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
6
|
+
|
|
7
|
+
def self.silent
|
|
8
|
+
@silent ||= new(enabled: false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(out: $stdout, enabled: true, animated: false)
|
|
12
|
+
@out = out
|
|
13
|
+
@enabled = enabled
|
|
14
|
+
@animated = animated
|
|
15
|
+
@step = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def step(name)
|
|
19
|
+
return yield unless @enabled
|
|
20
|
+
|
|
21
|
+
number = next_step
|
|
22
|
+
started_at = monotonic_time
|
|
23
|
+
@out.puts "##{number} #{name}"
|
|
24
|
+
@active_spinner = start_spinner(number, started_at)
|
|
25
|
+
result = yield
|
|
26
|
+
stop_active_spinner
|
|
27
|
+
@out.puts "##{number} DONE #{elapsed_since(started_at)}"
|
|
28
|
+
result
|
|
29
|
+
rescue StandardError
|
|
30
|
+
stop_active_spinner
|
|
31
|
+
@out.puts "##{number} ERROR #{elapsed_since(started_at)}" if number
|
|
32
|
+
raise
|
|
33
|
+
ensure
|
|
34
|
+
stop_active_spinner
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write(output = nil)
|
|
38
|
+
return unless @enabled
|
|
39
|
+
|
|
40
|
+
output = yield if block_given?
|
|
41
|
+
stop_active_spinner
|
|
42
|
+
text = output.to_s
|
|
43
|
+
text.each_line { |line| @out.print " #{line}" }
|
|
44
|
+
@out.puts unless text.empty? || text.end_with?("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def next_step
|
|
50
|
+
@step += 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def start_spinner(number, started_at)
|
|
54
|
+
return unless @animated
|
|
55
|
+
|
|
56
|
+
Thread.new do
|
|
57
|
+
frame = 0
|
|
58
|
+
loop do
|
|
59
|
+
@out.print "\r##{number} #{SPINNER_FRAMES.fetch(frame % SPINNER_FRAMES.length)} #{elapsed_since(started_at)}"
|
|
60
|
+
@out.flush
|
|
61
|
+
frame += 1
|
|
62
|
+
sleep 0.1
|
|
63
|
+
end
|
|
64
|
+
rescue IOError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def stop_spinner(thread)
|
|
70
|
+
return unless thread&.alive?
|
|
71
|
+
|
|
72
|
+
thread.kill
|
|
73
|
+
thread.join
|
|
74
|
+
@out.print "\r\e[2K"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def stop_active_spinner
|
|
78
|
+
stop_spinner(@active_spinner)
|
|
79
|
+
@active_spinner = nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def monotonic_time
|
|
83
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def elapsed_since(started_at)
|
|
87
|
+
format("%.1fs", monotonic_time - started_at)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dagger_ruby"
|
|
4
|
+
|
|
5
|
+
module BoringBuilder
|
|
6
|
+
class Builder
|
|
7
|
+
attr_reader :configuration, :progress
|
|
8
|
+
|
|
9
|
+
def initialize(configuration, progress: BuildProgress.silent)
|
|
10
|
+
@configuration = configuration
|
|
11
|
+
@progress = progress
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def build
|
|
15
|
+
project = Project.new(configuration).validate!
|
|
16
|
+
runtime = Runtime.new(configuration.runtime)
|
|
17
|
+
resolved_runtime = runtime.resolve
|
|
18
|
+
dagger_configuration = DaggerRuby::Config.new(
|
|
19
|
+
runtime: runtime.dagger_runtime(resolved_runtime),
|
|
20
|
+
progress: configuration.progress,
|
|
21
|
+
silent: configuration.progress.nil?
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
DaggerRuby.connection(dagger_configuration) do |client|
|
|
25
|
+
container = project.container(client, progress: progress)
|
|
26
|
+
Exporter.new(project, client, container, runtime: resolved_runtime, progress: progress).call
|
|
27
|
+
end
|
|
28
|
+
rescue DaggerRuby::DaggerError => e
|
|
29
|
+
message = e.message.sub(/\s*\[traceparent:[^\]]+\]\s*\z/, "")
|
|
30
|
+
raise BuildError, "Dagger build failed: #{message}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module BoringBuilder
|
|
8
|
+
class CLI
|
|
9
|
+
def self.start(argv = ARGV, **)
|
|
10
|
+
new(argv, **).start
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(argv, out: $stdout, err: $stderr, builder_class: Builder, environment: ENV)
|
|
14
|
+
@argv = argv.dup
|
|
15
|
+
@out = out
|
|
16
|
+
@err = err
|
|
17
|
+
@builder_class = builder_class
|
|
18
|
+
@environment = environment
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run
|
|
22
|
+
return root_help if @argv.empty? || %w[help --help -h].include?(@argv.first)
|
|
23
|
+
return version if %w[version --version -v].include?(@argv.first)
|
|
24
|
+
|
|
25
|
+
command = @argv.shift
|
|
26
|
+
case command
|
|
27
|
+
when "build" then build
|
|
28
|
+
when "doctor" then doctor
|
|
29
|
+
when "init" then initialize_project
|
|
30
|
+
else raise ConfigurationError, "Unknown command '#{command}'. Use build, doctor, init, or version."
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def start
|
|
35
|
+
run
|
|
36
|
+
rescue BoringBuilder::Error, OptionParser::ParseError => e
|
|
37
|
+
@err.puts "boringbuilder: #{e.message}"
|
|
38
|
+
1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def version
|
|
44
|
+
@out.puts BoringBuilder::VERSION
|
|
45
|
+
0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def root_help
|
|
49
|
+
@out.puts <<~HELP
|
|
50
|
+
Usage: boringbuilder COMMAND [options]
|
|
51
|
+
|
|
52
|
+
Commands:
|
|
53
|
+
build [PROJECT] Build and export a deployment artifact
|
|
54
|
+
doctor Check the selected container runtime
|
|
55
|
+
init [PROJECT] Generate a Dagger Ruby build recipe
|
|
56
|
+
version Print the gem version
|
|
57
|
+
HELP
|
|
58
|
+
0
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build
|
|
62
|
+
options = {}
|
|
63
|
+
config_file = :auto
|
|
64
|
+
dry_run = false
|
|
65
|
+
json = false
|
|
66
|
+
parser = build_parser(options, ->(value) { config_file = value }, -> { dry_run = true }) { json = true }
|
|
67
|
+
help_requested = catch(:boringbuilder_help) do
|
|
68
|
+
parser.parse!(@argv)
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
return 0 if help_requested
|
|
72
|
+
raise OptionParser::InvalidArgument, "expected at most one PROJECT directory" if @argv.length > 1
|
|
73
|
+
|
|
74
|
+
configuration = BoringBuilder.configuration(root: @argv.first || Dir.pwd, config: config_file, **options)
|
|
75
|
+
project = Project.new(configuration).validate!
|
|
76
|
+
if dry_run
|
|
77
|
+
@out.puts JSON.pretty_generate(project.plan)
|
|
78
|
+
return 0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
print_build_start(project, json: json)
|
|
82
|
+
result = @builder_class.new(configuration, progress: build_progress(configuration, json: json)).build
|
|
83
|
+
json ? @out.puts(JSON.generate(result.to_h)) : print_result(result)
|
|
84
|
+
0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_parser(options, config_file, dry_run, &json)
|
|
88
|
+
OptionParser.new do |parser|
|
|
89
|
+
parser.banner = "Usage: boringbuilder build [options] [PROJECT]"
|
|
90
|
+
add_runtime_options(parser, options)
|
|
91
|
+
add_build_options(parser, options)
|
|
92
|
+
add_export_options(parser, options)
|
|
93
|
+
add_rails_options(parser, options)
|
|
94
|
+
parser.on("--config FILE", "Load a Ruby build recipe (default: config/boringbuilder.rb)") do |value|
|
|
95
|
+
config_file.call(value)
|
|
96
|
+
end
|
|
97
|
+
parser.on("--no-config", "Ignore config/boringbuilder.rb") { config_file.call(false) }
|
|
98
|
+
parser.on("--dry-run", "Print the resolved build plan") { dry_run.call }
|
|
99
|
+
parser.on("--json", "Print the build result as JSON") { json.call }
|
|
100
|
+
parser.on("-h", "--help", "Show this help") do
|
|
101
|
+
@out.puts(parser)
|
|
102
|
+
throw :boringbuilder_help, true
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def add_runtime_options(parser, options)
|
|
108
|
+
parser.on("--runtime NAME", "auto, apple, or docker") { |value| options[:runtime] = value }
|
|
109
|
+
parser.on("--platform PLATFORM", "Target platform, for example linux/amd64") do |value|
|
|
110
|
+
options[:platform] = value
|
|
111
|
+
end
|
|
112
|
+
parser.on("--progress MODE", %w[auto plain tty], "Dagger progress: auto, plain, or tty") do |value|
|
|
113
|
+
options[:progress] = value
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def add_build_options(parser, options)
|
|
118
|
+
parser.on("--command COMMAND", "Override the built-in Ruby image command") { |value| options[:command] = value }
|
|
119
|
+
parser.on("--port PORT", Integer, "Expose PORT for a built-in Ruby web application") do |value|
|
|
120
|
+
options[:port] = value
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def add_export_options(parser, options)
|
|
125
|
+
parser.on("--format FORMAT", "directory, tar, tar.zst, oci, or docker") { |value| options[:format] = value }
|
|
126
|
+
parser.on("-o", "--output PATH", "Local artifact destination") { |value| options[:output] = value }
|
|
127
|
+
parser.on("--path SOURCE[=DESTINATION]",
|
|
128
|
+
"Export a container directory, optionally remapped (repeatable)") do |value|
|
|
129
|
+
(options[:paths] ||= []) << value
|
|
130
|
+
end
|
|
131
|
+
parser.on("--file SOURCE[=DESTINATION]", "Export a container file, optionally remapped (repeatable)") do |value|
|
|
132
|
+
(options[:files] ||= []) << value
|
|
133
|
+
end
|
|
134
|
+
parser.on("--host-path SOURCE=DESTINATION", "Add an explicit host file or directory (repeatable)") do |value|
|
|
135
|
+
(options[:host_paths] ||= []) << value
|
|
136
|
+
end
|
|
137
|
+
parser.on("--push IMAGE", "Publish the image to a registry") { |value| options[:publish] = value }
|
|
138
|
+
parser.on("--load IMAGE", "Load the image into the selected runtime") { |value| options[:load] = value }
|
|
139
|
+
parser.on("--exporter NAME", "Artifact exporter: auto, local, or boringcache") do |value|
|
|
140
|
+
options[:exporter] = value
|
|
141
|
+
end
|
|
142
|
+
parser.on("--artifact", "Use the BoringCache Artifact exporter") { options[:exporter] = :boringcache }
|
|
143
|
+
parser.on("--no-artifact", "Use the local artifact exporter") { options[:exporter] = :local }
|
|
144
|
+
parser.on("--artifact-name NAME", "Name the BoringCache Artifact") { |value| options[:artifact_name] = value }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def add_rails_options(parser, options)
|
|
148
|
+
parser.on("--base-image IMAGE", "Base image for the built-in Rails build") do |value|
|
|
149
|
+
options[:base_image] = value
|
|
150
|
+
end
|
|
151
|
+
parser.on("--[no-]assets", "Enable or disable Rails asset precompilation") { |value| options[:assets] = value }
|
|
152
|
+
parser.on("--[no-]bootsnap", "Enable or disable Bootsnap precompilation") { |value| options[:bootsnap] = value }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def doctor
|
|
156
|
+
options = { runtime: :auto }
|
|
157
|
+
parser = OptionParser.new do |command|
|
|
158
|
+
command.banner = "Usage: boringbuilder doctor [--runtime NAME]"
|
|
159
|
+
command.on("--runtime NAME", "auto, apple, or docker") { |value| options[:runtime] = value }
|
|
160
|
+
command.on("-h", "--help", "Show this help") do
|
|
161
|
+
@out.puts(command)
|
|
162
|
+
throw :boringbuilder_help, true
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
help_requested = catch(:boringbuilder_help) do
|
|
166
|
+
parser.parse!(@argv)
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
return 0 if help_requested
|
|
170
|
+
raise OptionParser::InvalidArgument, "doctor does not accept a PROJECT directory" unless @argv.empty?
|
|
171
|
+
|
|
172
|
+
runtime = Runtime.new(options[:runtime]).resolve
|
|
173
|
+
@out.puts "Dagger runtime ready: #{runtime}"
|
|
174
|
+
0
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def initialize_project
|
|
178
|
+
options = { template: :auto, force: false }
|
|
179
|
+
parser = OptionParser.new do |command|
|
|
180
|
+
command.banner = "Usage: boringbuilder init [options] [PROJECT]"
|
|
181
|
+
command.on("--template NAME", Initializer::TEMPLATES.map(&:to_s),
|
|
182
|
+
"Recipe template: auto, ruby, node, rust, go, or generic") do |value|
|
|
183
|
+
options[:template] = value
|
|
184
|
+
end
|
|
185
|
+
command.on("--force", "Replace an existing config/boringbuilder.rb") { options[:force] = true }
|
|
186
|
+
command.on("-h", "--help", "Show this help") do
|
|
187
|
+
@out.puts(command)
|
|
188
|
+
throw :boringbuilder_help, true
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
help_requested = catch(:boringbuilder_help) do
|
|
192
|
+
parser.parse!(@argv)
|
|
193
|
+
false
|
|
194
|
+
end
|
|
195
|
+
return 0 if help_requested
|
|
196
|
+
raise OptionParser::InvalidArgument, "expected at most one PROJECT directory" if @argv.length > 1
|
|
197
|
+
|
|
198
|
+
root = Pathname.new(@argv.first || Dir.pwd).expand_path
|
|
199
|
+
initializer = Initializer.new(root, template: options[:template])
|
|
200
|
+
path = initializer.call(force: options[:force])
|
|
201
|
+
@out.puts "Created #{path.relative_path_from(root)} (#{initializer.template_name})"
|
|
202
|
+
0
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def print_result(result)
|
|
206
|
+
@out.puts "Built with Dagger on #{result.runtime}"
|
|
207
|
+
@out.puts "Exported #{result.format}: #{result.path}" if result.exported?
|
|
208
|
+
@out.puts "Image: #{result.reference}" if result.published?
|
|
209
|
+
@out.puts "Artifact: #{result.artifact_id} (#{result.artifact_name})" if result.artifact_published?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def print_build_start(project, json:)
|
|
213
|
+
return if json || !dagger_session?
|
|
214
|
+
|
|
215
|
+
cache = BoringCache.new(project, nil, environment: @environment).mode
|
|
216
|
+
pipeline = project.custom_pipeline? ? "custom pipeline" : project.framework.to_s
|
|
217
|
+
details = [pipeline, "#{project.configuration.runtime} runtime", "#{cache} cache",
|
|
218
|
+
"#{project.configuration.format.to_s.tr('_', '.')} output"]
|
|
219
|
+
|
|
220
|
+
@out.puts "Building #{project.app_name} with Dagger"
|
|
221
|
+
@out.puts " #{details.join(' · ')}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def dagger_session?
|
|
225
|
+
@environment["DAGGER_SESSION_PORT"] && @environment["DAGGER_SESSION_TOKEN"]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def build_progress(configuration, json:)
|
|
229
|
+
enabled = !json && dagger_session? && configuration.progress.nil?
|
|
230
|
+
animated = enabled && @out.respond_to?(:tty?) && @out.tty?
|
|
231
|
+
BuildProgress.new(out: @out, enabled: enabled, animated: animated)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BoringBuilder
|
|
4
|
+
class ConfigFile
|
|
5
|
+
DEFAULT_PATH = "config/boringbuilder.rb"
|
|
6
|
+
THREAD_KEY = :boringbuilder_config_file
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def resolve(root, requested = :auto)
|
|
10
|
+
return if requested == false || requested.nil?
|
|
11
|
+
|
|
12
|
+
path = root.join(requested == :auto ? DEFAULT_PATH : requested).expand_path
|
|
13
|
+
return path if path.file?
|
|
14
|
+
return if requested == :auto
|
|
15
|
+
|
|
16
|
+
raise ConfigurationError, "BoringBuilder config file does not exist: #{path}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def load(path, configuration)
|
|
20
|
+
previous = Thread.current[THREAD_KEY]
|
|
21
|
+
state = { configuration: configuration, applied: false }
|
|
22
|
+
Thread.current[THREAD_KEY] = state
|
|
23
|
+
Kernel.load(path.to_s)
|
|
24
|
+
raise ConfigurationError, "#{path} must call BoringBuilder.configure" unless state[:applied]
|
|
25
|
+
|
|
26
|
+
configuration
|
|
27
|
+
ensure
|
|
28
|
+
Thread.current[THREAD_KEY] = previous
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configure
|
|
32
|
+
state = Thread.current[THREAD_KEY]
|
|
33
|
+
raise ConfigurationError, "BoringBuilder.configure is only available while loading a config file" unless state
|
|
34
|
+
|
|
35
|
+
state[:applied] = true
|
|
36
|
+
yield state.fetch(:configuration)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|