cloud-sh 1.0.0

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.
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cloud/sh/commands/base"
4
+
5
+ module Cloud
6
+ module Sh
7
+ module Commands
8
+ class Refresh < Base
9
+ attr_reader :aliases
10
+
11
+ def execute
12
+ Cloud::Sh::Providers::DigitalOcean.refresh_k8s_configs
13
+ build_aliases
14
+ save_aliases
15
+ end
16
+
17
+ def build_aliases
18
+ @aliases = {}
19
+ config.accounts.each do |account|
20
+ puts "Refreshing account #{account.name}"
21
+ build_ssh_aliases(account)
22
+ build_db_aliases(account)
23
+ build_k8s_aliases(account)
24
+ end
25
+ end
26
+
27
+ def save_aliases
28
+ print "Saving aliases ... "
29
+ aliases_text = aliases.map do |alias_name, cmd|
30
+ "alias #{alias_name}=\"#{cmd}\""
31
+ end.join("\n")
32
+ File.write(config.aliases_file, aliases_text)
33
+ puts "DONE"
34
+ end
35
+
36
+ def build_ssh_aliases(account)
37
+ print " Refreshing SSH aliases ... "
38
+ provider = cloud_provider(account)
39
+ provider.servers do |server|
40
+ add_alias(:do, account, :ssh, server.name, "ssh #{server.ip}")
41
+ end
42
+ puts "DONE"
43
+ end
44
+
45
+ def build_db_aliases(account)
46
+ print " Refreshing DB aliases ... "
47
+ provider = cloud_provider(account)
48
+ provider.databases do |database|
49
+ next if database.cluster.ignore
50
+ if database.cluster.engine == "pg"
51
+ add_alias(:do, account, :psql, database.cluster, database.name, "psql \\\"#{database.uri}\\\"")
52
+ add_alias(:do, account, :pgdump, database.cluster, database.name, pgdump_command(database))
53
+ add_alias(:do, account, :pgcli, database.cluster, database.name, "pgcli \\\"#{database.uri}\\\"")
54
+ elsif database.cluster.engine == "mysql"
55
+ add_alias(:do, account, :mysql, database.cluster, database.name, mysql_command(database))
56
+ add_alias(:do, account, :mysqldump, database.cluster, database.name, mysqldump_command(database))
57
+ add_alias(:do, account, :mycli, database.cluster, database.name, "pgcli \\\"#{database.uri}\\\"")
58
+ elsif database.cluster.engine == "redis"
59
+ add_alias(:do, account, :redis, database.cluster, database.name, "redli -u \\\"#{database.uri}\\\"")
60
+ else
61
+ puts "Don't know how to handle database engine #{database.cluster.engine}"
62
+ end
63
+ end
64
+ puts "DONE"
65
+ end
66
+
67
+ def build_k8s_aliases(account)
68
+ print " Refreshing K8S aliases ... "
69
+ add_alias(:k8s, account, :ctl, kubectl)
70
+ provider = cloud_provider(account)
71
+ provider.clusters do |cluster|
72
+ add_alias(:k8s, account, :switch, :to, cluster, kubectl("config use-context", cluster.context))
73
+ add_alias(:k8s, account, :ctl, cluster, kubectl("--context #{cluster.context}"))
74
+ cluster.pods.each do |namespace, pods|
75
+ add_alias(:k8s, account, :tail, namespace, :all, "cloud-sh k8s tail --context #{cluster.context} --namespace #{namespace}")
76
+ pods.each do |pod|
77
+ add_alias(:k8s, account, :tail, namespace, pod.name, "cloud-sh k8s tail --context #{cluster.context} --namespace #{namespace} --pod #{pod.name}") unless pod.name == "console"
78
+ add_alias(:k8s, account, :exec, namespace, pod.name, "cloud-sh k8s exec --context #{cluster.context} --namespace #{namespace} --pod #{pod.name}")
79
+ add_alias(:k8s, account, namespace, :rails, :console, "cloud-sh k8s exec --context #{cluster.context} --namespace #{namespace} --pod #{pod.name} --cmd 'bundle exec rails console'") if pod.name == "console"
80
+ end
81
+ end
82
+ end
83
+ puts "DONE"
84
+ end
85
+
86
+ def kubectl(*parts)
87
+ [
88
+ "kubectl",
89
+ "--kubeconfig=#{Cloud::Sh::Providers::DigitalOcean.kube_config}",
90
+ parts
91
+ ].flatten.join(" ")
92
+ end
93
+
94
+ def mysql_command(database)
95
+ uri = URI.parse(database.uri)
96
+ [ :mysql, mysql_connection_params(uri), uri.path.delete("/")].join(" ")
97
+ end
98
+
99
+ def mysqldump_command(database)
100
+ uri = URI.parse(database.uri)
101
+ dump_name = "#{database.db}-`date +%s`.sql"
102
+ [ :mysqldump, mysql_connection_params(uri), uri.path.delete("/"), "> #{dump_name}"].join(" ")
103
+ end
104
+
105
+ def mysql_connection_params(uri)
106
+ [
107
+ "--host=#{uri.host}",
108
+ "--user=#{uri.user}",
109
+ "--password=#{uri.password}",
110
+ "--port=#{uri.port}",
111
+ "--ssl-mode=REQUIRED"
112
+ ].join(" ")
113
+ end
114
+
115
+ def pgdump_command(database)
116
+ dump_name = "#{database.db}-`date +%s`.sql"
117
+ "pg_dump \\\"#{database.uri}\\\" -f #{dump_name}"
118
+ end
119
+
120
+ def add_alias(*parts, cmd)
121
+ alias_name = parts.map { |part| normalize_alias_part(part) }.compact.join("-")
122
+ aliases[alias_name] = cmd
123
+ end
124
+
125
+ def normalize_alias_part(part)
126
+ return nil if part.respond_to?(:default) && part.default
127
+ if part.respond_to?(:alias)
128
+ part = part.alias
129
+ elsif part.respond_to?(:name)
130
+ part = part.name
131
+ elsif part.respond_to?(:to_s)
132
+ part = part.to_s
133
+ end
134
+ part.tr("._", "-")
135
+ end
136
+
137
+ def cloud_provider(account)
138
+ @cloud_providers ||= {}
139
+ @cloud_providers[account] ||= Cloud::Sh::Providers.build(account)
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloud
4
+ module Sh
5
+ class Config
6
+ attr_reader :accounts
7
+
8
+ def initialize
9
+ @accounts = []
10
+ read_config
11
+ end
12
+
13
+ def read_config
14
+ return unless File.exist?(config_file)
15
+ config = YAML.safe_load(File.read(config_file))
16
+ config.each do |account_config|
17
+ accounts << Account.new(account_config)
18
+ end
19
+ end
20
+
21
+ def config_file
22
+ File.expand_path(".config/cloud-sh.yml", "~")
23
+ end
24
+
25
+ def aliases_file
26
+ File.expand_path(".cloud_sh_aliases", "~/")
27
+ end
28
+ end
29
+
30
+ class Account
31
+ attr_reader :name, :kind, :context, :default, :clusters, :databases
32
+
33
+ def initialize(config)
34
+ @name = config["name"]
35
+ @kind = config["kind"]
36
+ @context = config["context"]
37
+ @default = config.key?("default") && !!config["default"]
38
+ @clusters = []
39
+ @databases = []
40
+ load_clusters(config)
41
+ load_databases(config)
42
+ end
43
+
44
+ def load_clusters(config)
45
+ return unless config.key?("clusters")
46
+ config["clusters"].each do |cluster_config|
47
+ clusters << Cluster.new(cluster_config)
48
+ end
49
+ end
50
+
51
+ def load_databases(config)
52
+ return unless config.key?("databases")
53
+ config["databases"].each do |database_config|
54
+ databases << Database.new(database_config)
55
+ end
56
+ end
57
+
58
+ def find_cluster(name)
59
+ clusters.find { |cluster| cluster.name == name }
60
+ end
61
+
62
+ def find_database(name)
63
+ databases.find { |database| database.name == name }
64
+ end
65
+
66
+ def ignore_database?(name)
67
+ databases.any? do |database|
68
+ database.name == name && database.ignore
69
+ end
70
+ end
71
+ end
72
+ class Cluster
73
+ attr_reader :name, :alias, :default, :ignore
74
+
75
+ def initialize(config)
76
+ @name = config["name"]
77
+ @alias = config["alias"] || @name
78
+ @default = config.key?("default") && !!config["default"]
79
+ @ignore = config.key?("ignore") && !!config["ignore"]
80
+ end
81
+
82
+ def enrich(object)
83
+ object.alias = @alias
84
+ object.default = default
85
+ object.ignore = ignore
86
+ end
87
+ end
88
+
89
+ class Database
90
+ attr_reader :name, :alias, :default, :ignore
91
+
92
+ def initialize(config)
93
+ @name = config["name"]
94
+ @alias = config["alias"] || @name
95
+ @default = config.key?("default") && !!config["default"]
96
+ @ignore = config.key?("ignore") && !!config["ignore"]
97
+ end
98
+
99
+ def enrich(object)
100
+ object.alias = @alias
101
+ object.default = default
102
+ object.ignore = ignore
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloud
4
+ module Sh
5
+ module Helpers
6
+ module Commands
7
+
8
+ def command_chain(base)
9
+ CmdChain.new(base)
10
+ end
11
+
12
+ class CmdChain
13
+ def initialize(base)
14
+ @cmd = [base]
15
+ end
16
+
17
+ def with(val)
18
+ @cmd << val
19
+ self
20
+ end
21
+
22
+ def method_missing(name, *args)
23
+ if args.empty?
24
+ @cmd << name.to_s.tr("_", "-")
25
+ elsif args.first.is_a?(TrueClass)
26
+ @cmd << "--#{name.to_s.tr("_", "-")}"
27
+ else
28
+ @cmd << "--#{name.to_s.tr("_", "-")}=#{args.first}"
29
+ end
30
+ self
31
+ end
32
+
33
+ def map(*fields)
34
+ execute.lines.map do |line|
35
+ values = line.split.first(fields.size)
36
+ OpenStruct.new(fields.zip(values).to_h)
37
+ end
38
+ end
39
+
40
+ def replace_current_process
41
+ exec(@cmd.join(" "))
42
+ end
43
+
44
+ def execute
45
+ cloud_sh_exec(@cmd)
46
+ end
47
+
48
+ def to_s
49
+ @cmd.join(" ")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloud
4
+ module Sh
5
+ module Providers
6
+ def self.providers
7
+ @providers ||= {}
8
+ end
9
+
10
+ def self.add_provider(name, klass)
11
+ providers[name] = klass
12
+ end
13
+
14
+ def self.build(account)
15
+ return providers[account.kind].new(account) if providers.key?(account.kind)
16
+ raise ArgumentError, "Don't know account kind #{account.kind} for account #{account.inspect}"
17
+ end
18
+
19
+ class Base
20
+ include Cloud::Sh::Helpers::Commands
21
+
22
+ attr_reader :account
23
+
24
+ def initialize(account)
25
+ @account = account
26
+ end
27
+
28
+ def servers
29
+ raise NotImplementedError
30
+ end
31
+
32
+ def databases
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def clusters
37
+ raise NotImplementedError
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cloud/sh/providers/base"
4
+ require "open3"
5
+
6
+ module Cloud
7
+ module Sh
8
+ module Providers
9
+ class DigitalOcean < Base
10
+ def self.refresh_k8s_configs
11
+ return if File.exist?(kube_config) && (Time.now.to_i - File.mtime(kube_config).to_i) < 3600
12
+ configs = Cloud::Sh.config.accounts.map { |account| new(account).k8s_configs }.flatten.compact
13
+ config = configs.shift
14
+ configs.each do |cfg|
15
+ config["clusters"] += cfg["clusters"]
16
+ config["contexts"] += cfg["contexts"]
17
+ config["users"] += cfg["users"]
18
+ end
19
+ config["current-context"] = config["contexts"].first["name"]
20
+ File.write(kube_config, YAML.dump(config))
21
+ end
22
+
23
+ def self.kube_config
24
+ File.expand_path(".kube/cloud_sh_config", "~/")
25
+ end
26
+
27
+ def servers
28
+ doctl.compute.droplet.list.format("Name,PublicIPv4").no_header(true).map(:name, :ip).each do |server|
29
+ yield server if block_given?
30
+ end
31
+ end
32
+
33
+ def databases
34
+ list = []
35
+ doctl.db.list.no_header(true).map(:id, :name, :engine).each do |cluster|
36
+ account.find_database(cluster.name)&.enrich(cluster)
37
+ defaultdb_uri = doctl.db.conn.with(cluster.id).format("URI").no_header(true).map(:uri).first.uri
38
+ dbs = [OpenStruct.new(name: "")] if cluster.engine == "redis"
39
+ dbs ||= doctl.db.db.list.with(cluster.id).no_header(true).map(:name)
40
+ dbs.each do |db|
41
+ uri = URI.parse(defaultdb_uri).tap { |uri| uri.path = "/#{db.name}" }.to_s
42
+ database = OpenStruct.new(cluster: cluster, name: db.name, uri: uri)
43
+ yield database if block_given?
44
+ list << database
45
+ end
46
+ end
47
+ list
48
+ end
49
+
50
+ def clusters
51
+ doctl.k8s.cluster.list.no_header(true).map(:id, :name).map do |cluster|
52
+ account.find_cluster(cluster.name)&.enrich(cluster)
53
+ next if cluster.ignore
54
+ cluster.context = k8s_context(account, cluster)
55
+ cluster.pods = kubectl.context(cluster.context).get.pod.all_namespaces(true).no_headers(true).map(:namespace, :name).map do |pod|
56
+ pod.name = k8s_pod_name(pod.name)
57
+ pod
58
+ end.group_by(&:itself).keys.group_by(&:namespace)
59
+ yield cluster if block_given?
60
+ end.compact
61
+ end
62
+
63
+ def k8s_configs
64
+ doctl.k8s.cluster.list.no_header(true).map(:id, :name).map do |cluster|
65
+ account.find_cluster(cluster.name)&.enrich(cluster)
66
+ next if cluster.ignore
67
+ cluster_config = YAML.load(doctl.k8s.cluster.config.show.with(cluster.id).execute)
68
+ cluster_config["contexts"].first["name"] = k8s_context(account, cluster)
69
+ cluster_config
70
+ end
71
+ end
72
+
73
+ def k8s_context(account, cluster)
74
+ [
75
+ account.name,
76
+ (cluster.alias unless cluster.default)
77
+ ].compact.join("-").tr("._", "-")
78
+ end
79
+
80
+ def k8s_pod_name(name)
81
+ parts = name.split("-")
82
+ parts.pop if parts.last =~ /^[a-z0-9]{5}$/
83
+ parts.pop if parts.last =~ /^[a-f0-9]{8,10}$/
84
+ parts.pop if parts.last =~ /^[a-f0-9]{8,10}$/
85
+ parts.join("-")
86
+ end
87
+
88
+ def doctl
89
+ command_chain("doctl").context(account.context)
90
+ end
91
+
92
+ def kubectl
93
+ command_chain("kubectl").kubeconfig(Cloud::Sh::Providers::DigitalOcean.kube_config)
94
+ end
95
+ end
96
+
97
+ add_provider("do", DigitalOcean)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloud
4
+ module Sh
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
data/lib/cloud/sh.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cloud/sh/version"
4
+ require "gli"
5
+
6
+ require "cloud/sh/config"
7
+
8
+ require "cloud/sh/helpers/commands"
9
+
10
+ require "cloud/sh/commands/refresh"
11
+ require "cloud/sh/commands/k8s_tail"
12
+ require "cloud/sh/commands/k8s_exec"
13
+
14
+ require "cloud/sh/providers/digital_ocean"
15
+
16
+ module Cloud
17
+ module Sh
18
+ class Error < StandardError; end
19
+ module_function
20
+
21
+ def config
22
+ @config ||= Cloud::Sh::Config.new
23
+ end
24
+ end
25
+ end
26
+
27
+ module Kernel
28
+ def cloud_sh_exec(*cmd, env: nil)
29
+ cmd = cmd.flatten.map(&:to_s).join(" ")
30
+ args = env ? [env, cmd] : [cmd]
31
+ message = "Executing: #{cmd}"
32
+ message << " (#{env.inspect})" if env
33
+ stdout, stderr, status = Open3.capture3(*args)
34
+ unless status.success?
35
+ puts "Command: #{cmd}"
36
+ puts "ENV: #{env.inspect}" if env
37
+ puts "Stdout:\n#{stdout}\n"
38
+ puts "Stderr:\n#{stderr}\n"
39
+ raise "Command failed!!!"
40
+ end
41
+ stdout
42
+ end
43
+ end
44
+
45
+ class OpenStruct
46
+ def merge(other)
47
+ other.each_pair do |k, v|
48
+ self[k] = v
49
+ end
50
+ end
51
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cloud-sh
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Cristian Bica
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2019-08-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: gli
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.17'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.17'
41
+ - !ruby/object:Gem::Dependency
42
+ name: minitest
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ description: Cloud shell helpers.
70
+ email:
71
+ - cristian.bica@gmail.com
72
+ executables:
73
+ - cloud-sh
74
+ - kubetail
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - ".github/workflows/gempush.yml"
79
+ - ".gitignore"
80
+ - ".rubocop.yml"
81
+ - ".travis.yml"
82
+ - Gemfile
83
+ - Gemfile.lock
84
+ - LICENSE.txt
85
+ - README.md
86
+ - Rakefile
87
+ - bin/console
88
+ - bin/setup
89
+ - cloud-sh.gemspec
90
+ - exe/cloud-sh
91
+ - exe/kubetail
92
+ - lib/cloud/sh.rb
93
+ - lib/cloud/sh/cli.rb
94
+ - lib/cloud/sh/commands/base.rb
95
+ - lib/cloud/sh/commands/k8s_exec.rb
96
+ - lib/cloud/sh/commands/k8s_tail.rb
97
+ - lib/cloud/sh/commands/refresh.rb
98
+ - lib/cloud/sh/config.rb
99
+ - lib/cloud/sh/helpers/commands.rb
100
+ - lib/cloud/sh/providers/base.rb
101
+ - lib/cloud/sh/providers/digital_ocean.rb
102
+ - lib/cloud/sh/version.rb
103
+ homepage: https://github.com/cristianbica/cloud-sh
104
+ licenses:
105
+ - MIT
106
+ metadata:
107
+ homepage_uri: https://github.com/cristianbica/cloud-sh
108
+ source_code_uri: https://github.com/cristianbica/cloud-sh
109
+ post_install_message:
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.0.3
125
+ signing_key:
126
+ specification_version: 4
127
+ summary: Cloud shell helpers.
128
+ test_files: []