diff --git a/lib/pharos/cluster_manager.rb b/lib/pharos/cluster_manager.rb index 0f36f8d6d..5884b8594 100644 --- a/lib/pharos/cluster_manager.rb +++ b/lib/pharos/cluster_manager.rb @@ -97,6 +97,7 @@ def apply_phases apply_phase(Phases::PullMasterImages, master_hosts, parallel: true) apply_phase(Phases::ConfigureMaster, master_hosts, parallel: false) + apply_phase(Phases::ConfigureServiceAccount, master_hosts, parallel: false) apply_phase(Phases::ConfigureClient, master_only, parallel: false) apply_phase(Phases::ReconfigureKubelet, config.hosts, parallel: true) diff --git a/lib/pharos/kube/config.rb b/lib/pharos/kube/config.rb index f3df344dd..66a51399b 100644 --- a/lib/pharos/kube/config.rb +++ b/lib/pharos/kube/config.rb @@ -18,12 +18,12 @@ def initialize(content = nil) def config @config ||= yaml_content || { 'apiVersion' => 'v1', + 'kind' => 'Config', 'clusters' => [], 'contexts' => [], - 'current-context' => nil, - 'kind' => 'Config', + 'users' => [], 'preferences' => {}, - 'users' => [] + 'current-context' => nil } end alias to_h config @@ -31,7 +31,7 @@ def config # Convert to YAML # @return [String] def dump - YAML.dump(config) + YAML.dump(JSON.parse(JSON.dump(config))) # dereference to get rid of *1 &1 etc in output end alias to_s dump diff --git a/lib/pharos/phases/configure_client.rb b/lib/pharos/phases/configure_client.rb index ca720817a..d96be4fc7 100644 --- a/lib/pharos/phases/configure_client.rb +++ b/lib/pharos/phases/configure_client.rb @@ -21,7 +21,11 @@ def call end def kubeconfig - @kubeconfig ||= transport.file(REMOTE_FILE) + @kubeconfig ||= user_config.exist? ? user_config : transport.file(REMOTE_FILE) + end + + def user_config + @user_config ||= transport.file('~/.kube/config', expand: true) end # @return [String] diff --git a/lib/pharos/phases/configure_master.rb b/lib/pharos/phases/configure_master.rb index 6347d8852..34db64e56 100644 --- a/lib/pharos/phases/configure_master.rb +++ b/lib/pharos/phases/configure_master.rb @@ -54,7 +54,6 @@ def install def install_kubeconfig transport.exec!('install -m 0700 -d ~/.kube') - transport.exec!('sudo install -o $USER -m 0600 /etc/kubernetes/admin.conf ~/.kube/config') end def reconfigure diff --git a/lib/pharos/phases/configure_service_account.rb b/lib/pharos/phases/configure_service_account.rb new file mode 100644 index 000000000..e67e69b3e --- /dev/null +++ b/lib/pharos/phases/configure_service_account.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Pharos + module Phases + class ConfigureServiceAccount < Pharos::Phase + title "Configure 'pharos-admin' service account" + + ADMIN_USER = 'pharos-admin' + KUBECONFIG_PARAM = '--kubeconfig=/etc/kubernetes/admin.conf' + + def call + logger.info "Creating service account" + create_service_account + logger.info "Creating cluster role binding" + create_cluster_role_binding + + config = build_config + + if config_file.exist? + logger.info "Merging existing configuration" + existing_config = Pharos::Kube::Config.new(config_file.read) + config << existing_config + end + + config_file.write(config.dump, overwrite: true) + config_file.chmod('0600') + + logger.info "Testing new configuration" + validate + end + + def validate + # Validates that "kubectl" without sudo or setting KUBECONFIG / --kubeconfig works on the host + transport.exec!("kubectl get --kubeconfig=/root/.kube/config -n kube-system serviceaccount/#{ADMIN_USER}") + end + + def config_file + @config_file ||= transport.file(File.join(home_kube_dir.path, 'config')) + end + + # Get a real path to ~/.kube and mkdir + chmod it unless exists + def home_kube_dir + transport.file('~/.kube', expand: true).tap do |dir| + transport.exec!("mkdir '#{dir}' && chmod 0700 '#{dir}") unless dir.exist? + end + end + + # Sudo used because /etc/kubernetes/admin.conf is not user-readable + def create_service_account + transport.exec!("sudo kubectl get #{KUBECONFIG_PARAM} -n kube-system serviceaccount/#{ADMIN_USER} || sudo kubectl #{KUBECONFIG_PARAM} -n kube-system create serviceaccount #{ADMIN_USER}") + end + + def create_cluster_role_binding + transport.exec!("sudo kubectl get #{KUBECONFIG_PARAM} -n kube-system clusterrolebinding/pharos-cluster-admin || sudo kubectl create #{KUBECONFIG_PARAM} -n kube-system clusterrolebinding pharos-cluster-admin --clusterrole=cluster-admin --serviceaccount=kube-system:#{ADMIN_USER}") + end + + # @return token_name [String] + def token_name + transport.exec!("sudo kubectl -n kube-system #{KUBECONFIG_PARAM} get serviceaccount/#{ADMIN_USER} -o jsonpath='{.secrets[0].name}'") + end + + # @return token [String] + def token + @token ||= transport.exec!("sudo kubectl -n kube-system #{KUBECONFIG_PARAM} get secret #{token_name} -o jsonpath='{.data.token}' | base64 -d") + end + + # @return [Pharos::Kube::Config] + def build_config + config = Pharos::Kube::Config.new + config.config['clusters'] << { + 'cluster' => { + 'certificate-authority-data' => certificate_authority_data, + 'server' => "https://#{master_host.api_address}:6443" + }, + 'name' => @config.name + } + + config.config['users'] << { + 'user' => { + 'token' => token + }, + 'name' => ADMIN_USER + } + + config.config['contexts'] << { + 'context' => { + 'cluster' => @config.name, + 'user' => ADMIN_USER + }, + 'name' => context_name + } + + config.config['current-context'] = context_name + + config + end + + # @return [String] + def context_name + @context_name ||= "#{ADMIN_USER}@#{@config.name}" + end + + # @return [String] + def certificate_authority_data + # --flatten expands relative paths, --minify cleans out everything but the stuff needed by current_context + transport.exec!("sudo kubectl config view #{KUBECONFIG_PARAM} --raw --minify --flatten -o jsonpath='{.clusters[].cluster.certificate-authority-data}'") + end + end + end +end diff --git a/lib/pharos/transport/base.rb b/lib/pharos/transport/base.rb index c86c9a020..fc97926e3 100644 --- a/lib/pharos/transport/base.rb +++ b/lib/pharos/transport/base.rb @@ -90,9 +90,10 @@ def exec?(cmd, **options) end # @param path [String] + # @param (see Pharos::Transport::TransportFile#initialize) # @return [Pathname] - def file(path) - Pharos::Transport::TransportFile.new(self, path) + def file(path, **options) + Pharos::Transport::TransportFile.new(self, path, **options) end def closed? diff --git a/lib/pharos/transport/transport_file.rb b/lib/pharos/transport/transport_file.rb index 2f31aef9a..4f7c908b3 100644 --- a/lib/pharos/transport/transport_file.rb +++ b/lib/pharos/transport/transport_file.rb @@ -9,10 +9,13 @@ class TransportFile attr_reader :path # Initializes an instance of a remote file # @param [Pharos::Transport::Base] + # @param expand [Boolean] when true, relative and special paths like ~/.kube or $HOME/xyz will be converted to real paths # @param path [String] - def initialize(client, path) + def initialize(client, path, expand: false) @client = client - @path = path + @path = expand ? (self.class.new(client, path).readlink(escape: false, canonicalize: true) || path) : path + @path.freeze + freeze end @@ -37,12 +40,13 @@ def dirname end # @param content [String] + # @param overwrite [Boolean] use force to overwrite target # @return [Pharos::Transport::Command::Result] # @raise [Pharos::ExecError] - def write(content) + def write(content, overwrite: false) tmp = temp_file_path.shellescape @client.exec!( - "cat > #{tmp} && (sudo mv #{tmp} #{escaped_path} || (rm #{tmp}; exit 1))", + "cat > #{tmp} && (sudo mv #{'-f ' if overwrite}#{tmp} #{escaped_path} || (rm -f #{tmp}; exit 1))", stdin: content ) end @@ -76,10 +80,11 @@ def with_existing # Moves the current file to target path # @param target [String] + # @param overwrite [Boolean] use force to overwrite target # @return [Pharos::Transport::Command::Result] # @raise [Pharos::ExecError] - def move(target) - @client.exec!("sudo mv #{@path} #{target.shellescape}") + def move(target, overwrite: false) + @client.exec!("sudo mv #{'-f ' if overwrite}#{@path} #{target.shellescape}") end alias mv move @@ -100,10 +105,12 @@ def link(target) @client.exec!("sudo ln -s #{escaped_path} #{target.shellescape}") end + # @param escape [Boolean] escape file path + # @param canonicalize [Boolean] canonicalize by following every symlink in every component of the file name recursively; all but the last component must exist # @return [String, nil] # @raise [Pharos::ExecError] - def readlink - target = @client.exec!("readlink #{escaped_path} || echo").strip + def readlink(escape: true, canonicalize: false) + target = @client.exec!("readlink #{'-f ' if canonicalize}#{escape ? escaped_path : @path} || echo").strip return nil if target.empty? diff --git a/spec/pharos/phases/configure_client_spec.rb b/spec/pharos/phases/configure_client_spec.rb index 82017963c..495980a58 100644 --- a/spec/pharos/phases/configure_client_spec.rb +++ b/spec/pharos/phases/configure_client_spec.rb @@ -23,6 +23,7 @@ before(:each) do allow(host).to receive(:master_sort_score).and_return(0) allow(host).to receive(:transport).and_return(transport) + allow(transport).to receive(:file).with('~/.kube/config', expand: true).and_return(double(exist?: false)) allow(transport).to receive(:file).with('/etc/kubernetes/admin.conf').and_return(remote_file) allow(remote_file).to receive(:exist?).and_return(true) allow(kubeclient).to receive(:apis)