summaryrefslogtreecommitdiff
path: root/import/rubygems.to_chunk
blob: 63c9cd261fcb03448ccc5265c0d927315341daf3 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
#!/usr/bin/env ruby
#
# Create a chunk morphology to integrate a RubyGem in Baserock
#
# Copyright (C) 2014  Codethink Limited
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; version 2 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

require 'bundler'

require_relative 'importer_base'

class << Bundler
  def default_gemfile
    # This is a hack to make things not crash when there's no Gemfile
    Pathname.new('.')
  end
end

def spec_is_from_current_source_tree(spec, source_dir)
  spec.source.instance_of? Bundler::Source::Path and
    File.identical?(spec.source.path, source_dir)
end

BANNER = "Usage: rubygems.to_chunk SOURCE_DIR GEM_NAME [VERSION]"

DESCRIPTION = <<-END
This tool reads the Gemfile and optionally the Gemfile.lock from a Ruby project
source tree in SOURCE_DIR. It outputs a chunk morphology for GEM_NAME on
stdout. If VERSION is supplied, it is used to check that the build instructions
will produce the expected version of the Gem.

It is intended for use with the `baserock-import` tool.
END

class RubyGemChunkMorphologyGenerator < Importer::Base
  def initialize
    local_data = YAML.load_file("rubygems.yaml")
    @build_dependency_whitelist = local_data['build-dependency-whitelist']
  end

  def parse_options(arguments)
    opts = create_option_parser(BANNER, DESCRIPTION)

    parsed_arguments = opts.parse!(arguments)

    if parsed_arguments.length != 2 && parsed_arguments.length != 3
      STDERR.puts "Expected 2 or 3 arguments, got #{parsed_arguments}."
      opts.parse(['-?'])
      exit 255
    end

    source_dir, gem_name, expected_version = parsed_arguments
    source_dir = File.absolute_path(source_dir)
    if expected_version != nil
      expected_version = Gem::Version.new(expected_version.dup)
    end
    [source_dir, gem_name, expected_version]
  end

  def load_local_gemspecs()
    # Look for .gemspec files in the source repo.
    #
    # If there is no .gemspec, but you set 'name' and 'version' then
    # inside Bundler::Source::Path.load_spec_files this call will create a
    # fake gemspec matching that name and version. That's probably not useful.

    dir = '.'

    source = Bundler::Source::Path.new({
      'path' => dir,
    })

    log.info "Loaded #{source.specs.count} specs from source dir."
    source.specs.each do |spec|
      log.debug "  * #{spec.inspect} #{spec.dependencies.inspect}"
    end

    source
  end

  def get_spec_for_gem(specs, gem_name)
    found = specs[gem_name].select {|s| Gem::Platform.match(s.platform)}
    if found.empty?
      raise Exception,
        "No Gemspecs found matching '#{gem_name}'"
    elsif found.length != 1
      raise Exception,
        "Unsure which Gem to use for #{gem_name}, got #{found}"
    end
    found[0]
  end

  def chunk_name_for_gemspec(spec)
    # Chunk names are the Gem's "full name" (name + version number), so
    # that we don't break in the rare but possible case that two different
    # versions of the same Gem are required for something to work. It'd be
    # nicer to only use the full_name if we detect such a conflict.
    spec.full_name
  end

  def is_signed_gem(spec)
    spec.signing_key != nil
  end

  def generate_chunk_morph_for_gem(spec)
    description = 'Automatically generated by rubygems.to_chunk'

    bin_dir = "\"$DESTDIR/$PREFIX/bin\""
    gem_dir = "\"$DESTDIR/$(gem environment home)\""

    # There's more splitting to be done, but putting the docs in the
    # correct artifact is the single biggest win for enabling smaller
    # system images.
    #
    # Adding this to Morph's default ruleset is painful, because:
    #   - Changing the default split rules triggers a rebuild of everything.
    #   - The whole split rule code needs reworking to prevent overlaps and to
    #     make it possible to extend rules without creating overlaps. It's
    #     otherwise impossible to reason about.

    split_rules = [
      {
        'artifact' => "#{spec.full_name}-doc",
        'include' => [
          'usr/lib/ruby/gems/\d[\w.]*/doc/.*'
        ]
      }
    ]

    # It'd be rather tricky to include these build instructions as a
    # BuildSystem implementation in Morph. The problem is that there's no
    # way for the default commands to know what .gemspec file they should
    # be building. It doesn't help that the .gemspec may be in a subdirectory
    # (as in Rails, for example).
    #
    # Note that `gem help build` says the following:
    #
    #   The best way to build a gem is to use a Rakefile and the
    #   Gem::PackageTask which ships with RubyGems.
    #
    # It's often possible to run `rake gem`, but this may require Hoe,
    # rake-compiler, Jeweler or other assistance tools to be present at Gem
    # construction time. It seems that many Ruby projects that use these tools
    # also maintain an up-to-date generated .gemspec file, which means that we
    # can get away with using `gem build` just fine in many cases.
    #
    # Were we to use `setup.rb install` or `rake install`, programs that loaded
    # with the 'rubygems' library would complain that required Gems were not
    # installed. We must have the Gem metadata available, and `gem build; gem
    # install` seems the easiest way to achieve that.

    configure_commands = []

    if is_signed_gem(spec)
      # This is a best-guess hack for allowing unsigned builds of Gems that are
      # normally built signed. There's no value in building signed Gems when we
      # control the build and deployment environment, and we obviously can't
      # provide the private key of the Gem's maintainer.
      configure_commands <<
        "sed -e '/cert_chain\\s*=/d' -e '/signing_key\\s*=/d' -i " +
        "#{spec.name}.gemspec"
    end

    build_commands = [
      "gem build #{spec.name}.gemspec",
    ]

    install_commands = [
      "mkdir -p #{gem_dir}",
      "gem install --install-dir #{gem_dir} --bindir #{bin_dir} " +
        "--ignore-dependencies --local ./#{spec.full_name}.gem"
    ]

    {
      'name' => chunk_name_for_gemspec(spec),
      'kind' => 'chunk',
      'description' => description,
      'build-system' => 'manual',
      'products' => split_rules,
      'configure-commands' => configure_commands,
      'build-commands' => build_commands,
      'install-commands' => install_commands,
    }
  end

  def build_deps_for_gem(spec)
    deps = spec.dependencies.select do |d|
      d.type == :development && @build_dependency_whitelist.member?(d.name)
    end
  end

  def runtime_deps_for_gem(spec)
    spec.dependencies.select {|d| d.type == :runtime}
  end

  def run
    source_dir_name, gem_name, expected_version = parse_options(ARGV)

    log.info("Creating chunk morph for #{gem_name} based on " +
             "source code in #{source_dir_name}")

    Dir.chdir(source_dir_name)

    # Instead of reading the real Gemfile, invent one that simply includes the
    # chosen .gemspec. If present, the Gemfile.lock will be honoured.
    fake_gemfile = Bundler::Dsl.new
    fake_gemfile.source('https://rubygems.org')
    fake_gemfile.gemspec({:name => gem_name})

    definition = fake_gemfile.to_definition('Gemfile.lock', true)
    resolved_specs = definition.resolve_remotely!

    spec = get_spec_for_gem(resolved_specs, gem_name)

    if not spec_is_from_current_source_tree(spec, source_dir_name)
      error "Specified gem '#{spec.name}' doesn't live in the source in " +
            "'#{source_dir_name}'"
      log.debug "SPEC: #{spec.inspect} #{spec.source}"
      exit 1
    end

    if expected_version != nil && spec.version != expected_version
      # This check is brought to you by Coderay, which changes its version
      # number based on an environment variable. Other Gems may do this too.
      error "Source in #{source_dir_name} produces #{spec.full_name}, but " +
            "the expected version was #{expected_version}."
      exit 1
    end

    morph = generate_chunk_morph_for_gem(spec)

    # One might think that you could use the Bundler::Dependency.groups
    # field to filter but it doesn't seem to be useful. Instead we go back to
    # the Gem::Specification of the target Gem and use the dependencies fild
    # there. We look up each dependency in the resolved_specset to find out
    # what version Bundler has chosen of it.

    def format_deps_for_morphology(specset, dep_list)
      info = dep_list.collect do |dep|
        spec = specset[dep][0]
        [spec.name, spec.version.to_s]
      end
      Hash[info]
    end

    build_deps = format_deps_for_morphology(
      resolved_specs, build_deps_for_gem(spec))
    runtime_deps = format_deps_for_morphology(
      resolved_specs, runtime_deps_for_gem(spec))

    morph['x-build-dependencies-rubygems'] = build_deps
    morph['x-runtime-dependencies-rubygems'] = runtime_deps

    write_morph(STDOUT, morph)
  end
end

RubyGemChunkMorphologyGenerator.new.run