File indexing completed on 2022-10-04 16:21:34

0001 #!/usr/bin/env ruby
0002 
0003 # Copyright 2017 Jonathan Riddell <jr@jriddell.org>
0004 # Copyright 2015-2019 Harald Sitter <sitter@kde.org>
0005 #
0006 # This program is free software; you can redistribute it and/or
0007 # modify it under the terms of the GNU General Public License as
0008 # published by the Free Software Foundation; either version 2 of
0009 # the License or (at your option) version 3 or any later version
0010 # accepted by the membership of KDE e.V. (or its successor approved
0011 # by the membership of KDE e.V.), which shall act as a proxy
0012 # defined in Section 14 of version 3 of the license.
0013 #
0014 # This program is distributed in the hope that it will be useful,
0015 # but WITHOUT ANY WARRANTY; without even the implied warranty of
0016 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
0017 # GNU General Public License for more details.
0018 #
0019 # You should have received a copy of the GNU General Public License
0020 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
0021 
0022 if $PROGRAM_NAME != __FILE__
0023   # Note that during program execution docker is required in the exec block
0024   # as it gets on-demand installed if applicable.
0025   begin
0026     require 'docker'
0027   rescue LoadError
0028     puts 'Could not find docker-api library, run: sudo gem install docker-api'
0029     exit 1
0030   end
0031 end
0032 
0033 require 'etc'
0034 require 'optparse'
0035 require 'shellwords'
0036 
0037 # Finds executables. MakeMakefile is the only core ruby entity providing
0038 # PATH based executable lookup, unfortunately it is really not meant to be
0039 # used outside extconf.rb use cases as it mangles the main name scope by
0040 # injecting itself into it (which breaks for example the ffi gem).
0041 # The Shell interface's command-processor also has lookup code but it's not
0042 # Windows compatible.
0043 # NB: this is lifted from releaseme! should this need changing, change it there
0044 # first! also mind the unit test.
0045 class Executable
0046   attr_reader :bin
0047 
0048   def initialize(bin)
0049     @bin = bin
0050   end
0051 
0052   # Finds the executable in PATH by joining it with all parts of PATH and
0053   # checking if the resulting absolute path exists and is an executable.
0054   # This also honor's Windows' PATHEXT to determine the list of potential
0055   # file extensions. So find('gpg2') will find gpg2 on POSIX and gpg2.exe
0056   # on Windows.
0057   def find
0058     # PATHEXT on Windows defines the valid executable extensions.
0059     exts = ENV.fetch('PATHEXT', '').split(';')
0060     # On other systems we'll work with no extensions.
0061     exts << '' if exts.empty?
0062 
0063     ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
0064       path = unescape_path(path)
0065       exts.each do |ext|
0066         file = File.join(path, bin + ext)
0067         return file if executable?(file)
0068       end
0069     end
0070 
0071     nil
0072   end
0073 
0074   private
0075 
0076   class << self
0077     def windows?
0078       @windows ||= ENV['RELEASEME_FORCE_WINDOWS'] || mswin? || mingw?
0079     end
0080 
0081     private
0082 
0083     def mswin?
0084       @mswin ||= /mswin/ =~ RUBY_PLATFORM
0085     end
0086 
0087     def mingw?
0088       @mingw ||= /mingw/ =~ RUBY_PLATFORM
0089     end
0090   end
0091 
0092   def windows?
0093     self.class.windows?
0094   end
0095 
0096   def executable?(path)
0097     stat = File.stat(path)
0098   rescue SystemCallError
0099   else
0100     return true if stat.file? && stat.executable?
0101   end
0102 
0103   def unescape_path(path)
0104     # Strip qutation.
0105     # NB: POSIX does not define any quoting mechanism so you simply cannot
0106     # have colons in PATH on POSIX systems as a side effect we mustn't
0107     # strip quotes as they have no syntactic meaning and instead are
0108     # assumed to be part of the path
0109     # http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html#tag_08_03
0110     return path.sub(/\A"(.*)"\z/m, '\1') if windows?
0111     path
0112   end
0113 end
0114 
0115 # A wee command to simplify running KDE neon Docker images.
0116 #
0117 # KDE neon Docker images are the fastest and easiest way to test out KDE's
0118 # software.  You can use them on top of any Linux distro.
0119 #
0120 # ## Pre-requisites
0121 #
0122 # Install Docker and ensure you add yourself into the necessary group.
0123 # Also install Xephyr which is the X-server-within-a-window to run
0124 # Plasma.  With Ubuntu this is:
0125 #
0126 # ```apt install docker.io xserver-xephyr
0127 # usermod -G docker
0128 # newgrp docker
0129 # ```
0130 #
0131 # # Run
0132 #
0133 # To run a full Plasma session of Neon Developer Unstable Edition:
0134 # `neondocker`
0135 #
0136 # To run a full Plasma session of Neon User Edition:
0137 # `neondocker --edition user`
0138 #
0139 # For more options see
0140 # `neondocker --help`
0141 class NeonDocker
0142   attr_accessor :options # settings
0143   attr_accessor :tag # docker image tag to use
0144   attr_accessor :container # my Docker::Container
0145 
0146   def command_options
0147     @options = { pull: false, all: false, edition: 'unstable', kill: false }
0148     OptionParser.new do |opts|
0149       opts.banner = 'Usage: neondocker [options] [standalone-application]'
0150 
0151       opts.on('-p', '--pull', 'Always pull latest version') do |v|
0152         @options[:pull] = v
0153       end
0154       opts.on('-a', '--all',
0155               'Use Neon All images (larger, contains all apps)') do |v|
0156         @options[:all] = v
0157       end
0158       opts.on('-e', '--edition EDITION',
0159               '[user,testing,unstable,developer]') do |v|
0160         @options[:edition] = v
0161       end
0162       opts.on('-k', '--keep-alive', 'keep-alive container on exit') do |v|
0163         @options[:keep_alive] = v
0164       end
0165       opts.on('-r', '--reattach',
0166               'reuse an existing container [assumes -k]') do |v|
0167         @options[:reattach] = v
0168       end
0169       opts.on('-n', '--new',
0170               'Always start a new container even if one is already running' \
0171               'from the requested image') { |v| @options[:new] = v }
0172       opts.on('-w', '--wayland', 'Run a Wayland session') do |v|
0173         @options[:wayland] = v
0174       end
0175       opts.on_tail('standalone-application: Run a standalone application ' \
0176                    'rather than full Plasma shell. Assumes -n to always ' \
0177                    'start a new container.')
0178     end.parse!
0179 
0180     edition_options = ['user', 'testing', 'unstable', 'developer']
0181     unless edition_options.include?(@options[:edition])
0182       puts "Unknown edition. Valid editions are: #{edition_options}"
0183       exit 1
0184     end
0185     @options
0186   end
0187 
0188   def validate_docker
0189     Docker.version
0190   rescue
0191     puts 'Could not connect to Docker, check it is installed, running and ' \
0192          'your user is in the right group for access'
0193     exit 1
0194   end
0195 
0196   # Has the image already been downloaded to the local Docker?
0197   def docker_has_image?
0198     !Docker::Image.all.find do |image|
0199       next false if image.info['RepoTags'].nil?
0200       image.info['RepoTags'].include?(@tag)
0201     end.nil?
0202   end
0203 
0204   def docker_image_tag
0205     image_type = @options[:all] ? 'all' : 'plasma'
0206     @tag = 'kdeneon/' + image_type + ':' + @options[:edition]
0207   end
0208 
0209   def docker_pull
0210     puts "Downloading image #{@tag}"
0211     Docker::Image.create('fromImage' => @tag)
0212   end
0213 
0214   # Is the command available to run?
0215   def installed?(command)
0216     Executable.new(command).find
0217   end
0218 
0219   def running_xhost
0220     unless installed?('xhost')
0221       puts 'xhost is not installed, run apt install xserver-xephyr or similar'
0222       exit 1
0223     end
0224     system('xhost +')
0225     yield
0226     system('xhost -')
0227   end
0228 
0229   def xdisplay
0230     return @xdisplay if defined? @xdisplay
0231     @xdisplay = (0..1024).find { |i| !File.exist?("/tmp/.X11-unix/X#{i}") }
0232   end
0233 
0234   def running_xephyr
0235     installed = installed?('Xephyr')
0236     unless installed
0237       puts 'Xephyr is not installed, apt-get install xserver-xephyr or similar'
0238       exit 1
0239     end
0240     xephyr = IO.popen("Xephyr -screen 1024x768 :#{xdisplay}")
0241     yield
0242     Process.kill('KILL', xephyr.pid)
0243   end
0244 
0245   # If this image already has a container then use that, else start a new one
0246   def container
0247     return @container if defined? @container
0248     # find devices to bind for Wayland
0249     devices = Dir['/dev/dri/*'] + Dir['/dev/video*']
0250     devices_list = []
0251     devices.each do |dri|
0252       devices_list.push('PathOnHost' => dri,
0253                         'PathInContainer' => dri,
0254                         'CgroupPermissions' => 'mrw')
0255     end
0256     if @options[:reattach]
0257       all_containers = Docker::Container.all(all: true)
0258       all_containers.each do |container|
0259         if container.info['Image'] == @tag
0260           @container = Docker::Container.get(container.info['id'])
0261         end
0262       end
0263       begin
0264         @container = Docker::Container.create('Image' => @tag)
0265       rescue Docker::Error::NotFoundError
0266         puts "Could not find an image with @tag #{@tag}"
0267         return nil
0268       end
0269     elsif !ARGV.empty?
0270       @container = Docker::Container.create('Image' => @tag,
0271                                             'Cmd' => ARGV,
0272                                             'Env' => ['DISPLAY=:0'],
0273                                             'Binds' => ['/tmp/.X11-unix:/tmp/.X11-unix'],
0274                                             'Devices' => devices_list,
0275                                             'Privileged' => true)
0276     elsif @options[:wayland]
0277       @container = Docker::Container.create('Image' => @tag,
0278                                             'Env' => ['DISPLAY=:0'],
0279                                             'Cmd' => ['startplasma-wayland'],
0280                                             'Binds' => ['/tmp/.X11-unix:/tmp/.X11-unix'],
0281                                             'Devices' => devices_list,
0282                                             'Privileged' => true)
0283     else
0284       @container = Docker::Container.create('Image' => @tag,
0285                                             'Env' => ["DISPLAY=:#{xdisplay}"],
0286                                             'Binds' => ['/tmp/.X11-unix:/tmp/.X11-unix'],
0287                                             'Devices' => devices_list,
0288                                             'Privileged' => true)
0289     end
0290     @container
0291   end
0292 
0293   # runs the container and wait until Plasma or whatever has stopped running
0294   def run_container
0295 
0296     container.start()
0297     loop do
0298       container.refresh! if container.respond_to? :refresh!
0299       status = container.info.fetch('State', [])['Status']
0300       status ||= container.json.fetch('State').fetch('Status')
0301       break if not status == 'running'
0302       sleep 1
0303     end
0304     container.delete if !@options[:keep_alive] || @options[:reattach]
0305   end
0306 end
0307 
0308 # Jiggles dependencies into place.
0309 
0310 # Install deb dependencies.
0311 class DebDependencies
0312   def run
0313     pkgs_to_install = []
0314     pkgs_to_install << 'docker.io' unless File.exist?('/var/run/docker.sock')
0315     pkgs_to_install << 'xserver-xephyr' unless Executable.new('Xephyr').find
0316 
0317     return if pkgs_to_install.empty?
0318 
0319     warn 'Some packages need installing to use neondocker...'
0320     system('pkcon', 'install', *pkgs_to_install) || raise
0321   end
0322 end
0323 
0324 # Install !core gem dependencies and re-execs.
0325 class GemDependencies
0326   def run
0327     require 'docker'
0328   rescue LoadError
0329     if ENV['NEONDOCKER_REEXC']
0330       abort 'E: Installing ruby dependencies failed -> bugs.kde.org'
0331     end
0332     warn 'Some ruby dependencies need installing to use neondocker...'
0333     system('pkexec', 'gem', 'install', '--no-document', 'docker-api')
0334     ENV['NEONDOCKER_REEXC'] = '1'
0335     puts '...reexecuting...'
0336     exec(__FILE__, *ARGV)
0337   end
0338 end
0339 
0340 # Switchs group through re-exec.
0341 class GroupDependencies
0342   DOCKER_GROUP = 'docker'.freeze
0343 
0344   def run
0345     return if Process.uid.zero? # root always has access
0346     return if Process.groups.include?(docker_gid)
0347 
0348     unless user_in_group?
0349       adduser? || raise # adduser? actually aborts, the raise is just sugar
0350       system('pkexec', 'adduser', Etc.getlogin, DOCKER_GROUP) || raise
0351     end
0352 
0353     puts '...reexecuting with docker access...'
0354     exec('sg', DOCKER_GROUP, '-c', __FILE__, *ARGV)
0355   end
0356 
0357   private
0358 
0359   def user_in_group?
0360     member = false
0361     Etc.group do |group|
0362       member = group.mem.include?(Etc.getlogin) if group.name == DOCKER_GROUP
0363     end
0364     member
0365   end
0366 
0367   def adduser?
0368     loop do
0369       puts <<~QUESTION
0370         You currently do not have access to the docker socket. Do you want to
0371         give this user access? [Y/n]
0372       QUESTION
0373 
0374       input = gets.strip
0375       if input.casecmp('n').zero?
0376         abort <<~MSG
0377           Without socket access you need to use pkexec or sudo to run neondocker
0378         MSG
0379       end
0380 
0381       return true if input.casecmp('y').zero?
0382     end
0383     false
0384   end
0385 
0386   def docker_gid
0387     @docker_gid ||= begin
0388       gid = nil
0389       Etc.group do |group|
0390         gid = group.gid if group.name == DOCKER_GROUP
0391       end
0392       gid
0393     end
0394   end
0395 end
0396 
0397 # Jiggles dependencies into place.
0398 class DependencyJiggler
0399   def run
0400     DebDependencies.new.run
0401     GemDependencies.new.run
0402     GroupDependencies.new.run
0403   end
0404 end
0405 
0406 # Parses Linux os-release files.
0407 #
0408 # Variables from os-release are accessible through constants.
0409 #
0410 # @example Put os-release 'ID' of current system
0411 #   puts OSRelease::ID
0412 #
0413 # @note When running on potential !Linux or legacy systems you'll need to check
0414 #   {#available?} before accessing constants, otherwise you may encounter
0415 #   {NotFoundError} exceptions.
0416 #
0417 # @see https://www.freedesktop.org/software/systemd/man/os-release.html
0418 module OSRelease
0419   # Raised when no default os-release file could be found.
0420   class NotFoundError < StandardError; end
0421 
0422   class << self
0423     # @return [Boolean] true when an os-release file was found in default
0424     #   locations as per the os-release specification
0425     def available?
0426       default_path
0427       true
0428     rescue NotFoundError
0429       false
0430     end
0431 
0432     # @param key [Symbol] variable name in the os-release file
0433     # @return [Boolean] true when the variable key is defined in the os-release
0434     #   data
0435     def variable?(key)
0436       data.key?(key)
0437     end
0438 
0439     # Behaves exactly like {Hash#fetch}.
0440     #
0441     # @return value of variable (if it is defined see {#variable?})
0442     def value(key, default = nil, &block)
0443       data.fetch(key, default, &block)
0444     end
0445 
0446     # @api private
0447     def load!(path = default_path)
0448       @data = default_data.dup
0449       File.read(path).split("\n").each do |line|
0450         # Split by comment to also drop leading and trailing comments. Then
0451         # strip to possibly reduce to an empty line.
0452         # Note that trailing comments are technically not defined by the spec.
0453         line = line.split('#', 2)[0].strip
0454         next if line.empty?
0455 
0456         key, value = parse(line)
0457         @data[key.to_sym] = value
0458       end
0459       @data
0460     end
0461 
0462     # @api private
0463     def reset!
0464       @data = nil
0465     end
0466 
0467     # @api private
0468     def const_missing(name)
0469       return value(name) if variable?(name)
0470 
0471       super
0472     end
0473 
0474     private
0475 
0476     STRINGLISTS = %w[ID_LIKE].freeze
0477 
0478     def parse(line)
0479       key, value = line.split('=', 2)
0480       return parse_list(key, value) if STRINGLISTS.include?(key)
0481 
0482       parse_string(key, value)
0483     end
0484 
0485     def parse_list(key, value)
0486       # If the value is quoted split twice. This is effectively the same
0487       # as dropping the quotes. ID_LIKE derives from ID and is therefore
0488       # super restricted in what it may contain so that a double split has
0489       # no adverse affects.
0490       value = Shellwords.split(value)[0] if value.start_with?('"')
0491       [key, Shellwords.split(value)]
0492     end
0493 
0494     def parse_string(key, value)
0495       value = Shellwords.split(value)
0496       [key, value[0]]
0497     end
0498 
0499     def data
0500       @data ||= load!
0501     end
0502 
0503     def default_data
0504       # Spec defines some variables with a default value.
0505       {
0506         ID: 'linux',
0507         NAME: 'Linux',
0508         PRETTY_NAME: 'Linux'
0509       }
0510     end
0511 
0512     def default_path
0513       paths = %w[/etc/os-release /usr/lib/os-release]
0514       path = paths.find { |x| File.exist?(x) }
0515       return path if path
0516 
0517       raise NotFoundError,
0518             "Could not find os-release file in default locations: #{paths}"
0519     end
0520   end
0521 end
0522 
0523 if $PROGRAM_NAME == __FILE__
0524   if OSRelease.available? && (OSRelease::ID == 'ubuntu' ||
0525                              (defined? OSRelease::ID_LIKE && OSRelease::ID_LIKE.include?('ubuntu')))
0526     DependencyJiggler.new.run
0527   end
0528 
0529   neon_docker = NeonDocker.new
0530   options = neon_docker.command_options
0531   neon_docker.validate_docker
0532   neon_docker.docker_image_tag
0533   options[:pull] = true unless neon_docker.docker_has_image?
0534   neon_docker.docker_pull if options[:pull]
0535   if !ARGV.empty? || options[:wayland]
0536     neon_docker.running_xhost do
0537       neon_docker.run_container
0538     end
0539   else
0540     neon_docker.running_xephyr do
0541       neon_docker.run_container
0542     end
0543   end
0544   exit 0
0545 end