msi.rb 18.2 KB
Newer Older
sersut's avatar
sersut committed
1
#
2
# Copyright 2014 Chef Software, Inc.
sersut's avatar
sersut committed
3 4 5 6 7 8 9 10 11 12 13 14 15 16
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

17 18
require 'pathname'

sersut's avatar
sersut committed
19
module Omnibus
Seth Vargo's avatar
Seth Vargo committed
20
  class Packager::MSI < Packager::Base
21 22
    DEFAULT_TIMESTAMP_SERVERS = ['http://timestamp.digicert.com',
                                 'http://timestamp.verisign.com/scripts/timestamp.dll']
23
    id :msi
sersut's avatar
sersut committed
24 25

    setup do
26 27
      # Render the localization
      write_localization_file
sersut's avatar
sersut committed
28

29 30
      # Render the msi parameters
      write_parameters_file
31

32 33
      # Render the source file
      write_source_file
34

Thom May's avatar
Thom May committed
35 36 37
      # Optionally, render the bundle file
      write_bundle_file if bundle_msi

38 39 40
      # Copy all the staging assets from vendored Omnibus into the resources
      # directory.
      create_directory("#{resources_dir}/assets")
41
      FileSyncer.glob("#{Omnibus.source_root}/resources/#{id}/assets/*").each do |file|
42 43 44 45 46 47 48
        copy_file(file, "#{resources_dir}/assets/#{File.basename(file)}")
      end

      # Copy all assets in the user's project directory - this may overwrite
      # files copied in the previous step, but that's okay :)
      FileSyncer.glob("#{resources_path}/assets/*").each do |file|
        copy_file(file, "#{resources_dir}/assets/#{File.basename(file)}")
sersut's avatar
sersut committed
49 50 51 52
      end
    end

    build do
53 54
      # Harvest the files with heat.exe, recursively generate fragment for
      # project directory
55 56 57 58 59 60 61 62
      Dir.chdir(staging_dir) do
        shellout! <<-EOH.split.join(' ').squeeze(' ').strip
          heat.exe dir "#{windows_safe_path(project.install_dir)}"
            -nologo -srd -gg -cg ProjectDir
            -dr PROJECTLOCATION
            -var "var.ProjectSourceDir"
            -out "project-files.wxs"
        EOH
63

64
        # Compile with candle.exe
65 66
        log.debug(log_key) { "wix_candle_flags: #{wix_candle_flags}" }

67 68 69
        shellout! <<-EOH.split.join(' ').squeeze(' ').strip
          candle.exe
            -nologo
70
            #{wix_candle_flags}
71
            #{wix_extension_switches(wix_candle_extensions)}
72 73 74
            -dProjectSourceDir="#{windows_safe_path(project.install_dir)}" "project-files.wxs"
            "#{windows_safe_path(staging_dir, 'source.wxs')}"
        EOH
75

76 77
        # Create the msi, ignoring the 204 return code from light.exe since it is
        # about some expected warnings
Jay Mundrawala's avatar
Jay Mundrawala committed
78

Thom May's avatar
Thom May committed
79
        msi_file = windows_safe_path(Config.package_dir, msi_name)
Jay Mundrawala's avatar
Jay Mundrawala committed
80

81
        light_command = <<-EOH.split.join(' ').squeeze(' ').strip
82 83 84
          light.exe
            -nologo
            -ext WixUIExtension
85
            #{wix_extension_switches(wix_light_extensions)}
86 87 88
            -cultures:en-us
            -loc "#{windows_safe_path(staging_dir, 'localization-en-us.wxl')}"
            project-files.wixobj source.wixobj
Jay Mundrawala's avatar
Jay Mundrawala committed
89
            -out "#{msi_file}"
90
        EOH
91
        shellout!(light_command, returns: [0, 204])
Jay Mundrawala's avatar
Jay Mundrawala committed
92

93
        if signing_identity
Jay Mundrawala's avatar
Jay Mundrawala committed
94 95
          sign_package(msi_file)
        end
Thom May's avatar
Thom May committed
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

        # This assumes, rightly or wrongly, that any installers we want to bundle
        # into our installer will be downloaded by omnibus and put in the cache dir

        if bundle_msi
          shellout! <<-EOH.split.join(' ').squeeze(' ').strip
          candle.exe
            -nologo
            #{wix_candle_flags}
            -ext WixBalExtension
            #{wix_extension_switches(wix_candle_extensions)}
            -dOmnibusCacheDir="#{windows_safe_path(File.expand_path(Config.cache_dir))}"
            "#{windows_safe_path(staging_dir, 'bundle.wxs')}"
          EOH

          bundle_file = windows_safe_path(Config.package_dir, bundle_name)

          bundle_light_command = <<-EOH.split.join(' ').squeeze(' ').strip
          light.exe
            -nologo
            -ext WixUIExtension
            -ext WixBalExtension
            #{wix_extension_switches(wix_light_extensions)}
            -cultures:en-us
            -loc "#{windows_safe_path(staging_dir, 'localization-en-us.wxl')}"
            bundle.wixobj
            -out "#{bundle_file}"
          EOH
          shellout!(bundle_light_command, returns: [0, 204])

          if signing_identity
            sign_package(bundle_file)
          end
        end
130
      end
sersut's avatar
sersut committed
131 132
    end

133 134 135 136
    #
    # @!group DSL methods
    # --------------------------------------------------

137 138 139 140 141 142 143 144 145 146 147 148 149 150
    #
    # Set or retrieve the upgrade code.
    #
    # @example
    #   upgrade_code 'ABCD-1234'
    #
    # @param [Hash] val
    #   the UpgradeCode to set
    #
    # @return [Hash]
    #   the set UpgradeCode
    #
    def upgrade_code(val = NULL)
      if null?(val)
151
        @upgrade_code || raise(MissingRequiredAttribute.new(self, :upgrade_code, '2CD7259C-776D-4DDB-A4C8-6E544E580AA1'))
152
      else
Seth Vargo's avatar
Seth Vargo committed
153 154 155 156
        unless val.is_a?(String)
          raise InvalidValue.new(:parameters, 'be a String')
        end

157 158 159 160 161
        @upgrade_code = val
      end
    end
    expose :upgrade_code

162 163 164 165
    #
    # Set or retrieve the custom msi building parameters.
    #
    # @example
166 167 168
    #   parameters {
    #     'MagicParam' => 'ABCD-1234'
    #   }
169 170 171 172 173 174 175 176 177 178 179
    #
    # @param [Hash] val
    #   the parameters to set
    #
    # @return [Hash]
    #   the set parameters
    #
    def parameters(val = NULL)
      if null?(val)
        @parameters || {}
      else
Seth Vargo's avatar
Seth Vargo committed
180 181 182 183
        unless val.is_a?(Hash)
          raise InvalidValue.new(:parameters, 'be a Hash')
        end

184 185 186 187 188
        @parameters = val
      end
    end
    expose :parameters

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
    #
    # Set the wix light extensions to load
    #
    # @example
    #   wix_light_extension 'WixUtilExtension'
    #
    # @param [String] extension
    #   A list of extensions to load
    #
    # @return [Array]
    #   The list of extensions that will be loaded
    #
    def wix_light_extension(extension)
      unless extension.is_a?(String)
        raise InvalidValue.new(:wix_light_extension, 'be an String')
      end

      wix_light_extensions << extension
    end
    expose :wix_light_extension

    #
    # Set the wix candle extensions to load
    #
    # @example
    #   wix_candle_extension 'WixUtilExtension'
    #
    # @param [String] extension
    #   A list of extensions to load
    #
    # @return [Array]
    #   The list of extensions that will be loaded
    #
    def wix_candle_extension(extension)
      unless extension.is_a?(String)
        raise InvalidValue.new(:wix_candle_extension, 'be an String')
      end

      wix_candle_extensions << extension
    end
    expose :wix_candle_extension

Thom May's avatar
Thom May committed
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249
    #
    # Signal that we're building a bundle rather than a single package
    #
    # @example
    #   bundle_msi true
    #
    # @param [TrueClass, FalseClass] value
    #   whether we're a bundle or not
    #
    # @return [TrueClass, FalseClass]
    #   whether we're a bundle or not
    def bundle_msi(val = false)
      unless (val.is_a?(TrueClass) || val.is_a?(FalseClass))
        raise InvalidValue.new(:bundle_msi, 'be TrueClass or FalseClass')
      end
      @bundle_msi ||= val
    end
    expose :bundle_msi

Jay Mundrawala's avatar
Jay Mundrawala committed
250
    #
251
    # Set the signing certificate name
Jay Mundrawala's avatar
Jay Mundrawala committed
252 253
    #
    # @example
254 255 256
    #   signing_identity 'FooCert'
    #   signing_identity 'FooCert', store: 'BarStore'
    #
257 258
    # @param [String] thumbprint
    #   the thumbprint of the certificate in the certificate store
259 260 261 262 263 264
    # @param [Hash<Symbol, String>] params
    #   an optional hash that defines the parameters for the singing identity
    #
    # @option params [String] :store (My)
    #   The name of the certificate store which contains the certificate
    # @option params [Array<String>, String] :timestamp_servers
Seth Chisamore's avatar
Seth Chisamore committed
265
    #   A trusted timestamp server or a list of truested timestamp servers to
266
    #   be tried. They are tried in the order provided.
267 268 269
    # @option params [TrueClass, FalseClass] :machine_store (false)
    #   If set to true, the local machine store will be searched for a valid
    #   certificate. Otherwise, the current user store is used
270 271 272 273
    #
    #   Setting nothing will default to trying ['http://timestamp.digicert.com',
    #   'http://timestamp.verisign.com/scripts/timestamp.dll']
    #
274
    # @return [Hash{:thumbprint => String, :store => String, :timestamp_servers => Array[String]}]
275
    #
276 277
    def signing_identity(thumbprint= NULL, params = NULL)
      unless null?(thumbprint)
278
        @signing_identity = {}
279
        unless thumbprint.is_a?(String)
280
          raise InvalidValue.new(:signing_identity, 'be a String')
Jay Mundrawala's avatar
Jay Mundrawala committed
281 282
        end

283
        @signing_identity[:thumbprint] = thumbprint
Jay Mundrawala's avatar
Jay Mundrawala committed
284

285 286 287 288
        if !null?(params)
          unless params.is_a?(Hash)
            raise InvalidValue.new(:params, 'be a Hash')
          end
Jay Mundrawala's avatar
Jay Mundrawala committed
289

290 291
          valid_keys = [:store, :timestamp_servers, :machine_store]
          invalid_keys = params.keys - valid_keys
292
          unless invalid_keys.empty?
293 294 295 296 297
            raise InvalidValue.new(:params, "contain keys from [#{valid_keys.join(', ')}]. "\
                                   "Found invalid keys [#{invalid_keys.join(', ')}]")
          end

          if !params[:machine_store].nil? && !(
Seth Chisamore's avatar
Seth Chisamore committed
298
             params[:machine_store].is_a?(TrueClass) ||
299 300
             params[:machine_store].is_a?(FalseClass))
            raise InvalidValue.new(:params, 'contain key :machine_store of type TrueClass or FalseClass')
301 302 303
          end
        else
          params = {}
Jay Mundrawala's avatar
Jay Mundrawala committed
304
        end
305 306 307 308

        @signing_identity[:store] = params[:store] || 'My'
        servers = params[:timestamp_servers] || DEFAULT_TIMESTAMP_SERVERS
        @signing_identity[:timestamp_servers] = [servers].flatten
309
        @signing_identity[:machine_store] = params[:machine_store] || false
Jay Mundrawala's avatar
Jay Mundrawala committed
310
      end
311 312

      @signing_identity
Jay Mundrawala's avatar
Jay Mundrawala committed
313
    end
314
    expose :signing_identity
Jay Mundrawala's avatar
Jay Mundrawala committed
315

316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346
    #
    # Discovers a path to a gem/file included in a gem under the install directory.
    #
    # @example
    #   gem_path 'chef-[0-9]*-mingw32' -> 'some/path/to/gems/chef-version-mingw32'
    #
    # @param [String] glob
    #   a ruby acceptable glob path such as with **, *, [] etc.
    #
    # @return [String] path relative to the project's install_dir
    #
    # Raises exception the glob matches 0 or more than 1 file/directory.
    #
    def gem_path(glob = NULL)
      unless glob.is_a?(String) || null?(glob)
        raise InvalidValue.new(:glob, 'be an String')
      end

      install_path = Pathname.new(project.install_dir)

      # Find path in which the Chef gem is installed
      search_pattern = install_path.join('**', 'gems')
      search_pattern = search_pattern.join(glob) unless null?(glob)
      file_paths  = Pathname.glob(search_pattern).find

      raise "Could not find `#{search_pattern}'!" if file_paths.none?
      raise "Multiple possible matches of `#{search_pattern}'! : #{file_paths}" if file_paths.count > 1
      file_paths.first.relative_path_from(install_path).to_s
    end
    expose :gem_path

347 348 349 350
    #
    # @!endgroup
    # --------------------------------------------------

sersut's avatar
sersut committed
351 352
    # @see Base#package_name
    def package_name
Thom May's avatar
Thom May committed
353 354 355 356
      bundle_msi ? bundle_name : msi_name
    end

    def msi_name
357
      "#{project.package_name}-#{project.build_version}-#{project.build_iteration}-#{Config.windows_arch}.msi"
sersut's avatar
sersut committed
358 359
    end

Thom May's avatar
Thom May committed
360
    def bundle_name
361
      "#{project.package_name}-#{project.build_version}-#{project.build_iteration}-#{Config.windows_arch}.exe"
Thom May's avatar
Thom May committed
362 363
    end

sersut's avatar
sersut committed
364
    #
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381
    # The path where the MSI resources will live.
    #
    # @return [String]
    #
    def resources_dir
      File.expand_path("#{staging_dir}/Resources")
    end

    #
    # Write the localization file into the staging directory.
    #
    # @return [void]
    #
    def write_localization_file
      render_template(resource_path('localization-en-us.wxl.erb'),
        destination: "#{staging_dir}/localization-en-us.wxl",
        variables: {
Seth Chisamore's avatar
Seth Chisamore committed
382
          name:          project.package_name,
383 384 385 386 387 388 389 390 391 392 393 394 395 396 397
          friendly_name: project.friendly_name,
          maintainer:    project.maintainer,
        }
      )
    end

    #
    # Write the parameters file into the staging directory.
    #
    # @return [void]
    #
    def write_parameters_file
      render_template(resource_path('parameters.wxi.erb'),
        destination: "#{staging_dir}/parameters.wxi",
        variables: {
Seth Chisamore's avatar
Seth Chisamore committed
398
          name:            project.package_name,
399 400
          friendly_name:   project.friendly_name,
          maintainer:      project.maintainer,
401
          upgrade_code:    upgrade_code,
402
          parameters:      parameters,
403 404 405 406 407 408 409 410 411 412 413 414
          version:         msi_version,
          display_version: msi_display_version,
        }
      )
    end

    #
    # Write the source file into the staging directory.
    #
    # @return [void]
    #
    def write_source_file
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443
      paths = []

      # Remove C:/
      install_dir = project.install_dir.split('/')[1..-1].join('/')

      # Grab all parent paths
      Pathname.new(install_dir).ascend do |path|
        paths << path.to_s
      end

      # Create the hierarchy
      hierarchy = paths.reverse.inject({}) do |hash, path|
        hash[File.basename(path)] = path.gsub(/[^[:alnum:]]/, '').upcase + 'LOCATION'
        hash
      end

      # The last item in the path MUST be named PROJECTLOCATION or else space
      # robots will cause permanent damage to you and your family.
      hierarchy[hierarchy.keys.last] = 'PROJECTLOCATION'

      # If the path hierarchy is > 1, the customizable installation directory
      # should default to the second-to-last item in the hierarchy. If the
      # hierarchy is smaller than that, then just use the system drive.
      wix_install_dir = if hierarchy.size > 1
        hierarchy.to_a[-2][1]
      else
        'WINDOWSVOLUME'
      end

444 445 446
      render_template(resource_path('source.wxs.erb'),
        destination: "#{staging_dir}/source.wxs",
        variables: {
Seth Chisamore's avatar
Seth Chisamore committed
447
          name:          project.package_name,
448 449
          friendly_name: project.friendly_name,
          maintainer:    project.maintainer,
450 451 452
          hierarchy:     hierarchy,

          wix_install_dir: wix_install_dir,
453 454
        }
      )
sersut's avatar
sersut committed
455
    end
456

Thom May's avatar
Thom May committed
457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477
    #
    # Write the bundle file into the staging directory.
    #
    # @return [void]
    #
    def write_bundle_file
      render_template(resource_path('bundle.wxs.erb'),
        destination: "#{staging_dir}/bundle.wxs",
        variables: {
          name:            project.package_name,
          friendly_name:   project.friendly_name,
          maintainer:      project.maintainer,
          upgrade_code:    upgrade_code,
          parameters:      parameters,
          version:         msi_version,
          display_version: msi_display_version,
          msi:             windows_safe_path(Config.package_dir, msi_name),
        }
      )
    end

478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496
    #
    # Parse and return the MSI version from the {Project#build_version}.
    #
    # A project's +build_version+ looks something like:
    #
    #     dev builds => 11.14.0-alpha.1+20140501194641.git.94.561b564
    #                => 0.0.0+20140506165802.1
    #
    #     rel builds => 11.14.0.alpha.1 || 11.14.0
    #
    # The MSI version spec expects a version that looks like X.Y.Z.W where
    # X, Y, Z & W are all 32 bit integers.
    #
    # @return [String]
    #
    def msi_version
      versions = project.build_version.split(/[.+-]/)
      "#{versions[0]}.#{versions[1]}.#{versions[2]}.#{project.build_iteration}"
    end
497

498 499 500 501 502 503 504 505
    #
    # The display version calculated from the {Project#build_version}.
    #
    # @see #msi_version an explanation of the breakdown
    #
    # @return [String]
    #
    def msi_display_version
Seth Vargo's avatar
Seth Vargo committed
506
      versions = project.build_version.split(/[.+-]/)
507
      "#{versions[0]}.#{versions[1]}.#{versions[2]}"
508
    end
509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529

    #
    # Returns the extensions to use for light
    #
    # @return [Array]
    #   the extensions that will be loaded for light
    #
    def wix_light_extensions
      @wix_light_extensions ||= []
    end

    #
    # Returns the extensions to use for candle
    #
    # @return [Array]
    #   the extensions that will be loaded for candle
    #
    def wix_candle_extensions
      @wix_candle_extensions ||= []
    end

530 531 532 533 534 535 536 537
    #
    # Returns the options to use for candle
    #
    # @return [Array]
    #   the extensions that will be loaded for candle
    #
    def wix_candle_flags
      # we support x86 or x64.  No Itanium support (ia64).
538
      @wix_candle_flags ||= "-arch " + (Config.windows_arch.to_sym == :x86 ? "x86" : "x64")
539 540
    end

541 542 543 544 545 546 547 548 549 550 551 552
    #
    # Takes an array of wix extension names and creates a string
    # that can be passed to wix to load those.
    #
    # for example,
    # ['a', 'b'] => "-ext 'a' -ext 'b'"
    #
    # @return [String]
    #
    def wix_extension_switches(arr)
      "#{arr.map {|e| "-ext '#{e}'"}.join(' ')}"
    end
Jay Mundrawala's avatar
Jay Mundrawala committed
553

554 555
    def thumbprint
      signing_identity[:thumbprint]
556 557 558 559 560 561 562 563 564 565
    end

    def cert_store_name
      signing_identity[:store]
    end

    def timestamp_servers
      signing_identity[:timestamp_servers]
    end

566 567 568 569
    def machine_store?
      signing_identity[:machine_store]
    end

Jay Mundrawala's avatar
Jay Mundrawala committed
570
    #
571
    # Takes a path to a msi and uses the set certificate store and
Jay Mundrawala's avatar
Jay Mundrawala committed
572 573 574
    # certificate name
    #
    def sign_package(msi_file)
575 576 577 578 579 580
      cmd = Array.new.tap do |arr|
        arr << 'signtool.exe'
        arr << 'sign /v'
        arr << '/sm' if machine_store?
        arr << "/s #{cert_store_name}"
        arr << "/sha1 #{thumbprint}"
Salim Alam's avatar
Salim Alam committed
581
        arr << "/d #{project.package_name}"
582 583 584
        arr << "\"#{msi_file}\""
      end
      shellout!(cmd.join(" "))
Jay Mundrawala's avatar
Jay Mundrawala committed
585 586 587 588 589 590 591 592 593 594
      add_timestamp(msi_file)
    end

    #
    # Iterates through available timestamp servers and tries to timestamp
    # the file. If non succeed, an exception is raised.
    #
    def add_timestamp(msi_file)
      success = false
      timestamp_servers.each do |ts|
Jay Mundrawala's avatar
Jay Mundrawala committed
595 596 597
        success = try_timestamp(msi_file, ts)
        break if success
      end
598
      raise FailedToTimestampMSI.new if !success
Jay Mundrawala's avatar
Jay Mundrawala committed
599 600 601
    end

    def try_timestamp(msi_file, url)
602
      timestamp_command = "signtool.exe timestamp -t #{url} \"#{msi_file}\""
Jay Mundrawala's avatar
Jay Mundrawala committed
603 604 605 606 607
      status = shellout(timestamp_command)
      if status.exitstatus != 0
        log.warn(log_key) do
          <<-EOH.strip
                Failed to add timestamp with timeserver #{url}
Jay Mundrawala's avatar
Jay Mundrawala committed
608 609 610 611 612 613 614 615 616 617 618

                STDOUT
                ------
                #{status.stdout}

                STDERR
                ------
                #{status.stderr}
                EOH
        end
      end
Jay Mundrawala's avatar
Jay Mundrawala committed
619
      status.exitstatus == 0
Jay Mundrawala's avatar
Jay Mundrawala committed
620
    end
sersut's avatar
sersut committed
621 622
  end
end