pgai 0.2.4 → 1.0.0.alpha2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 638625e64ce9fa8f81c98f8a618b74f3d9f1e7ae14632bb4994a581011ad4bf3
4
- data.tar.gz: 4703d7e923b428ac933f72335af104b373b991d048abef7516248983d8dcfb25
3
+ metadata.gz: 8a9ded7a8c272d713343af77a8fa74c139d47f0bb478ce90bb8154205f9bed00
4
+ data.tar.gz: 5a2d6c82be207fce031a814bebe10e0015b08e79be471e7a526ee0272fb3f7e8
5
5
  SHA512:
6
- metadata.gz: f05388f1e73e3277a06baea0298bafd175a8e50959e3a0fe9580e099820e84bd13e1665c9f8841b0e46f437fea638728a11c510c332026834fa399144394b022
7
- data.tar.gz: dd1c62be57dd6fa39a4b9b685f390072eb6e51ebb9dc7a10627c7366ddf89521e9edca73b17def3e178d36108c889466e6ca95a038cb121635092084c7ff8646
6
+ metadata.gz: 71ec50b2d9622e2c65d50ceca0f6405133409b0aac40537084f97f86f10a88ddbce2696c775671d62ac08910d0c7d5695941510b22bae9413e0f5ec8281b2020
7
+ data.tar.gz: 78f9e1794a40b34ea0d2da5c892d4cbfb5e5883cfb42851b370e3e23f7ebab49cb23da2eea140966be4983d1cad780513eb98ba163125df16beabc06f1ba2fe5
data/README.md CHANGED
@@ -25,35 +25,31 @@ An access token will be required and it can be obtained from: https://console.po
25
25
  Example:
26
26
 
27
27
  ```shell
28
- pgai config --dbname=gitlabhq_dblab --prefix=<gitlab handle> --proxy=<alias> --exe=<optional /path/to/dblab>
28
+ pgai config --prefix=<gitlab handle>
29
29
  ```
30
30
 
31
31
  To configure environments:
32
32
 
33
33
  ```shell
34
- pgai env add --alias ci --id ci-database --port 12345
34
+ pgai env add --alias ci --id ci.lab.internal --port 2345 -n gitlabhq_dblab
35
35
  ```
36
36
 
37
- The environment id, port, and proxy domain can be found by clicking on the `Connect` button on a database instance page.
37
+ The environment id and port can be found by clicking on the `Connect` button on a database instance page.
38
38
 
39
- Configuring the proxy attributes must be done via the `~/.ssh/config` file. Example:
39
+ The the id attributes must be configured for SSH port forwarding via the `~/.ssh/config` file. Example:
40
40
 
41
- ```
42
- Host <alias>
43
- HostName <domain.postgres.ai>
41
+ ```shell
42
+ Host <bastion>
44
43
  User <username>
45
44
  IdentityFile ~/.ssh/id_ed25519
46
- ```
47
-
48
- ## dblab binary file
49
-
50
- This CLI is built around around the [`dblab`](https://postgres.ai/docs/how-to-guides/cli/cli-install-init) CLI and it looks for it at the following locations:
51
45
 
52
- - at the path specified in the config file if specified when running the `pgai config` command
53
- - in `$PATH`
54
- - at `~/.dblab/dblab`
55
46
 
56
- If it's not found at any of the specified locations, a copy will be downloaded to `~/.dblab/dblab`.
47
+ Host *.lab.internal
48
+ User <username>
49
+ PreferredAuthentications publickey
50
+ IdentityFile ~/.ssh/id_ed25519
51
+ ProxyCommand ssh <bastion> -W %h:%p
52
+ ```
57
53
 
58
54
  ## Usage
59
55
 
@@ -63,10 +59,10 @@ pgai connect <env alias>
63
59
 
64
60
  Multiple `connect` commands for an environment will connect to the same clone, it won't start a new one.
65
61
 
66
- `pgai info <env alias>` prints out a database URL variable that can be used from Rails to integrate with dblab. Example for `CI` database:
62
+ `pgai use --only <env alias> -- command` can be used to run commands with access to a clone. Example for connecting a Rails console to the `CI` database:
67
63
 
68
64
  ```shell
69
- CI_DATABASE_URL="postgresql://foo:bar@localhost:9000/foo_test" bin/rails c -e test
65
+ pgai use -o ci -v -- bin/rails c -e test
70
66
  ```
71
67
 
72
68
  ### Features
@@ -76,6 +72,14 @@ CI_DATABASE_URL="postgresql://foo:bar@localhost:9000/foo_test" bin/rails c -e te
76
72
  - automatic port forward management
77
73
  - prevents system sleep while psql sessions are active via `caffeinate`
78
74
 
75
+ ## Upgrading from version 0
76
+
77
+ - Update and test the ssh config file
78
+ - `~/.dblab/` and its contents can be removed
79
+ - `~/.config/pgai/config.pstore` should be removed and recreated with `pgai config`
80
+ - The environment ID serves as the proxy host and the database name can be configured per environment.
81
+ - Environments can share the same remote port value.
82
+
79
83
  ## Contributing
80
84
 
81
85
  Bug reports and pull requests are welcome on GitLab at https://gitlab.com/mbobin/pgai.
data/lib/pgai/cli/base.rb CHANGED
@@ -2,20 +2,23 @@ require "thor"
2
2
 
3
3
  module Pgai::Cli
4
4
  class Base < Thor
5
- def self.exit_on_failure?
6
- true
7
- end
5
+ def self.exit_on_failure? = true
8
6
 
9
- private
7
+ check_unknown_options!
8
+
9
+ class_option :verbose, type: :boolean, aliases: "-v", desc: "Detailed logging"
10
+ class_option :quiet, type: :boolean, aliases: "-q", desc: "Minimal logging"
10
11
 
11
- def configuration
12
- @configuration ||= load_and_validate_config!
12
+ def initialize(*)
13
+ super
14
+
15
+ Pgai::Commander.configure(options_with_subcommand_class_options)
13
16
  end
14
17
 
15
- def load_and_validate_config!
16
- Pgai::Config.load.tap do |config|
17
- raise "Access token is not set" unless config.access_token
18
- end
18
+ private
19
+
20
+ def options_with_subcommand_class_options
21
+ options.merge(@_initializer.last[:class_options] || {})
19
22
  end
20
23
  end
21
24
  end
data/lib/pgai/cli/env.rb CHANGED
@@ -1,22 +1,42 @@
1
1
  module Pgai::Cli
2
2
  class Env < Base
3
3
  desc "add", "Add new environment"
4
- method_option :alias, aliases: "-a", desc: "This will be used internally for the connect command", required: true
5
- method_option :id, aliases: "-i", desc: "Environment id from postgres.ai", required: true
6
- method_option :port, aliases: "-p", desc: "Environment port from postgres.ai", required: true
4
+ method_option :alias, aliases: "-a",
5
+ desc: "This will be used internally for the connect command",
6
+ required: true
7
+ method_option :id,
8
+ aliases: "-i",
9
+ desc: "Environment id from postgres.ai, example: lab-ci.db-lab.internal",
10
+ required: true
11
+ method_option :port,
12
+ aliases: "-p",
13
+ desc: "Environment port from postgres.ai",
14
+ required: true
15
+ method_option :dbname,
16
+ aliases: "-n",
17
+ desc: "Specify database name to connect to by default",
18
+ required: true,
19
+ default: "postgres"
7
20
  def add
8
- configuration.add_env(**options)
21
+ Pgai::Resources::Local::Environment.new(options).save
9
22
  end
10
23
 
11
24
  desc "remove", "Remove the specified environment from the config"
12
- method_option :alias, aliases: "-a", desc: "This will be used internally for the connect command", required: true
25
+ method_option :alias,
26
+ aliases: "-a",
27
+ desc: "The alias used for registering the environment",
28
+ required: true
13
29
  def remove
14
- configuration.remove_env(options[:alias])
30
+ Pgai::Resources::Local::Environment.delete(options[:alias])
15
31
  end
16
32
 
17
33
  desc "list", "List all configured environments"
18
34
  def list
19
- puts JSON.pretty_generate(configuration.enviroments)
35
+ data = Pgai::Resources::Local::Environment.all.map do |env|
36
+ env.attributes.slice(:id, :alias, :port, :dbname)
37
+ end
38
+
39
+ say JSON.pretty_generate(data)
20
40
  end
21
41
  end
22
42
  end
data/lib/pgai/cli/main.rb CHANGED
@@ -2,60 +2,50 @@ module Pgai::Cli
2
2
  class Main < Base
3
3
  desc "config", "Configure CLI options"
4
4
  method_option :prefix, aliases: "-p", desc: "Specify prefix name to be used for clones", required: true
5
- method_option :dbname, aliases: "-n", desc: "Specify database name to connect to by default", required: true, default: "postgres"
6
- method_option :proxy, aliases: "-x", desc: "Specify proxy host name", required: true
7
- method_option :exe, aliases: "-e", desc: "dblab binary path"
8
5
  def config
9
6
  token = ask("Access token:", echo: false)
10
7
 
11
- Pgai::Config.persist(options.merge(token: token))
8
+ Pgai::Resources::Local::Configuration
9
+ .new(clone_prefix: options[:prefix], access_token: token)
10
+ .save
12
11
  end
13
12
 
14
13
  desc "connect database-name", "Create and connect to a thin clone database"
15
14
  def connect(name)
16
- env = configuration.enviroments.fetch(name)
17
-
18
- Pgai::CloneManager.new(env, config: configuration).connect
15
+ with_env(name) do |env|
16
+ Pgai::CloneManager.new(env).connect
17
+ end
19
18
  end
20
19
 
21
20
  desc "destroy database-name", "Remove the thin clone and all port forwards"
22
21
  def destroy(name)
23
- env = configuration.enviroments.fetch(name)
24
-
25
- Pgai::CloneManager.new(env, config: configuration).cleanup
22
+ with_env(name) do |env|
23
+ Pgai::CloneManager.new(env).cleanup
24
+ end
26
25
  end
27
26
 
28
27
  desc "reset database-name", "Reset clone's state"
29
28
  def reset(name)
30
- env = configuration.enviroments.fetch(name)
31
-
32
- Pgai::CloneManager.new(env, config: configuration).reset
33
- end
34
-
35
- desc "info database-name", "Show clone details"
36
- def info(name)
37
- env = configuration.enviroments.fetch(name)
38
-
39
- data = Pgai::CloneManager.new(env, config: configuration).info
40
- print_table data
29
+ with_env(name) do |env|
30
+ Pgai::CloneManager.new(env).reset
31
+ end
41
32
  end
42
33
 
43
34
  desc "use", "Execute the given command with DATABASE_URLs"
44
35
  method_option :only, aliases: "-o", desc: "Export only this database connection", repeatable: true
45
36
  def use(*command)
46
- envs = options.fetch(:only) { configuration.enviroments.keys }
47
-
48
- vars = envs.each_with_object({}) do |env, acc|
49
- environment = configuration.enviroments.fetch(env)
50
- clone = Pgai::CloneManager.new(environment, config: configuration).clone
51
-
52
- acc["#{env.to_s.upcase}_DATABASE_URL"] = clone.database_url
53
- end
37
+ envs = options.fetch(:only) { Pgai::Resources::Local::Environment.all.map(&:alias) }
54
38
 
55
- exec(vars, *command)
39
+ Pgai::ExternalCommandManager.new(envs, command).run
56
40
  end
57
41
 
58
42
  desc "env", "Manage environments"
59
43
  subcommand "env", Pgai::Cli::Env
44
+
45
+ private
46
+
47
+ def with_env(name, &block)
48
+ Pgai::Commander.instance.with_env(name, &block)
49
+ end
60
50
  end
61
51
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "excon"
4
+
5
+ module Pgai
6
+ class Client
7
+ def initialize(token:, host: "http://127.0.0.1:2355")
8
+ @client = Excon.new(host, headers: {"Verification-Token" => token})
9
+ end
10
+
11
+ def version
12
+ get("healthz").fetch(:version)
13
+ end
14
+
15
+ def expected_cloning_time
16
+ get("status").dig(:cloning, :expected_cloning_time)
17
+ end
18
+
19
+ def get(path)
20
+ response = client.get(path: path)
21
+ parse_reponse(response.body)
22
+ end
23
+
24
+ def post(path, body = nil)
25
+ response = if body
26
+ client.post(path: path, body: JSON.dump(body))
27
+ else
28
+ client.post(path: path)
29
+ end
30
+ return {} if response.body.empty?
31
+
32
+ parse_reponse(response.body)
33
+ end
34
+
35
+ def delete(path)
36
+ response = client.delete(path: path)
37
+ return {} if response.body.empty?
38
+
39
+ parse_reponse(response.body)
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :client
45
+
46
+ def parse_reponse(data)
47
+ data = JSON.parse(data)
48
+ transform!(data)
49
+ data
50
+ end
51
+
52
+ def transform_hash!(data)
53
+ data.transform_keys! { |key| key.to_s.gsub(/(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-z\d])(?=[A-Z])/, "_").downcase.to_sym }
54
+ data.each_value { |value| transform!(value) }
55
+ end
56
+
57
+ def transform_array!(data)
58
+ data.each { transform!(_1) }
59
+ end
60
+
61
+ def transform!(data)
62
+ if data.is_a?(Hash)
63
+ transform_hash!(data)
64
+ elsif data.is_a?(Array)
65
+ transform_array!(data)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,151 +1,81 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
-
5
3
  module Pgai
6
4
  class CloneManager
7
- HOSTNAME = "127.0.0.1"
8
-
9
- def initialize(environment, config:)
10
- @environment = environment
11
- @config = config
12
- @port_forward = PortForward.new(config: config, hostname: HOSTNAME)
13
- @dblab = Dblab.new(config: config, hostname: HOSTNAME)
5
+ def initialize(env)
6
+ @env = env
7
+ @logger = Pgai::Commander.instance.logger
14
8
  end
15
9
 
16
10
  def connect
17
- configure_enviroment
11
+ prepare do |cached_clone|
12
+ PsqlManager.new(
13
+ cached_clone,
14
+ logger: logger
15
+ ).run
16
+ end
17
+ end
18
18
 
19
- psql find_or_create_clone
19
+ def prepare
20
+ find_or_create_clone.with_port_forward do |cached_clone|
21
+ yield cached_clone
22
+ end
20
23
  end
21
24
 
22
25
  def cleanup
23
- configure_enviroment
24
- return unless find_raw_clone
26
+ return unless clone_already_created?
25
27
 
26
- dblab.destroy_clone(id: clone_id)
27
- config.remove_clone(clone_id)
28
- port_forward.stop
28
+ Resources::Local::Clone.delete(clone_id)
29
+ Resources::Remote::Clone.find(clone_id).tap do |resource|
30
+ resource.delete
31
+ resource.refresh
32
+ logger.info resource.status_message
33
+ end
29
34
  end
30
35
 
31
36
  def reset
32
- configure_enviroment
33
- return unless find_raw_clone
34
-
35
- dblab.reset_clone(id: clone_id)
36
- end
37
-
38
- def info
39
- configure_enviroment
40
-
41
- clone = find_clone
42
- return {} unless clone
37
+ return unless clone_already_created?
43
38
 
44
- database_url = "#{enviroment_alias.to_s.upcase}_DATABASE_URL='#{clone.database_url}'"
45
-
46
- {
47
- psql_connection_string: clone.connection_string,
48
- rails_database_url: database_url,
49
- created_at: clone.created_at,
50
- data_state_at: clone.data_state_at
51
- }
52
- end
53
-
54
- def clone
55
- configure_enviroment
56
- clone = find_or_create_clone
57
- port_forward.start(clone.port)
58
- clone
39
+ Resources::Remote::Clone.find(clone_id).tap do |resource|
40
+ resource.reset
41
+ logger.info resource.status_message
42
+ end
59
43
  end
60
44
 
61
45
  private
62
46
 
63
- attr_reader :environment, :config, :port_forward, :dblab
47
+ attr_reader :env, :logger
64
48
 
65
- def configure_enviroment
66
- port_forward.start(enviroment_port)
67
- dblab.configure_env(port: enviroment_port, token: config.access_token, id: environment_id)
49
+ def clone_id
50
+ env.clone_id
68
51
  end
69
52
 
70
53
  def find_or_create_clone
71
- find_clone || create_clone
54
+ find_cached_clone || create_clone
72
55
  end
73
56
 
74
- def find_clone
75
- raw_clone = find_raw_clone
76
-
77
- if raw_clone
78
- config.find_clone(clone_id) || raise(Pgai::CloneNotFount, "clone not tracked")
57
+ def find_cached_clone
58
+ if clone_already_created?
59
+ Resources::Local::Clone.find(clone_id) || raise_unknown_clone
79
60
  else
80
- config.remove_clone(clone_id) && nil
61
+ Resources::Local::Clone.delete(clone_id) && nil
81
62
  end
82
63
  end
83
64
 
84
- def find_raw_clone
85
- dblab.list_clones.find { |clone| clone["id"] == clone_id }
86
- end
87
-
88
65
  def create_clone
89
- raw_clone = dblab.create_clone(id: clone_id, user: clone_user, password: clone_password)
90
-
91
- attributes = {
92
- port: raw_clone.dig("db", "port"),
93
- password: clone_password,
94
- user: clone_user,
95
- host: HOSTNAME,
96
- dbname: config.dbname,
97
- created_at: raw_clone["createdAt"],
98
- data_state_at: raw_clone.dig("snapshot", "dataStateAt")
99
- }
100
-
101
- config.persist_clone(clone_id, attributes)
102
- end
103
-
104
- def psql(clone)
105
- puts "Data state at: #{clone.data_state_at}"
106
- port_forward.start(clone.port)
107
-
108
- psql_pid = fork do
109
- exec("psql #{clone.connection_string}")
110
- end
111
-
112
- Signal.trap("INT") {}
113
- start_caffeinate(psql_pid)
114
- Process.wait(psql_pid)
115
- ensure
116
- port_forward.conditionally_stop(clone.port, [psql_pid])
117
- port_forward.conditionally_stop(enviroment_port)
118
- end
119
-
120
- def environment_id
121
- environment.fetch(:id)
122
- end
123
-
124
- def enviroment_port
125
- environment.fetch(:port)
66
+ CreateCloneService.new(clone_id, env: env, logger: logger).execute
126
67
  end
127
68
 
128
- def enviroment_alias
129
- environment.fetch(:alias)
69
+ def raise_unknown_clone
70
+ raise(Pgai::ResourceNotFound, <<~MESSAGE)
71
+ Unknown clone. Remove it and retry.
72
+ MESSAGE
130
73
  end
131
74
 
132
- def clone_id
133
- @clone_id ||= [config.clone_prefix, enviroment_alias].join("_")
134
- end
135
-
136
- def clone_user
137
- @clone_user ||= SecureRandom.hex(8)
138
- end
139
-
140
- def clone_password
141
- @clone_password ||= SecureRandom.hex(16)
142
- end
143
-
144
- def start_caffeinate(pid)
145
- return if `which caffeinate`.to_s.empty?
146
-
147
- caffeinate_pid = Process.spawn("caffeinate -is -w #{pid}")
148
- Process.detach(caffeinate_pid)
75
+ def clone_already_created?
76
+ !!Resources::Remote::Clone.find(clone_id)
77
+ rescue Pgai::ResourceNotFound
78
+ false
149
79
  end
150
80
  end
151
81
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-logger"
4
+ require "singleton"
5
+ require "forwardable"
6
+
7
+ module Pgai
8
+ class Commander
9
+ include Singleton
10
+ extend Forwardable
11
+
12
+ attr_reader :verbosity
13
+ def_delegators :logger, *TTY::Logger::LOG_TYPES.keys
14
+
15
+ def self.configure(options)
16
+ instance.tap do |commander|
17
+ if options[:verbose]
18
+ commander.verbosity = :debug
19
+ end
20
+
21
+ if options[:quiet]
22
+ commander.verbosity = :error
23
+ end
24
+ end
25
+ end
26
+
27
+ def initialize
28
+ self.verbosity = :info
29
+ end
30
+
31
+ def store
32
+ @store ||= Pgai::Store.new
33
+ end
34
+
35
+ def config
36
+ @config ||= Pgai::Resources::Local::Configuration.default
37
+ end
38
+
39
+ def configure
40
+ yield self
41
+ end
42
+
43
+ def verbosity=(verbosity)
44
+ @verbosity = verbosity
45
+
46
+ TTY::Logger.configure do |config|
47
+ config.level = verbosity
48
+ end
49
+ end
50
+
51
+ def logger
52
+ @logger ||= TTY::Logger.new
53
+ end
54
+
55
+ def port_manager
56
+ @port_manager ||= Port::Manager.new(logger: logger)
57
+ end
58
+
59
+ def check_access_token_presence!
60
+ raise "Access token is not set" unless config&.access_token
61
+ end
62
+
63
+ def with_env(env_name, &block)
64
+ check_access_token_presence!
65
+
66
+ env = Resources::Local::Environment.find(env_name) do |env|
67
+ env.access_token = config.access_token
68
+ env.clone_prefix = config.clone_prefix
69
+ end
70
+
71
+ env.prepare(&block)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-progressbar"
4
+
5
+ module Pgai
6
+ class CreateCloneService
7
+ HOSTNAME = "127.0.0.1"
8
+ CLONE_REFRESH_INTERVAL = 0.2
9
+
10
+ attr_reader :id, :env, :logger
11
+
12
+ def initialize(id, env:, logger:)
13
+ @id = id
14
+ @env = env
15
+ @logger = logger
16
+ end
17
+
18
+ def execute
19
+ create_clone_resource
20
+ logger.debug(clone_resource.status_message)
21
+ progress_bar.advance
22
+ wait_for_clone_to_be_ready
23
+ logger.debug(clone_resource.status_message)
24
+
25
+ Resources::Local::Clone.new(cached_clone_attributes).tap(&:save)
26
+ end
27
+
28
+ def create_clone_resource
29
+ @clone_resource = Resources::Remote::Clone.new(id: id).tap do |resource|
30
+ resource.db_object = Resources::Remote::DbObject.new(db_name: env.dbname)
31
+ resource.snapshot = Resources::Remote::Snapshot.latest
32
+ resource.save
33
+ end
34
+ end
35
+
36
+ def cached_clone_attributes
37
+ {
38
+ id: id,
39
+ port: clone_resource.db_object.port,
40
+ password: clone_resource.db_object.password,
41
+ user: clone_resource.db_object.username,
42
+ host: HOSTNAME,
43
+ remote_host: env.id,
44
+ dbname: env.dbname,
45
+ created_at: clone_resource.created_at,
46
+ data_state_at: clone_resource.snapshot.data_state_at
47
+ }
48
+ end
49
+
50
+ def clone_resource
51
+ @clone_resource ||= create_clone_resource
52
+ end
53
+
54
+ def wait_for_clone_to_be_ready
55
+ loop do
56
+ clone_resource.refresh
57
+ progress_bar.advance
58
+ break if clone_resource.ready?
59
+ sleep(CLONE_REFRESH_INTERVAL)
60
+ end
61
+ progress_bar.finish
62
+ end
63
+
64
+ def progress_bar
65
+ @progress_bar ||= TTY::ProgressBar.new(
66
+ "Preparing clone ... [:bar]",
67
+ total: progress_bar_steps
68
+ )
69
+ end
70
+
71
+ def progress_bar_steps
72
+ (env.expected_cloning_time / CLONE_REFRESH_INTERVAL).ceil + 1
73
+ end
74
+ end
75
+ end