pgai 0.2.4 → 1.0.0.alpha2

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.
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